* MCP to CustomTool bridge.
*
* Converts MCP tool definitions to CustomTool format for the agent.
*/
import type { AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
import type { TSchema } from "@oh-my-pi/pi-ai";
import { normalizeSchemaForMCP } from "@oh-my-pi/pi-ai/utils/schema";
import { untilAborted } from "@oh-my-pi/pi-utils";
import type { SourceMeta } from "../capability/types";
import type {
CustomTool,
CustomToolContext,
CustomToolResult,
RenderResultOptions,
} from "../extensibility/custom-tools/types";
import type { Theme } from "../modes/theme/theme";
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
import { callTool } from "./client";
import { renderMCPCall, renderMCPResult } from "./render";
import type { MCPContent, MCPServerConnection, MCPToolCallParams, MCPToolCallResult, MCPToolDefinition } from "./types";
export type MCPReconnect = () => Promise<MCPServerConnection | null>;
* Network-level and stale-session errors that warrant a reconnect + single retry.
* Conservative: only catches errors where the server is likely alive but the
* connection object is stale (dead SSE, expired session, refused after restart).
*/
const RETRIABLE_PATTERNS = [
"econnrefused",
"econnreset",
"epipe",
"enetunreach",
"ehostunreach",
"fetch failed",
"transport not connected",
"transport closed",
"network error",
];
export function isRetriableConnectionError(error: unknown): boolean {
if (!(error instanceof Error)) return false;
const msg = error.message.toLowerCase();
if (/^http (404|502|503):/.test(msg)) return true;
return RETRIABLE_PATTERNS.some(p => msg.includes(p));
}
type MCPToolArgs = NonNullable<MCPToolCallParams["arguments"]>;
function normalizeToolArgs(value: unknown): MCPToolArgs {
if (typeof value !== "object" || value === null || Array.isArray(value)) {
return {};
}
return value as MCPToolArgs;
}
export interface MCPToolDetails {
serverName: string;
mcpToolName: string;
isError?: boolean;
rawContent?: MCPContent[];
provider?: string;
providerName?: string;
}
* Format MCP content for LLM consumption.
*/
function formatMCPContent(content: MCPContent[]): string {
const parts: string[] = [];
for (const item of content) {
switch (item.type) {
case "text":
parts.push(item.text);
break;
case "image":
parts.push(`[Image: ${item.mimeType}]`);
break;
case "resource":
if (item.resource.text) {
parts.push(`[Resource: ${item.resource.uri}]\n${item.resource.text}`);
} else {
parts.push(`[Resource: ${item.resource.uri}]`);
}
break;
}
}
return parts.join("\n\n");
}
function buildResult(
result: MCPToolCallResult,
serverName: string,
mcpToolName: string,
provider?: string,
providerName?: string,
): CustomToolResult<MCPToolDetails> {
const text = formatMCPContent(result.content);
const details: MCPToolDetails = {
serverName,
mcpToolName,
isError: result.isError,
rawContent: result.content,
provider,
providerName,
};
if (result.isError) {
return { content: [{ type: "text", text: `Error: ${text}` }], details };
}
return { content: [{ type: "text", text }], details };
}
function buildErrorResult(
error: unknown,
serverName: string,
mcpToolName: string,
provider?: string,
providerName?: string,
): CustomToolResult<MCPToolDetails> {
const message = error instanceof Error ? error.message : String(error);
return {
content: [{ type: "text", text: `MCP error: ${message}` }],
details: { serverName, mcpToolName, isError: true, provider, providerName },
};
}
function rethrowIfAborted(error: unknown, signal?: AbortSignal): void {
if (error instanceof ToolAbortError) throw error;
if (error instanceof Error && error.name === "AbortError") throw new ToolAbortError();
if (signal?.aborted) throw new ToolAbortError();
}
async function reconnectWithAbort(reconnect: MCPReconnect, signal?: AbortSignal): Promise<MCPServerConnection | null> {
try {
return await untilAborted(signal, reconnect);
} catch (error) {
rethrowIfAborted(error, signal);
return null;
}
}
* Create a unique tool name for an MCP tool.
*
* Prefixes with server name to avoid conflicts. If the tool name already
* starts with the server name (e.g., server "puppeteer" with tool
* "puppeteer_screenshot"), strips the redundant prefix to produce
* "mcp__puppeteer_screenshot" instead of "mcp__puppeteer_puppeteer_screenshot".
*/
function sanitizeMCPToolNamePart(value: string, fallback: string): string {
const sanitized = value
.toLowerCase()
.replace(/[^a-z_]+/g, "_")
.replace(/_+/g, "_")
.replace(/^_+|_+$/g, "");
return sanitized.length > 0 ? sanitized : fallback;
}
export function createMCPToolName(serverName: string, toolName: string): string {
const sanitizedServerName = sanitizeMCPToolNamePart(serverName, "server");
const sanitizedToolName = sanitizeMCPToolNamePart(toolName, "tool");
const prefixWithUnderscore = `${sanitizedServerName}_`;
let normalizedToolName = sanitizedToolName;
if (sanitizedToolName.startsWith(prefixWithUnderscore)) {
normalizedToolName = sanitizedToolName.slice(prefixWithUnderscore.length);
}
return `mcp__${sanitizedServerName}_${normalizedToolName}`;
}
* Parse an MCP tool name back to server and tool components.
*
* Note: This returns the normalized tool name (with server prefix stripped).
* The original MCP tool name may have had the server name as a prefix.
*/
export function parseMCPToolName(name: string): { serverName: string; toolName: string } | null {
if (!name.startsWith("mcp__")) return null;
const rest = name.slice(5);
const underscoreIdx = rest.indexOf("_");
if (underscoreIdx === -1) return null;
return {
serverName: rest.slice(0, underscoreIdx),
toolName: rest.slice(underscoreIdx + 1),
};
}
* CustomTool wrapping an MCP tool with an active connection.
*/
export class MCPTool implements CustomTool<TSchema, MCPToolDetails> {
readonly name: string;
readonly label: string;
readonly description: string;
readonly parameters: TSchema;
readonly mcpToolName: string;
readonly mcpServerName: string;
static fromTools(connection: MCPServerConnection, tools: MCPToolDefinition[], reconnect?: MCPReconnect): MCPTool[] {
return tools.map(tool => new MCPTool(connection, tool, reconnect));
}
constructor(
private connection: MCPServerConnection,
private readonly tool: MCPToolDefinition,
private readonly reconnect?: MCPReconnect,
) {
this.name = createMCPToolName(connection.name, tool.name);
this.label = `${connection.name}/${tool.name}`;
this.description = tool.description ?? `MCP tool from ${connection.name}`;
this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
this.mcpToolName = tool.name;
this.mcpServerName = connection.name;
}
renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
return renderMCPCall(normalizeToolArgs(args), theme, this.label);
}
renderResult(result: CustomToolResult<MCPToolDetails>, options: RenderResultOptions, theme: Theme, args?: unknown) {
return renderMCPResult(result, options, theme, normalizeToolArgs(args));
}
async execute(
_toolCallId: string,
params: unknown,
_onUpdate: AgentToolUpdateCallback<MCPToolDetails> | undefined,
_ctx: CustomToolContext,
signal?: AbortSignal,
): Promise<CustomToolResult<MCPToolDetails>> {
throwIfAborted(signal);
const args = normalizeToolArgs(params);
const provider = this.connection._source?.provider;
const providerName = this.connection._source?.providerName;
try {
const result = await callTool(this.connection, this.tool.name, args, { signal });
return buildResult(result, this.connection.name, this.tool.name, provider, providerName);
} catch (error) {
rethrowIfAborted(error, signal);
if (this.reconnect && isRetriableConnectionError(error)) {
const newConn = await reconnectWithAbort(this.reconnect, signal);
if (newConn) {
this.connection = newConn;
const retryProvider = newConn._source?.provider ?? provider;
const retryProviderName = newConn._source?.providerName ?? providerName;
try {
const result = await callTool(newConn, this.tool.name, args, { signal });
return buildResult(result, newConn.name, this.tool.name, retryProvider, retryProviderName);
} catch (retryError) {
rethrowIfAborted(retryError, signal);
return buildErrorResult(
retryError,
this.connection.name,
this.tool.name,
retryProvider,
retryProviderName,
);
}
}
}
return buildErrorResult(error, this.connection.name, this.tool.name, provider, providerName);
}
}
}
* CustomTool wrapping an MCP tool with deferred connection resolution.
*/
export class DeferredMCPTool implements CustomTool<TSchema, MCPToolDetails> {
readonly name: string;
readonly label: string;
readonly description: string;
readonly parameters: TSchema;
readonly mcpToolName: string;
readonly mcpServerName: string;
readonly #fallbackProvider: string | undefined;
readonly #fallbackProviderName: string | undefined;
static fromTools(
serverName: string,
tools: MCPToolDefinition[],
getConnection: () => Promise<MCPServerConnection>,
source?: SourceMeta,
reconnect?: MCPReconnect,
): DeferredMCPTool[] {
return tools.map(tool => new DeferredMCPTool(serverName, tool, getConnection, source, reconnect));
}
constructor(
private readonly serverName: string,
private readonly tool: MCPToolDefinition,
private readonly getConnection: () => Promise<MCPServerConnection>,
source?: SourceMeta,
private readonly reconnect?: MCPReconnect,
) {
this.name = createMCPToolName(serverName, tool.name);
this.label = `${serverName}/${tool.name}`;
this.description = tool.description ?? `MCP tool from ${serverName}`;
this.parameters = normalizeSchemaForMCP(tool.inputSchema) as TSchema;
this.mcpToolName = tool.name;
this.mcpServerName = serverName;
this.#fallbackProvider = source?.provider;
this.#fallbackProviderName = source?.providerName;
}
renderCall(args: unknown, _options: RenderResultOptions, theme: Theme) {
return renderMCPCall(normalizeToolArgs(args), theme, this.label);
}
renderResult(result: CustomToolResult<MCPToolDetails>, options: RenderResultOptions, theme: Theme, args?: unknown) {
return renderMCPResult(result, options, theme, normalizeToolArgs(args));
}
async execute(
_toolCallId: string,
params: unknown,
_onUpdate: AgentToolUpdateCallback<MCPToolDetails> | undefined,
_ctx: CustomToolContext,
signal?: AbortSignal,
): Promise<CustomToolResult<MCPToolDetails>> {
throwIfAborted(signal);
const args = normalizeToolArgs(params);
const provider = this.#fallbackProvider;
const providerName = this.#fallbackProviderName;
try {
const connection = await untilAborted(signal, () => this.getConnection());
throwIfAborted(signal);
try {
const result = await callTool(connection, this.tool.name, args, { signal });
return buildResult(
result,
this.serverName,
this.tool.name,
connection._source?.provider ?? provider,
connection._source?.providerName ?? providerName,
);
} catch (callError) {
rethrowIfAborted(callError, signal);
if (this.reconnect && isRetriableConnectionError(callError)) {
const newConn = await reconnectWithAbort(this.reconnect, signal);
if (newConn) {
const retryProvider = newConn._source?.provider ?? provider;
const retryProviderName = newConn._source?.providerName ?? providerName;
try {
const result = await callTool(newConn, this.tool.name, args, { signal });
return buildResult(result, this.serverName, this.tool.name, retryProvider, retryProviderName);
} catch (retryError) {
rethrowIfAborted(retryError, signal);
return buildErrorResult(
retryError,
this.serverName,
this.tool.name,
retryProvider,
retryProviderName,
);
}
}
}
return buildErrorResult(callError, this.serverName, this.tool.name, provider, providerName);
}
} catch (connError) {
rethrowIfAborted(connError, signal);
if (this.reconnect) {
const newConn = await reconnectWithAbort(this.reconnect, signal);
if (newConn) {
try {
const result = await callTool(newConn, this.tool.name, args, { signal });
return buildResult(
result,
this.serverName,
this.tool.name,
newConn._source?.provider ?? provider,
newConn._source?.providerName ?? providerName,
);
} catch (retryError) {
rethrowIfAborted(retryError, signal);
return buildErrorResult(retryError, this.serverName, this.tool.name, provider, providerName);
}
}
}
return buildErrorResult(connError, this.serverName, this.tool.name, provider, providerName);
}
}
}