d421944598
- Forwarded MCP image blocks to the agent and TUI without copying their base64 payloads. - Retained existing text and resource formatting around image blocks. - Added regression coverage for mixed text and image tool results. Fixes #8687
717 lines
24 KiB
TypeScript
717 lines
24 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 { ImageContent, TextContent, 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;
|
|
}
|
|
/**
|
|
* Convert MCP content to agent content while retaining image payloads.
|
|
*/
|
|
function formatMCPContent(content: MCPContent[]): Array<TextContent | ImageContent> {
|
|
const blocks: Array<TextContent | ImageContent> = [];
|
|
let text = "";
|
|
const flushText = () => {
|
|
if (!text) return;
|
|
blocks.push({ type: "text", text });
|
|
text = "";
|
|
};
|
|
const appendText = (value: string) => {
|
|
text += text ? `\n\n${value}` : value;
|
|
};
|
|
|
|
for (const item of content) {
|
|
switch (item.type) {
|
|
case "text":
|
|
appendText(item.text);
|
|
break;
|
|
case "image":
|
|
flushText();
|
|
blocks.push(item);
|
|
break;
|
|
case "resource":
|
|
appendText(
|
|
item.resource.text
|
|
? `[Resource: ${item.resource.uri}]\n${item.resource.text}`
|
|
: `[Resource: ${item.resource.uri}]`,
|
|
);
|
|
break;
|
|
}
|
|
}
|
|
flushText();
|
|
return blocks.length > 0 ? blocks : [{ type: "text", text: "" }];
|
|
}
|
|
|
|
/** Build a CustomToolResult from a callTool response. */
|
|
function buildResult(
|
|
result: MCPToolCallResult,
|
|
serverName: string,
|
|
mcpToolName: string,
|
|
provider?: string,
|
|
providerName?: string,
|
|
): CustomToolResult<MCPToolDetails> {
|
|
const content = formatMCPContent(result.content);
|
|
const details: MCPToolDetails = {
|
|
serverName,
|
|
mcpToolName,
|
|
isError: result.isError,
|
|
rawContent: result.content,
|
|
mcpMeta: result._meta,
|
|
provider,
|
|
providerName,
|
|
};
|
|
if (result.isError) {
|
|
if (content[0]?.type === "text") {
|
|
content[0] = { type: "text", text: `Error: ${content[0].text}` };
|
|
} else {
|
|
content.unshift({ type: "text", text: "Error:" });
|
|
}
|
|
}
|
|
const toolResult: CustomToolResult<MCPToolDetails> = { content, 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}`;
|
|
}
|
|
|
|
export interface MCPToolOriginSource {
|
|
readonly name: string;
|
|
readonly mcpServerName?: unknown;
|
|
readonly mcpToolName?: unknown;
|
|
}
|
|
|
|
/** Stable identity for a tool's original MCP route, before its public name was normalized. */
|
|
export function getMCPToolOriginKey(tool: MCPToolOriginSource): string | undefined {
|
|
if (typeof tool.mcpServerName !== "string" || typeof tool.mcpToolName !== "string") return undefined;
|
|
return `${tool.mcpServerName}\u0000${tool.mcpToolName}`;
|
|
}
|
|
|
|
/**
|
|
* 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 MCPToolOriginSource>(tools: readonly T[]): T[] {
|
|
const deduplicated: T[] = [];
|
|
const registered = new Map<string, { tool: T; originKey: string; index: number }>();
|
|
|
|
for (const tool of tools) {
|
|
const originKey = getMCPToolOriginKey(tool);
|
|
if (originKey === undefined) {
|
|
deduplicated.push(tool);
|
|
continue;
|
|
}
|
|
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);
|
|
}
|
|
}
|
|
}
|