Files
oh-my-pi/packages/coding-agent/src/mcp/tool-bridge.ts
T
can1357 9448aac3a6 fix(mcp): kept disable precedence and stable tool collision winners
- Added a suppress load option so disabled servers still claim their
  capability key: a project foo with enabled:false shadows a same-named
  enabled user foo again, while scope-removed entries drop fully.
- Tool-name collisions now resolve by stable server+tool origin key
  instead of manager array order, so reconnect re-appends cannot flip
  the routed implementation.
- Review follow-up for PR #6787.
2026-07-27 16:07:23 +02:00

692 lines
23 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 { logger, untilAborted } from "@oh-my-pi/pi-utils";
import { INTENT_FIELD } from "@oh-my-pi/pi-wire";
import type { SourceMeta } from "../capability/types";
import type {
CustomTool,
CustomToolContext,
CustomToolResult,
RenderResultOptions,
} from "../extensibility/custom-tools/types";
import { resolveLocalUrlToFile } from "../internal-urls/local-protocol";
import type { Theme } from "../modes/theme/theme";
import type { OutputMeta } from "../tools/output-meta";
import { normalizeLocalScheme } from "../tools/path-utils";
import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
import { callTool } from "./client";
import { renderMCPCall, renderMCPResult } from "./render";
import type {
MCPAuthChallenge,
MCPContent,
MCPServerConnection,
MCPToolCallParams,
MCPToolCallResult,
MCPToolDefinition,
} from "./types";
/** Reconnect callback: tears down a stale connection, optionally authorizing first. */
export type MCPReconnect = (options?: { authChallenge?: MCPAuthChallenge }) => 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;
}
function isUnusedOptionalPlaceholder(value: unknown): boolean {
return (
value === undefined ||
value === "" ||
(typeof value === "object" && value !== null && !Array.isArray(value) && Object.keys(value).length === 0)
);
}
function omitUnusedOptionalArgs(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
const properties = inputSchema.properties;
if (!properties) return args;
let cleaned: MCPToolArgs | undefined;
const required = new Set(inputSchema.required ?? []);
for (const [key, value] of Object.entries(args)) {
if (required.has(key) || !Object.hasOwn(properties, key) || !isUnusedOptionalPlaceholder(value)) {
continue;
}
cleaned ??= { ...args };
delete cleaned[key];
}
return cleaned ?? args;
}
/**
* Drop the harness-internal intent field (`INTENT_FIELD`) before forwarding
* args to an MCP server. The harness injects `i` into every tool's wire
* schema; the direct model tool-call path strips it via `extractIntent`, but
* the `eval` `tool.*` bridge and any other in-process caller forwards args
* verbatim. Strict-schema servers (Linear, anything with
* `additionalProperties:false` / Zod `.strict()`) reject every call that
* carries `i`. The MCP boundary is the authoritative guard so callers don't
* have to pre-strip.
*
* Leaves `i` in place when the server's own `inputSchema.properties` declares
* it, so a server that legitimately uses `i` as a parameter is unaffected.
*/
function stripHarnessIntent(args: MCPToolArgs, inputSchema: MCPToolDefinition["inputSchema"]): MCPToolArgs {
if (!Object.hasOwn(args, INTENT_FIELD)) return args;
if (inputSchema.properties && Object.hasOwn(inputSchema.properties, INTENT_FIELD)) return args;
const { [INTENT_FIELD]: _intent, ...rest } = args;
return rest;
}
async function resolveOutboundLocalUrlArgs(
value: unknown,
context: CustomToolContext,
seen: WeakSet<object> = new WeakSet(),
): Promise<unknown> {
if (typeof value === "string") {
const normalized = normalizeLocalScheme(value);
if (!normalized.startsWith("local://")) return value;
const localFile = await resolveLocalUrlToFile(normalized, {
cwd: context.sessionManager?.getCwd?.(),
settings: context.settings,
localProtocolOptions: context.localProtocolOptions,
});
return localFile?.path ?? value;
}
if (typeof value !== "object" || value === null) return value;
if (seen.has(value)) return value;
seen.add(value);
if (Array.isArray(value)) {
let resolved: unknown[] | undefined;
for (let index = 0; index < value.length; index++) {
const item = value[index];
const next = await resolveOutboundLocalUrlArgs(item, context, seen);
if (next === item && !resolved) continue;
resolved ??= value.slice();
resolved[index] = next;
}
return resolved ?? value;
}
const input = value as Record<string, unknown>;
let resolved: Record<string, unknown> | undefined;
for (const key in input) {
const item = input[key];
const next = await resolveOutboundLocalUrlArgs(item, context, seen);
if (next === item && !resolved) continue;
resolved ??= { ...input };
resolved[key] = next;
}
return resolved ?? value;
}
/**
* Normalize raw tool params into the outbound `tools/call` arguments: strip
* the harness intent field, drop optional empty placeholders the server
* declares but doesn't require, then translate session-local files to paths
* external MCP servers can read.
*/
async function prepareOutboundArgs(
params: unknown,
inputSchema: MCPToolDefinition["inputSchema"],
context: CustomToolContext,
): Promise<MCPToolArgs> {
const args = omitUnusedOptionalArgs(stripHarnessIntent(normalizeToolArgs(params), inputSchema), inputSchema);
return (await resolveOutboundLocalUrlArgs(args, context)) 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[];
/** Structured metadata from the MCP response */
mcpMeta?: Record<string, unknown>;
/** Provider ID (e.g., "claude", "mcp-json") */
provider?: string;
/** Provider display name (e.g., "Claude Code", "MCP Config") */
providerName?: string;
/** Structured output metadata (set by the spill wrapper when output is truncated to an artifact). */
meta?: OutputMeta;
}
/**
* 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,
mcpMeta: result._meta,
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,
};
}
type MCPToolCallAttempt = {
connection: MCPServerConnection;
result?: MCPToolCallResult;
error?: unknown;
};
function getMcpAuthChallenge(result: MCPToolCallResult): MCPAuthChallenge | undefined {
if (!result.isError) return undefined;
const values = result._meta?.["mcp/www_authenticate"];
if (!Array.isArray(values)) return undefined;
const wwwAuthenticate = values.filter((value): value is string => typeof value === "string" && value.trim() !== "");
return wwwAuthenticate.length > 0 ? { wwwAuthenticate } : undefined;
}
async function callToolWithAuthRetry(
connection: MCPServerConnection,
toolName: string,
args: MCPToolArgs,
reconnect: MCPReconnect | undefined,
signal?: AbortSignal,
): Promise<MCPToolCallAttempt> {
const result = await callTool(connection, toolName, args, { signal });
const authChallenge = getMcpAuthChallenge(result);
if (!authChallenge || !reconnect) return { connection, result };
let newConnection: MCPServerConnection | null;
try {
newConnection = await reconnectWithAbort(reconnect, signal, { authChallenge });
} catch (error) {
rethrowIfAborted(error, signal);
return { connection, error };
}
if (!newConnection) return { connection, result };
try {
return {
connection: newConnection,
result: await callTool(newConnection, toolName, args, { signal }),
};
} catch (error) {
rethrowIfAborted(error, signal);
return { connection: newConnection, error };
}
}
/** 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,
options?: { authChallenge?: MCPAuthChallenge },
): Promise<MCPServerConnection | null> {
try {
return await untilAborted(signal, () => reconnect(options));
} 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}`;
}
/**
* Keeps one MCP tool per minted name and logs collisions between distinct MCP
* origins. The winner is chosen by a stable origin key (server name + original
* tool name), NOT array order: MCPManager re-appends a reconnecting server's
* tools, so insertion order is mutable across reconnects and first-wins would
* silently flip ownership of the minted name. Non-MCP tools pass through
* unchanged.
*/
export function deduplicateMCPToolsByName<T extends { name: string; mcpServerName?: unknown; mcpToolName?: unknown }>(
tools: readonly T[],
): T[] {
const deduplicated: T[] = [];
const registered = new Map<string, { tool: T; originKey: string; index: number }>();
for (const tool of tools) {
if (typeof tool.mcpServerName !== "string" || typeof tool.mcpToolName !== "string") {
deduplicated.push(tool);
continue;
}
const originKey = `${tool.mcpServerName}\u0000${tool.mcpToolName}`;
const existing = registered.get(tool.name);
if (!existing) {
registered.set(tool.name, { tool, originKey, index: deduplicated.length });
deduplicated.push(tool);
continue;
}
if (existing.originKey === originKey) continue;
// Deterministic winner regardless of encounter order across reconnects.
const keepExisting = existing.originKey < originKey;
const winner = keepExisting ? existing.tool : tool;
const loser = keepExisting ? tool : existing.tool;
if (!keepExisting) {
deduplicated[existing.index] = tool;
existing.tool = tool;
existing.originKey = originKey;
}
logger.warn("MCP tool name collision; keeping stable winner", {
name: tool.name,
keptServer: winner.mcpServerName,
keptTool: winner.mcpToolName,
ignoredServer: loser.mcpServerName,
ignoredTool: loser.mcpToolName,
});
}
return deduplicated;
}
/**
* 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;
/**
* MCP-backed tools opt out of strict structured-output grammar. The server
* owns validation, and strict mode makes OpenAI-family models over-fill
* mutually exclusive optional fields (#4336/#4340). Serializers preserve an
* explicit `false`; an omitted flag would leave nothing to preserve.
*/
readonly strict = false as const;
/** 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 = await prepareOutboundArgs(params, this.tool.inputSchema, _ctx);
const provider = this.connection._source?.provider;
const providerName = this.connection._source?.providerName;
try {
const attempt = await callToolWithAuthRetry(this.connection, this.tool.name, args, this.reconnect, signal);
if (attempt.error !== undefined) {
return buildErrorResult(attempt.error, this.connection.name, this.tool.name, provider, providerName);
}
if (!attempt.result) {
return buildErrorResult(
new Error("MCP tool call returned no result"),
this.connection.name,
this.tool.name,
provider,
providerName,
);
}
this.connection = attempt.connection;
return buildResult(
attempt.result,
attempt.connection.name,
this.tool.name,
attempt.connection._source?.provider ?? provider,
attempt.connection._source?.providerName ?? 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;
/** See {@link MCPTool.strict}: MCP servers own validation, so stay non-strict. */
readonly strict = false as const;
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 = await prepareOutboundArgs(params, this.tool.inputSchema, _ctx);
const provider = this.#fallbackProvider;
const providerName = this.#fallbackProviderName;
try {
const connection = await untilAborted(signal, () => this.getConnection());
throwIfAborted(signal);
try {
const attempt = await callToolWithAuthRetry(connection, this.tool.name, args, this.reconnect, signal);
if (attempt.error !== undefined) {
return buildErrorResult(
attempt.error,
this.serverName,
this.tool.name,
attempt.connection._source?.provider ?? provider,
attempt.connection._source?.providerName ?? providerName,
);
}
if (!attempt.result) {
return buildErrorResult(
new Error("MCP tool call returned no result"),
this.serverName,
this.tool.name,
provider,
providerName,
);
}
return buildResult(
attempt.result,
this.serverName,
this.tool.name,
attempt.connection._source?.provider ?? provider,
attempt.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);
}
}
}