diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 20e8c912e1..9f37b53529 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -36,6 +36,24 @@ import { withStatics } from "@opencode-ai/core/schema" const log = Log.create({ service: "mcp" }) const DEFAULT_TIMEOUT = 30_000 +const TolerantToolSchema = z.looseObject({ + name: z.string(), + description: z.string().optional(), + inputSchema: z + .object({ + type: z.literal("object"), + properties: z.record(z.string(), z.unknown()).optional(), + required: z.array(z.string()).optional(), + }) + .catchall(z.unknown()), + outputSchema: z.unknown().optional(), +}) + +const TolerantListToolsResultSchema = z.looseObject({ + tools: z.array(TolerantToolSchema), + nextCursor: z.string().optional(), +}) + export const Resource = Schema.Struct({ name: Schema.String, uri: Schema.String, @@ -119,6 +137,27 @@ function remoteURL(key: string, value: string) { log.warn("invalid remote mcp url", { key }) } +function isSchemaReferenceError(err: Error) { + return /can't resolve reference|schema.*reference|reference.*schema/i.test(err.message) +} + +async function listTools(key: string, client: MCPClient, timeout: number) { + try { + return (await client.listTools(undefined, { timeout })).tools + } catch (err) { + const error = err instanceof Error ? err : new Error(String(err)) + if (!isSchemaReferenceError(error)) throw error + + log.warn("failed to validate MCP tool output schemas, retrying without output schema validation", { + key, + error, + }) + + const result = await client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { timeout }) + return result.tools as MCPToolDef[] + } +} + // Convert MCP tool definition to AI SDK Tool type function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number): Tool { const inputSchema = mcpTool.inputSchema @@ -152,10 +191,10 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number function defs(key: string, client: MCPClient, timeout?: number) { return Effect.tryPromise({ - try: () => withTimeout(client.listTools(), timeout ?? DEFAULT_TIMEOUT), + try: () => listTools(key, client, timeout ?? DEFAULT_TIMEOUT), catch: (err) => (err instanceof Error ? err : new Error(String(err))), }).pipe( - Effect.map((result) => result.tools), + Effect.map((tools) => tools), Effect.catch((err) => { log.error("failed to get tools from client", { key, error: err }) return Effect.succeed(undefined) diff --git a/packages/opencode/test/mcp/output-schema-unresolved-refs.test.ts b/packages/opencode/test/mcp/output-schema-unresolved-refs.test.ts index a60f65dccf..fe64167e69 100644 --- a/packages/opencode/test/mcp/output-schema-unresolved-refs.test.ts +++ b/packages/opencode/test/mcp/output-schema-unresolved-refs.test.ts @@ -108,6 +108,15 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ } return { tools: this._state?.tools ?? [] } } + async request(req: { method: string }) { + // The fix retries via raw `request("tools/list", ...)` with a + // tolerant schema after the typed listTools() rejects on a bad + // outputSchema reference. The retry path bypasses the SDK's + // strict validator, so the mock returns the same tools list + // without the validation that originally threw. + if (req.method === "tools/list") return { tools: this._state?.tools ?? [] } + throw new Error(`unsupported request: ${req.method}`) + } async listPrompts() { return { prompts: [] } }