/** * 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"; /** Reconnect callback: tears down stale connection, returns new one or null. */ export type MCPReconnect = () => Promise; /** * 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(); // Stale session (server restarted, old session ID is gone) if (/^http (404|502|503):/.test(msg)) return true; return RETRIABLE_PATTERNS.some(p => msg.includes(p)); } type MCPToolArgs = NonNullable; function normalizeToolArgs(value: unknown): MCPToolArgs { if (typeof value !== "object" || value === null || Array.isArray(value)) { return {}; } return value as MCPToolArgs; } /** Details included in MCP tool results for rendering */ export interface MCPToolDetails { /** Server name */ serverName: string; /** Original MCP tool name */ mcpToolName: string; /** Whether the call resulted in an error */ isError?: boolean; /** Raw content from MCP response */ rawContent?: MCPContent[]; /** Provider ID (e.g., "claude", "mcp-json") */ provider?: string; /** Provider display name (e.g., "Claude Code", "MCP Config") */ 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"); } /** Build a CustomToolResult from a callTool response. */ function buildResult( result: MCPToolCallResult, serverName: string, mcpToolName: string, provider?: string, providerName?: string, ): CustomToolResult { 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 }; } /** Build an error CustomToolResult from a caught exception. */ function buildErrorResult( error: unknown, serverName: string, mcpToolName: string, provider?: string, providerName?: string, ): CustomToolResult { const message = error instanceof Error ? error.message : String(error); return { content: [{ type: "text", text: `MCP error: ${message}` }], details: { serverName, mcpToolName, isError: true, provider, providerName }, }; } /** Re-throw abort-related errors so they bypass error-result handling. */ 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 { 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"); // Strip redundant server name prefix from tool name if present 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 { readonly name: string; readonly label: string; readonly description: string; readonly parameters: TSchema; /** Original MCP tool name (before normalization) */ readonly mcpToolName: string; /** Server name */ readonly mcpServerName: string; /** Create MCPTool instances for all tools from an MCP server connection */ 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, options: RenderResultOptions, theme: Theme, args?: unknown) { return renderMCPResult(result, options, theme, normalizeToolArgs(args)); } async execute( _toolCallId: string, params: unknown, _onUpdate: AgentToolUpdateCallback | undefined, _ctx: CustomToolContext, signal?: AbortSignal, ): Promise> { 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) { // Rebind so subsequent calls on this instance use the fresh connection 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 { readonly name: string; readonly label: string; readonly description: string; readonly parameters: TSchema; /** Original MCP tool name (before normalization) */ readonly mcpToolName: string; /** Server name */ readonly mcpServerName: string; readonly #fallbackProvider: string | undefined; readonly #fallbackProviderName: string | undefined; /** Create DeferredMCPTool instances for all tools from an MCP server */ static fromTools( serverName: string, tools: MCPToolDefinition[], getConnection: () => Promise, 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, 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, options: RenderResultOptions, theme: Theme, args?: unknown) { return renderMCPResult(result, options, theme, normalizeToolArgs(args)); } async execute( _toolCallId: string, params: unknown, _onUpdate: AgentToolUpdateCallback | undefined, _ctx: CustomToolContext, signal?: AbortSignal, ): Promise> { 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) { // getConnection() failed — server never connected or connection lost. // This is always worth a reconnect attempt for deferred tools, since the // error ("MCP server not connected") isn't a network error from callTool. 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); } } }