Files
oh-my-pi/packages/coding-agent/src/mcp/tool-bridge.ts
T
Guts 83c8105be6 fix(mcp): declare approval tier for MCP tools to prevent hangs in non-yolo mode
MCPTool and DeferredMCPTool now declare approval = 'write' instead of
implicitly defaulting to 'exec'. Without this, the approval system
requires user confirmation for every MCP tool call in non-yolo modes,
but the confirmation prompt never renders in the TUI while streaming,
causing the agent to hang indefinitely.

Also propagate the approval property through customToolToDefinition()
in sdk.ts, which was silently dropping it during CustomTool ->
ToolDefinition conversion.
2026-06-07 16:22:33 +02:00

427 lines
14 KiB
TypeScript

/**
* 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<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();
// 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<MCPToolCallParams["arguments"]>;
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<MCPToolDetails> {
const text = formatMCPContent(result.content);
const details: MCPToolDetails = {
serverName,
mcpToolName,
isError: result.isError,
rawContent: result.content,
provider,
providerName,
};
const contentText = result.isError ? `Error: ${text}` : text;
const toolResult: CustomToolResult<MCPToolDetails> = { content: [{ type: "text", text: contentText }], details };
if (result.isError) {
toolResult.isError = true;
}
return toolResult;
}
/** Build an error CustomToolResult from a caught exception. */
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 },
isError: true,
};
}
/** 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<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");
// 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<TSchema, MCPToolDetails> {
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 approval = "write" as const;
/** Render completed MCP calls with the result header replacing the pending call header. */
readonly mergeCallAndResult = true;
/** 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<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) {
// 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<TSchema, MCPToolDetails> {
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 approval = "write" as const;
/** Render completed MCP calls with the result header replacing the pending call header. */
readonly mergeCallAndResult = true;
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<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) {
// 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);
}
}
}