From 42800aa44d98ba31b06bd6bb25bc817d4160b72b Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 11:02:41 -0500 Subject: [PATCH 1/7] fix(mcp): preserve metadata across tool pages --- packages/opencode/test/mcp/catalog.test.ts | 63 ++++++++++++++++++- .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 52 +++++++++++++++ 2 files changed, 114 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 55cabaef7699..0fb6c33b4011 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,6 +1,10 @@ import { describe, expect, test } from "bun:test" -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { McpCatalog } from "@/mcp/catalog" +import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -45,3 +49,60 @@ describe("McpCatalog.convertTool", () => { }) }) }) + +test("preserves output schema validation across paginated tool discovery", async () => { + const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), + ) + server.setRequestHandler(CallToolRequestSchema, ({ params }) => + Promise.resolve({ + content: [], + structuredContent: { value: params.name === "first" ? 42 : 1 }, + }), + ) + + const client = new Client({ name: "pagination-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) + + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index e38e68e75d49..9fc1c93a9404 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -112,6 +112,32 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +@@ -545,10 +551,12 @@ class Client extends protocol_js_1.Protocol { + * Cache validators for tool output schemas. + * Called after listTools() to pre-compile validators for better performance. + */ +- cacheToolMetadata(tools) { +- this._cachedToolOutputValidators.clear(); +- this._cachedKnownTaskTools.clear(); +- this._cachedRequiredTaskTools.clear(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { +@@ -575,7 +583,7 @@ class Client extends protocol_js_1.Protocol { + async listTools(params, options) { + const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); + // Cache the tools and their output schemas for future validation +- this.cacheToolMetadata(result.tools); ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); + return result; + } + /** diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 --- a/dist/cjs/client/streamableHttp.js @@ -461,6 +487,32 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +@@ -541,10 +547,12 @@ export class Client extends Protocol { + * Cache validators for tool output schemas. + * Called after listTools() to pre-compile validators for better performance. + */ +- cacheToolMetadata(tools) { +- this._cachedToolOutputValidators.clear(); +- this._cachedKnownTaskTools.clear(); +- this._cachedRequiredTaskTools.clear(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { +@@ -571,7 +579,7 @@ export class Client extends Protocol { + async listTools(params, options) { + const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); + // Cache the tools and their output schemas for future validation +- this.cacheToolMetadata(result.tools); ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); + return result; + } + /** diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644 --- a/dist/esm/client/streamableHttp.js From b556a061f4f6142189af88b1f584107410b28cb6 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 11:09:33 -0500 Subject: [PATCH 2/7] fix(mcp): correct SDK patch offsets --- patches/@modelcontextprotocol%2Fsdk@1.29.0.patch | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index 9fc1c93a9404..a9e17ba8dcfb 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -112,7 +112,7 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -545,10 +551,12 @@ class Client extends protocol_js_1.Protocol { +@@ -540,10 +546,12 @@ class Client extends protocol_js_1.Protocol { * Cache validators for tool output schemas. * Called after listTools() to pre-compile validators for better performance. */ @@ -129,7 +129,7 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { -@@ -575,7 +583,7 @@ class Client extends protocol_js_1.Protocol { +@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol { async listTools(params, options) { const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); // Cache the tools and their output schemas for future validation @@ -487,7 +487,7 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -541,10 +547,12 @@ export class Client extends Protocol { +@@ -536,10 +542,12 @@ export class Client extends Protocol { * Cache validators for tool output schemas. * Called after listTools() to pre-compile validators for better performance. */ @@ -504,7 +504,7 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { -@@ -571,7 +579,7 @@ export class Client extends Protocol { +@@ -565,7 +573,7 @@ export class Client extends Protocol { async listTools(params, options) { const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); // Cache the tools and their output schemas for future validation From 98bfdc0e3b1c8ad15fec89a5be460adb5b25a680 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 11:52:58 -0500 Subject: [PATCH 3/7] test(mcp): isolate pagination regression --- .../test/mcp/catalog-pagination.fixture.ts | 64 ++++++++++++++++++ packages/opencode/test/mcp/catalog.test.ts | 66 ++----------------- 2 files changed, 71 insertions(+), 59 deletions(-) create mode 100644 packages/opencode/test/mcp/catalog-pagination.fixture.ts diff --git a/packages/opencode/test/mcp/catalog-pagination.fixture.ts b/packages/opencode/test/mcp/catalog-pagination.fixture.ts new file mode 100644 index 000000000000..c5aa56ae342e --- /dev/null +++ b/packages/opencode/test/mcp/catalog-pagination.fixture.ts @@ -0,0 +1,64 @@ +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { McpCatalog } from "@/mcp/catalog" +import { Effect } from "effect" + +const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) +server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), +) +server.setRequestHandler(CallToolRequestSchema, ({ params }) => + Promise.resolve({ + content: [], + structuredContent: { value: params.name === "first" ? 42 : 1 }, + }), +) + +const client = new Client({ name: "pagination-test", version: "1.0.0" }) +const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() +await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + +try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + if (tools?.map((tool) => tool.name).join(",") !== "first,second") throw new Error("Missing paginated tools") + + await client.callTool({ name: "first", arguments: {} }) + throw new Error("Expected invalid structured output to be rejected") +} catch (error) { + if (!(error instanceof Error) || !error.message.includes("Structured content does not match the tool's output schema")) { + throw error + } +} finally { + await Promise.all([client.close(), server.close()]) +} diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 0fb6c33b4011..8f4053263689 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,10 +1,7 @@ import { describe, expect, test } from "bun:test" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" import { McpCatalog } from "@/mcp/catalog" -import { Effect } from "effect" +import { fileURLToPath } from "node:url" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -51,58 +48,9 @@ describe("McpCatalog.convertTool", () => { }) test("preserves output schema validation across paginated tool discovery", async () => { - const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, ({ params }) => - Promise.resolve( - params?.cursor === "page-2" - ? { - tools: [ - { - name: "second", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: "number" } }, - required: ["value"], - }, - }, - ], - } - : { - tools: [ - { - name: "first", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: "string" } }, - required: ["value"], - }, - }, - ], - nextCursor: "page-2", - }, - ), - ) - server.setRequestHandler(CallToolRequestSchema, ({ params }) => - Promise.resolve({ - content: [], - structuredContent: { value: params.name === "first" ? 42 : 1 }, - }), - ) - - const client = new Client({ name: "pagination-test", version: "1.0.0" }) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) - - try { - const tools = await Effect.runPromise(McpCatalog.defs(client)) - expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) - - await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( - "Structured content does not match the tool's output schema", - ) - } finally { - await Promise.all([client.close(), server.close()]) - } + const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./catalog-pagination.fixture.ts", import.meta.url))], { + stderr: "pipe", + }) + const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]) + if (exitCode !== 0) throw new Error(stderr) }) From 7589a7c1ec3c59ddcffda8d38854d42b5af06aa5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 13:06:53 -0500 Subject: [PATCH 4/7] fix(mcp): preserve output schema metadata --- packages/opencode/src/mcp/catalog.ts | 4 +- .../src/session/llm/native-request.ts | 2 + .../src/session/llm/native-runtime.ts | 1 + .../test/mcp/catalog-pagination.fixture.ts | 64 ---------- packages/opencode/test/mcp/catalog.test.ts | 118 ++++++++++++++++-- .../opencode/test/session/llm-native.test.ts | 29 ++++- .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 28 ++++- 7 files changed, 169 insertions(+), 77 deletions(-) delete mode 100644 packages/opencode/test/mcp/catalog-pagination.fixture.ts diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 3f985eeb94dc..ca033ec5aed1 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -47,7 +47,7 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe additionalProperties: false, } - return dynamicTool({ + const tool = dynamicTool({ description: mcpTool.description ?? "", inputSchema: jsonSchema(inputSchema), execute: async (args: unknown, options) => { @@ -80,6 +80,8 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe } }, }) + if (!mcpTool.outputSchema) return tool + return { ...tool, outputSchema: jsonSchema(mcpTool.outputSchema as JSONSchema7) } } export function fetch( diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index b7f30e24c362..8b60fc3ce697 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -16,6 +16,7 @@ import { isRecord } from "@/util/record" type ToolInput = { readonly description?: string readonly inputSchema?: unknown + readonly outputSchema?: unknown } export type RequestInput = { @@ -129,6 +130,7 @@ const tools = (input: Record | undefined): ToolDefinition[] = name, description: item.description ?? "", inputSchema: schema(item.inputSchema), + outputSchema: item.outputSchema === undefined ? undefined : schema(item.outputSchema), }), ) diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index bac385c59137..56e3456f2bcf 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -175,6 +175,7 @@ export function nativeTools(tools: Record, input: Pick Effect.tryPromise({ try: () => { diff --git a/packages/opencode/test/mcp/catalog-pagination.fixture.ts b/packages/opencode/test/mcp/catalog-pagination.fixture.ts deleted file mode 100644 index c5aa56ae342e..000000000000 --- a/packages/opencode/test/mcp/catalog-pagination.fixture.ts +++ /dev/null @@ -1,64 +0,0 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" -import { McpCatalog } from "@/mcp/catalog" -import { Effect } from "effect" - -const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) -server.setRequestHandler(ListToolsRequestSchema, ({ params }) => - Promise.resolve( - params?.cursor === "page-2" - ? { - tools: [ - { - name: "second", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: "number" } }, - required: ["value"], - }, - }, - ], - } - : { - tools: [ - { - name: "first", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: "string" } }, - required: ["value"], - }, - }, - ], - nextCursor: "page-2", - }, - ), -) -server.setRequestHandler(CallToolRequestSchema, ({ params }) => - Promise.resolve({ - content: [], - structuredContent: { value: params.name === "first" ? 42 : 1 }, - }), -) - -const client = new Client({ name: "pagination-test", version: "1.0.0" }) -const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() -await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) - -try { - const tools = await Effect.runPromise(McpCatalog.defs(client)) - if (tools?.map((tool) => tool.name).join(",") !== "first,second") throw new Error("Missing paginated tools") - - await client.callTool({ name: "first", arguments: {} }) - throw new Error("Expected invalid structured output to be rejected") -} catch (error) { - if (!(error instanceof Error) || !error.message.includes("Structured content does not match the tool's output schema")) { - throw error - } -} finally { - await Promise.all([client.close(), server.close()]) -} diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 8f4053263689..a94348b1da5c 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test" -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { McpCatalog } from "@/mcp/catalog" -import { fileURLToPath } from "node:url" +import { asSchema } from "ai" +import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -24,6 +28,17 @@ function mcpTool() { } describe("McpCatalog.convertTool", () => { + test("preserves the declared output schema", () => { + const outputSchema = { + type: "object" as const, + properties: { value: { type: "string" as const } }, + required: ["value"], + } + const converted = McpCatalog.convertTool({ ...mcpTool(), outputSchema }, clientReturning({ content: [] })) + + expect(asSchema(converted.outputSchema!).jsonSchema).toEqual(outputSchema) + }) + test("preserves content when structuredContent is also present", async () => { const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } @@ -48,9 +63,98 @@ describe("McpCatalog.convertTool", () => { }) test("preserves output schema validation across paginated tool discovery", async () => { - const child = Bun.spawn([process.execPath, fileURLToPath(new URL("./catalog-pagination.fixture.ts", import.meta.url))], { - stderr: "pipe", - }) - const [exitCode, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]) - if (exitCode !== 0) throw new Error(stderr) + const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + Promise.resolve( + params?.cursor === "page-2" + ? { + tools: [ + { + name: "second", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "number" } }, + required: ["value"], + }, + }, + ], + } + : { + tools: [ + { + name: "first", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: "page-2", + }, + ), + ) + server.setRequestHandler(CallToolRequestSchema, ({ params }) => + Promise.resolve({ + content: [], + structuredContent: { value: params.name === "first" ? 42 : 1 }, + }), + ) + + const client = new Client({ name: "pagination-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) + +test("isolates malformed output schemas while caching valid sibling schemas", async () => { + const server = new Server({ name: "schemas", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, () => + Promise.resolve({ + tools: [ + { + name: "malformed", + inputSchema: { type: "object" }, + outputSchema: { type: "object", properties: { value: { $ref: "#/$defs/Missing" } } }, + }, + { + name: "valid", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: "string" } }, + required: ["value"], + }, + }, + ], + }), + ) + server.setRequestHandler(CallToolRequestSchema, () => + Promise.resolve({ content: [], structuredContent: { value: 42 } }), + ) + + const client = new Client({ name: "schema-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const tools = await Effect.runPromise(McpCatalog.defs(client)) + expect(tools?.map((tool) => tool.name)).toEqual(["malformed", "valid"]) + await expect(client.callTool({ name: "valid", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } }) diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index dd4d9cc17481..3183e149c7e3 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { LLMEvent, ToolFailure } from "@opencode-ai/llm" +import { LLMEvent, ToolFailure, toDefinitions } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { Effect, Fiber, Layer, Stream } from "effect" @@ -209,6 +209,11 @@ describe("session.llm-native.request", () => { }, required: ["command"], }), + outputSchema: jsonSchema({ + type: "object", + properties: { output: { type: "string" } }, + required: ["output"], + }), }), }, toolChoice: "required", @@ -257,6 +262,11 @@ describe("session.llm-native.request", () => { }, required: ["command"], }, + outputSchema: { + type: "object", + properties: { output: { type: "string" } }, + required: ["output"], + }, }, ]) expect(request.messages).toMatchObject([ @@ -524,6 +534,23 @@ describe("session.llm-native.request", () => { }), ) + test("native tool wrapper preserves output schemas", () => { + const outputSchema = { type: "object" as const, properties: { value: { type: "string" as const } } } + const wrapped = LLMNativeRuntime.nativeTools( + { + lookup: { + description: "lookup", + inputSchema: jsonSchema({ type: "object" }), + outputSchema: jsonSchema(outputSchema), + execute: async () => ({ value: "ok" }), + } satisfies Tool, + }, + { messages: [] as ModelMessage[], abort: new AbortController().signal }, + ) + + expect(toDefinitions(wrapped)[0]?.outputSchema).toEqual(outputSchema) + }) + it.effect("native tool wrapper raises ToolFailure when the source tool has no execute handler", () => Effect.gen(function* () { // The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools). diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index a9e17ba8dcfb..3ce60fd82e46 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -112,7 +112,7 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -540,10 +546,12 @@ class Client extends protocol_js_1.Protocol { +@@ -540,13 +546,20 @@ class Client extends protocol_js_1.Protocol { * Cache validators for tool output schemas. * Called after listTools() to pre-compile validators for better performance. */ @@ -129,7 +129,17 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { -@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol { +- const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); +- this._cachedToolOutputValidators.set(tool.name, toolValidator); ++ try { ++ const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); ++ this._cachedToolOutputValidators.set(tool.name, toolValidator); ++ } ++ catch { ++ this._cachedToolOutputValidators.delete(tool.name); ++ } + } +@@ -569,7 +582,7 @@ class Client extends protocol_js_1.Protocol { async listTools(params, options) { const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); // Cache the tools and their output schemas for future validation @@ -487,7 +497,7 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -536,10 +542,12 @@ export class Client extends Protocol { +@@ -536,13 +542,20 @@ export class Client extends Protocol { * Cache validators for tool output schemas. * Called after listTools() to pre-compile validators for better performance. */ @@ -504,7 +514,17 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { -@@ -565,7 +573,7 @@ export class Client extends Protocol { +- const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); +- this._cachedToolOutputValidators.set(tool.name, toolValidator); ++ try { ++ const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); ++ this._cachedToolOutputValidators.set(tool.name, toolValidator); ++ } ++ catch { ++ this._cachedToolOutputValidators.delete(tool.name); ++ } + } +@@ -565,7 +578,7 @@ export class Client extends Protocol { async listTools(params, options) { const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); // Cache the tools and their output schemas for future validation From 3882e0c83bc6c016b029b6b3be40e9260dfcf11e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 13:28:18 -0500 Subject: [PATCH 5/7] refactor(mcp): drop native output schema propagation --- .../src/session/llm/native-request.ts | 2 -- .../src/session/llm/native-runtime.ts | 1 - .../opencode/test/session/llm-native.test.ts | 29 +------------------ 3 files changed, 1 insertion(+), 31 deletions(-) diff --git a/packages/opencode/src/session/llm/native-request.ts b/packages/opencode/src/session/llm/native-request.ts index 8b60fc3ce697..b7f30e24c362 100644 --- a/packages/opencode/src/session/llm/native-request.ts +++ b/packages/opencode/src/session/llm/native-request.ts @@ -16,7 +16,6 @@ import { isRecord } from "@/util/record" type ToolInput = { readonly description?: string readonly inputSchema?: unknown - readonly outputSchema?: unknown } export type RequestInput = { @@ -130,7 +129,6 @@ const tools = (input: Record | undefined): ToolDefinition[] = name, description: item.description ?? "", inputSchema: schema(item.inputSchema), - outputSchema: item.outputSchema === undefined ? undefined : schema(item.outputSchema), }), ) diff --git a/packages/opencode/src/session/llm/native-runtime.ts b/packages/opencode/src/session/llm/native-runtime.ts index 56e3456f2bcf..bac385c59137 100644 --- a/packages/opencode/src/session/llm/native-runtime.ts +++ b/packages/opencode/src/session/llm/native-runtime.ts @@ -175,7 +175,6 @@ export function nativeTools(tools: Record, input: Pick Effect.tryPromise({ try: () => { diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 3183e149c7e3..dd4d9cc17481 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { LLMEvent, ToolFailure, toDefinitions } from "@opencode-ai/llm" +import { LLMEvent, ToolFailure } from "@opencode-ai/llm" import { LLMClient, RequestExecutor, WebSocketExecutor, type LLMClientShape } from "@opencode-ai/llm/route" import { jsonSchema, tool, type ModelMessage, type Tool } from "ai" import { Effect, Fiber, Layer, Stream } from "effect" @@ -209,11 +209,6 @@ describe("session.llm-native.request", () => { }, required: ["command"], }), - outputSchema: jsonSchema({ - type: "object", - properties: { output: { type: "string" } }, - required: ["output"], - }), }), }, toolChoice: "required", @@ -262,11 +257,6 @@ describe("session.llm-native.request", () => { }, required: ["command"], }, - outputSchema: { - type: "object", - properties: { output: { type: "string" } }, - required: ["output"], - }, }, ]) expect(request.messages).toMatchObject([ @@ -534,23 +524,6 @@ describe("session.llm-native.request", () => { }), ) - test("native tool wrapper preserves output schemas", () => { - const outputSchema = { type: "object" as const, properties: { value: { type: "string" as const } } } - const wrapped = LLMNativeRuntime.nativeTools( - { - lookup: { - description: "lookup", - inputSchema: jsonSchema({ type: "object" }), - outputSchema: jsonSchema(outputSchema), - execute: async () => ({ value: "ok" }), - } satisfies Tool, - }, - { messages: [] as ModelMessage[], abort: new AbortController().signal }, - ) - - expect(toDefinitions(wrapped)[0]?.outputSchema).toEqual(outputSchema) - }) - it.effect("native tool wrapper raises ToolFailure when the source tool has no execute handler", () => Effect.gen(function* () { // The AI SDK Tool shape allows execute to be omitted (e.g., client-side / MCP tools). From 8a7d8b099b3d4b8bdeef4467593daef837cab351 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 13:54:43 -0500 Subject: [PATCH 6/7] fix(mcp): make tool metadata refresh atomic --- packages/opencode/src/mcp/catalog.ts | 27 +-- packages/opencode/src/mcp/index.ts | 19 +- packages/opencode/test/mcp/catalog.test.ts | 192 ++++++++++++++++++ packages/opencode/test/mcp/lifecycle.test.ts | 2 +- .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 154 ++++++++++---- 5 files changed, 327 insertions(+), 67 deletions(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index ca033ec5aed1..200a5b8bd94c 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -1,20 +1,11 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { - CallToolResultSchema, - ListToolsResultSchema, - ToolSchema, - type Tool as MCPToolDef, -} from "@modelcontextprotocol/sdk/types.js" +import { CallToolResultSchema, type Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { Effect } from "effect" const DEFAULT_TIMEOUT = 30_000 const MAX_LIST_PAGES = 1_000 -const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ - tools: ToolSchema.omit({ outputSchema: true }).array(), -}) - export async function paginate( list: (cursor?: string) => Promise, items: (result: R) => T[], @@ -148,25 +139,11 @@ function listTools(client: Client, timeout: number) { return Effect.tryPromise({ try: () => paginate( - async (cursor) => { - const params = cursor === undefined ? undefined : { cursor } - try { - return await client.listTools(params, { timeout }) - } catch (error) { - if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error - return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) - } - }, + (cursor) => client.listTools(cursor === undefined ? undefined : { cursor }, { timeout }), (result) => result.tools, ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } -function isOutputSchemaValidationError(error: Error) { - return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( - error.message, - ) -} - export * as McpCatalog from "./catalog" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..dbf291754d55 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -440,6 +440,7 @@ const layer = Layer.effect( ) function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { + let refresh = Promise.resolve() client.onclose = () => { if (s.clients[name] !== client) return delete s.clients[name] @@ -459,15 +460,19 @@ const layer = Layer.effect( ) if (!client.getServerCapabilities()?.tools) return - client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + client.setNotificationHandler(ToolListChangedNotificationSchema, () => { + const next = refresh.then(async () => { + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - const listed = await bridge.promise(McpCatalog.defs(client, timeout)) - if (!listed) return - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + const listed = await bridge.promise(McpCatalog.defs(client, timeout)) + if (!listed) return + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - s.defs[name] = listed - await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + s.defs[name] = listed + await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) + }) + refresh = next.catch(() => {}) + return next }) } diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index a94348b1da5c..88fa18fc0647 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -158,3 +158,195 @@ test("isolates malformed output schemas while caching valid sibling schemas", as await Promise.all([client.close(), server.close()]) } }) + +test("refreshes valid sibling metadata when another output schema is malformed", async () => { + let refreshed = false + const server = new Server({ name: "schema-refresh", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, () => + Promise.resolve({ + tools: [ + ...(refreshed + ? [ + { + name: "malformed", + inputSchema: { type: "object" as const }, + outputSchema: { type: "object" as const, properties: { value: { $ref: "#/$defs/Missing" } } }, + }, + ] + : []), + { + name: "valid", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: refreshed ? ("number" as const) : ("string" as const) } }, + required: ["value"], + }, + }, + ], + }), + ) + server.setRequestHandler(CallToolRequestSchema, () => + Promise.resolve({ content: [], structuredContent: { value: 42 } }), + ) + + const client = new Client({ name: "schema-refresh-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(1) + refreshed = true + expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(2) + await expect(client.callTool({ name: "valid", arguments: {} })).resolves.toMatchObject({ + structuredContent: { value: 42 }, + }) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) + +test("retains the previous metadata when a continuation page fails", async () => { + let failContinuation = false + const server = new Server({ name: "failure", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => { + if (params?.cursor === "page-2") throw new Error("continuation failed") + return Promise.resolve({ + tools: [ + { + name: "lookup", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: failContinuation ? "number" : "string" } }, + required: ["value"], + }, + }, + ], + nextCursor: failContinuation ? "page-2" : undefined, + }) + }) + server.setRequestHandler(CallToolRequestSchema, () => + Promise.resolve({ content: [], structuredContent: { value: 42 } }), + ) + + const client = new Client({ name: "failure-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(1) + failContinuation = true + expect(await Effect.runPromise(McpCatalog.defs(client))).toBeUndefined() + await expect(client.callTool({ name: "lookup", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + await Promise.all([client.close(), server.close()]) + } +}) + +test("does not let an older overlapping listing replace newer metadata", async () => { + let listing = 0 + let releaseOld: (() => void) | undefined + let markOldContinuationStarted: (() => void) | undefined + const oldContinuationStarted = new Promise((resolve) => (markOldContinuationStarted = resolve)) + const oldContinuation = new Promise((resolve) => (releaseOld = resolve)) + const server = new Server({ name: "overlap", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async ({ params }) => { + if (params?.cursor === "old-page-2") { + markOldContinuationStarted?.() + await oldContinuation + return { tools: [] } + } + listing++ + return { + tools: [ + { + name: "lookup", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: listing === 1 ? "string" : "number" } }, + required: ["value"], + }, + }, + ], + nextCursor: listing === 1 ? "old-page-2" : undefined, + } + }) + server.setRequestHandler(CallToolRequestSchema, () => + Promise.resolve({ content: [], structuredContent: { value: 42 } }), + ) + + const client = new Client({ name: "overlap-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const old = Effect.runPromise(McpCatalog.defs(client)) + await oldContinuationStarted + expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(1) + releaseOld?.() + expect(await old).toHaveLength(1) + await expect(client.callTool({ name: "lookup", arguments: {} })).resolves.toMatchObject({ + structuredContent: { value: 42 }, + }) + } finally { + releaseOld?.() + await Promise.all([client.close(), server.close()]) + } +}) + +test("commits an older overlapping listing when the newer listing fails", async () => { + let listing = 0 + let releaseOld: (() => void) | undefined + let markOldContinuationStarted: (() => void) | undefined + const oldContinuationStarted = new Promise((resolve) => (markOldContinuationStarted = resolve)) + const oldContinuation = new Promise((resolve) => (releaseOld = resolve)) + const server = new Server({ name: "overlap-failure", version: "1.0.0" }, { capabilities: { tools: {} } }) + server.setRequestHandler(ListToolsRequestSchema, async ({ params }) => { + if (params?.cursor === "old-page-2") { + markOldContinuationStarted?.() + await oldContinuation + return { tools: [] } + } + if (params?.cursor === "new-page-2") throw new Error("newer continuation failed") + listing++ + return { + tools: [ + { + name: "lookup", + inputSchema: { type: "object" }, + outputSchema: { + type: "object", + properties: { value: { type: listing === 1 ? "string" : "number" } }, + required: ["value"], + }, + }, + ], + nextCursor: listing === 1 ? "old-page-2" : "new-page-2", + } + }) + server.setRequestHandler(CallToolRequestSchema, () => + Promise.resolve({ content: [], structuredContent: { value: 42 } }), + ) + + const client = new Client({ name: "overlap-failure-test", version: "1.0.0" }) + const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() + await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) + + try { + const old = Effect.runPromise(McpCatalog.defs(client)) + await oldContinuationStarted + expect(await Effect.runPromise(McpCatalog.defs(client))).toBeUndefined() + releaseOld?.() + expect(await old).toHaveLength(1) + await expect(client.callTool({ name: "lookup", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) + } finally { + releaseOld?.() + await Promise.all([client.close(), server.close()]) + } +}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 80c8fd22f886..5773c3302ef9 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -360,7 +360,7 @@ it.instance("one failed server does not affect another connected server", () => }), ) -it.instance("falls back when output schema refs fail SDK tool discovery", () => +it.instance("isolates invalid output schema refs without dropping tool discovery", () => Effect.gen(function* () { const server = yield* lifecycleServer({ capabilities: { tools: {} } }) server.state.tools = [ diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index 3ce60fd82e46..dbfd6a21b412 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -28,7 +28,16 @@ diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644 --- a/dist/cjs/client/index.js +++ b/dist/cjs/client/index.js -@@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol { +@@ -107,6 +107,8 @@ class Client extends protocol_js_1.Protocol { + this._cachedToolOutputValidators = new Map(); + this._cachedKnownTaskTools = new Set(); + this._cachedRequiredTaskTools = new Set(); ++ this._toolListGeneration = 0; ++ this._toolListCommittedGeneration = 0; + this._listChangedDebounceTimers = new Map(); + this._capabilities = options?.capabilities ?? {}; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new ajv_provider_js_1.AjvJsonSchemaValidator(); +@@ -288,41 +290,16 @@ class Client extends protocol_js_1.Protocol { } async connect(transport, options) { await super.connect(transport); @@ -74,7 +83,7 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 } catch (error) { // Disconnect if initialization fails. -@@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol { +@@ -330,6 +307,37 @@ class Client extends protocol_js_1.Protocol { throw error; } } @@ -112,20 +121,17 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -540,13 +546,20 @@ class Client extends protocol_js_1.Protocol { - * Cache validators for tool output schemas. +@@ -541,24 +549,30 @@ class Client extends protocol_js_1.Protocol { * Called after listTools() to pre-compile validators for better performance. */ - cacheToolMetadata(tools) { - this._cachedToolOutputValidators.clear(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools, reset = true) { -+ if (reset) { -+ this._cachedToolOutputValidators.clear(); -+ this._cachedKnownTaskTools.clear(); -+ this._cachedRequiredTaskTools.clear(); -+ } ++ cacheToolMetadata(tools) { ++ const outputValidators = new Map(); ++ const knownTaskTools = new Set(); ++ const requiredTaskTools = new Set(); for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { @@ -133,18 +139,55 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 - this._cachedToolOutputValidators.set(tool.name, toolValidator); + try { + const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); -+ this._cachedToolOutputValidators.set(tool.name, toolValidator); -+ } -+ catch { -+ this._cachedToolOutputValidators.delete(tool.name); ++ outputValidators.set(tool.name, toolValidator); + } ++ catch { } + } + // If the tool supports task-based execution, cache that information + const taskSupport = tool.execution?.taskSupport; + if (taskSupport === 'required' || taskSupport === 'optional') { +- this._cachedKnownTaskTools.add(tool.name); ++ knownTaskTools.add(tool.name); + } + if (taskSupport === 'required') { +- this._cachedRequiredTaskTools.add(tool.name); ++ requiredTaskTools.add(tool.name); } -@@ -569,7 +582,7 @@ class Client extends protocol_js_1.Protocol { + } ++ this._cachedToolOutputValidators = outputValidators; ++ this._cachedKnownTaskTools = knownTaskTools; ++ this._cachedRequiredTaskTools = requiredTaskTools; + } + /** + * Get cached validator for a tool +@@ -567,9 +581,28 @@ class Client extends protocol_js_1.Protocol { + return this._cachedToolOutputValidators.get(toolName); + } async listTools(params, options) { ++ if (params?.cursor !== undefined) { ++ return this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); ++ } ++ const generation = ++this._toolListGeneration; const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); - // Cache the tools and their output schemas for future validation +- // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); -+ this.cacheToolMetadata(result.tools, params?.cursor === undefined); ++ const cursors = new Set(); ++ for (let page = 1; result.nextCursor !== undefined; page++) { ++ if (page >= 1000) { ++ throw new Error('MCP tools/list exceeded 1000 pages'); ++ } ++ if (cursors.has(result.nextCursor)) { ++ throw new Error(`MCP tools/list returned duplicate cursor: ${result.nextCursor}`); ++ } ++ cursors.add(result.nextCursor); ++ const next = await this.request({ method: 'tools/list', params: { ...params, cursor: result.nextCursor } }, types_js_1.ListToolsResultSchema, options); ++ result.tools.push(...next.tools); ++ result.nextCursor = next.nextCursor; ++ } ++ if (generation > this._toolListCommittedGeneration) { ++ this.cacheToolMetadata(result.tools); ++ this._toolListCommittedGeneration = generation; ++ } return result; } /** @@ -413,7 +456,16 @@ diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644 --- a/dist/esm/client/index.js +++ b/dist/esm/client/index.js -@@ -284,41 +284,16 @@ export class Client extends Protocol { +@@ -103,6 +103,8 @@ export class Client extends Protocol { + this._cachedToolOutputValidators = new Map(); + this._cachedKnownTaskTools = new Set(); + this._cachedRequiredTaskTools = new Set(); ++ this._toolListGeneration = 0; ++ this._toolListCommittedGeneration = 0; + this._listChangedDebounceTimers = new Map(); + this._capabilities = options?.capabilities ?? {}; + this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); +@@ -284,41 +286,16 @@ export class Client extends Protocol { } async connect(transport, options) { await super.connect(transport); @@ -459,7 +511,7 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e } catch (error) { // Disconnect if initialization fails. -@@ -326,6 +301,37 @@ export class Client extends Protocol { +@@ -326,6 +303,37 @@ export class Client extends Protocol { throw error; } } @@ -497,20 +549,17 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -536,13 +542,20 @@ export class Client extends Protocol { - * Cache validators for tool output schemas. +@@ -537,24 +545,30 @@ export class Client extends Protocol { * Called after listTools() to pre-compile validators for better performance. */ - cacheToolMetadata(tools) { - this._cachedToolOutputValidators.clear(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools, reset = true) { -+ if (reset) { -+ this._cachedToolOutputValidators.clear(); -+ this._cachedKnownTaskTools.clear(); -+ this._cachedRequiredTaskTools.clear(); -+ } ++ cacheToolMetadata(tools) { ++ const outputValidators = new Map(); ++ const knownTaskTools = new Set(); ++ const requiredTaskTools = new Set(); for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { @@ -518,18 +567,55 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e - this._cachedToolOutputValidators.set(tool.name, toolValidator); + try { + const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); -+ this._cachedToolOutputValidators.set(tool.name, toolValidator); -+ } -+ catch { -+ this._cachedToolOutputValidators.delete(tool.name); ++ outputValidators.set(tool.name, toolValidator); + } ++ catch { } + } + // If the tool supports task-based execution, cache that information + const taskSupport = tool.execution?.taskSupport; + if (taskSupport === 'required' || taskSupport === 'optional') { +- this._cachedKnownTaskTools.add(tool.name); ++ knownTaskTools.add(tool.name); + } + if (taskSupport === 'required') { +- this._cachedRequiredTaskTools.add(tool.name); ++ requiredTaskTools.add(tool.name); } -@@ -565,7 +578,7 @@ export class Client extends Protocol { + } ++ this._cachedToolOutputValidators = outputValidators; ++ this._cachedKnownTaskTools = knownTaskTools; ++ this._cachedRequiredTaskTools = requiredTaskTools; + } + /** + * Get cached validator for a tool +@@ -563,9 +577,28 @@ export class Client extends Protocol { + return this._cachedToolOutputValidators.get(toolName); + } async listTools(params, options) { ++ if (params?.cursor !== undefined) { ++ return this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); ++ } ++ const generation = ++this._toolListGeneration; const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); - // Cache the tools and their output schemas for future validation +- // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); -+ this.cacheToolMetadata(result.tools, params?.cursor === undefined); ++ const cursors = new Set(); ++ for (let page = 1; result.nextCursor !== undefined; page++) { ++ if (page >= 1000) { ++ throw new Error('MCP tools/list exceeded 1000 pages'); ++ } ++ if (cursors.has(result.nextCursor)) { ++ throw new Error(`MCP tools/list returned duplicate cursor: ${result.nextCursor}`); ++ } ++ cursors.add(result.nextCursor); ++ const next = await this.request({ method: 'tools/list', params: { ...params, cursor: result.nextCursor } }, ListToolsResultSchema, options); ++ result.tools.push(...next.tools); ++ result.nextCursor = next.nextCursor; ++ } ++ if (generation > this._toolListCommittedGeneration) { ++ this.cacheToolMetadata(result.tools); ++ this._toolListCommittedGeneration = generation; ++ } return result; } /** From b63fea036c4f74f5434dd762464c32abef81d666 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 20:08:34 -0500 Subject: [PATCH 7/7] refactor(mcp): narrow pagination metadata patch --- packages/opencode/src/mcp/catalog.ts | 31 ++- packages/opencode/src/mcp/index.ts | 19 +- packages/opencode/test/mcp/catalog.test.ts | 245 ------------------ packages/opencode/test/mcp/lifecycle.test.ts | 2 +- .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 156 ++--------- 5 files changed, 58 insertions(+), 395 deletions(-) diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 200a5b8bd94c..3f985eeb94dc 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -1,11 +1,20 @@ import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { CallToolResultSchema, type Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import { + CallToolResultSchema, + ListToolsResultSchema, + ToolSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { Effect } from "effect" const DEFAULT_TIMEOUT = 30_000 const MAX_LIST_PAGES = 1_000 +const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), +}) + export async function paginate( list: (cursor?: string) => Promise, items: (result: R) => T[], @@ -38,7 +47,7 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe additionalProperties: false, } - const tool = dynamicTool({ + return dynamicTool({ description: mcpTool.description ?? "", inputSchema: jsonSchema(inputSchema), execute: async (args: unknown, options) => { @@ -71,8 +80,6 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe } }, }) - if (!mcpTool.outputSchema) return tool - return { ...tool, outputSchema: jsonSchema(mcpTool.outputSchema as JSONSchema7) } } export function fetch( @@ -139,11 +146,25 @@ function listTools(client: Client, timeout: number) { return Effect.tryPromise({ try: () => paginate( - (cursor) => client.listTools(cursor === undefined ? undefined : { cursor }, { timeout }), + async (cursor) => { + const params = cursor === undefined ? undefined : { cursor } + try { + return await client.listTools(params, { timeout }) + } catch (error) { + if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error + return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) + } + }, (result) => result.tools, ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + export * as McpCatalog from "./catalog" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index dbf291754d55..05f12fa2ee45 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -440,7 +440,6 @@ const layer = Layer.effect( ) function watch(s: State, name: string, client: MCPClient, bridge: EffectBridge.Shape, timeout?: number) { - let refresh = Promise.resolve() client.onclose = () => { if (s.clients[name] !== client) return delete s.clients[name] @@ -460,19 +459,15 @@ const layer = Layer.effect( ) if (!client.getServerCapabilities()?.tools) return - client.setNotificationHandler(ToolListChangedNotificationSchema, () => { - const next = refresh.then(async () => { - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - const listed = await bridge.promise(McpCatalog.defs(client, timeout)) - if (!listed) return - if (s.clients[name] !== client || s.status[name]?.status !== "connected") return + const listed = await bridge.promise(McpCatalog.defs(client, timeout)) + if (!listed) return + if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - s.defs[name] = listed - await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) - }) - refresh = next.catch(() => {}) - return next + s.defs[name] = listed + await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) }) } diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 88fa18fc0647..7b0d6403bb16 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -4,7 +4,6 @@ import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" import { Server } from "@modelcontextprotocol/sdk/server/index.js" import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { McpCatalog } from "@/mcp/catalog" -import { asSchema } from "ai" import { Effect } from "effect" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -28,17 +27,6 @@ function mcpTool() { } describe("McpCatalog.convertTool", () => { - test("preserves the declared output schema", () => { - const outputSchema = { - type: "object" as const, - properties: { value: { type: "string" as const } }, - required: ["value"], - } - const converted = McpCatalog.convertTool({ ...mcpTool(), outputSchema }, clientReturning({ content: [] })) - - expect(asSchema(converted.outputSchema!).jsonSchema).toEqual(outputSchema) - }) - test("preserves content when structuredContent is also present", async () => { const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } @@ -117,236 +105,3 @@ test("preserves output schema validation across paginated tool discovery", async await Promise.all([client.close(), server.close()]) } }) - -test("isolates malformed output schemas while caching valid sibling schemas", async () => { - const server = new Server({ name: "schemas", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, () => - Promise.resolve({ - tools: [ - { - name: "malformed", - inputSchema: { type: "object" }, - outputSchema: { type: "object", properties: { value: { $ref: "#/$defs/Missing" } } }, - }, - { - name: "valid", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: "string" } }, - required: ["value"], - }, - }, - ], - }), - ) - server.setRequestHandler(CallToolRequestSchema, () => - Promise.resolve({ content: [], structuredContent: { value: 42 } }), - ) - - const client = new Client({ name: "schema-test", version: "1.0.0" }) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) - - try { - const tools = await Effect.runPromise(McpCatalog.defs(client)) - expect(tools?.map((tool) => tool.name)).toEqual(["malformed", "valid"]) - await expect(client.callTool({ name: "valid", arguments: {} })).rejects.toThrow( - "Structured content does not match the tool's output schema", - ) - } finally { - await Promise.all([client.close(), server.close()]) - } -}) - -test("refreshes valid sibling metadata when another output schema is malformed", async () => { - let refreshed = false - const server = new Server({ name: "schema-refresh", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, () => - Promise.resolve({ - tools: [ - ...(refreshed - ? [ - { - name: "malformed", - inputSchema: { type: "object" as const }, - outputSchema: { type: "object" as const, properties: { value: { $ref: "#/$defs/Missing" } } }, - }, - ] - : []), - { - name: "valid", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: refreshed ? ("number" as const) : ("string" as const) } }, - required: ["value"], - }, - }, - ], - }), - ) - server.setRequestHandler(CallToolRequestSchema, () => - Promise.resolve({ content: [], structuredContent: { value: 42 } }), - ) - - const client = new Client({ name: "schema-refresh-test", version: "1.0.0" }) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) - - try { - expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(1) - refreshed = true - expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(2) - await expect(client.callTool({ name: "valid", arguments: {} })).resolves.toMatchObject({ - structuredContent: { value: 42 }, - }) - } finally { - await Promise.all([client.close(), server.close()]) - } -}) - -test("retains the previous metadata when a continuation page fails", async () => { - let failContinuation = false - const server = new Server({ name: "failure", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, ({ params }) => { - if (params?.cursor === "page-2") throw new Error("continuation failed") - return Promise.resolve({ - tools: [ - { - name: "lookup", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: failContinuation ? "number" : "string" } }, - required: ["value"], - }, - }, - ], - nextCursor: failContinuation ? "page-2" : undefined, - }) - }) - server.setRequestHandler(CallToolRequestSchema, () => - Promise.resolve({ content: [], structuredContent: { value: 42 } }), - ) - - const client = new Client({ name: "failure-test", version: "1.0.0" }) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) - - try { - expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(1) - failContinuation = true - expect(await Effect.runPromise(McpCatalog.defs(client))).toBeUndefined() - await expect(client.callTool({ name: "lookup", arguments: {} })).rejects.toThrow( - "Structured content does not match the tool's output schema", - ) - } finally { - await Promise.all([client.close(), server.close()]) - } -}) - -test("does not let an older overlapping listing replace newer metadata", async () => { - let listing = 0 - let releaseOld: (() => void) | undefined - let markOldContinuationStarted: (() => void) | undefined - const oldContinuationStarted = new Promise((resolve) => (markOldContinuationStarted = resolve)) - const oldContinuation = new Promise((resolve) => (releaseOld = resolve)) - const server = new Server({ name: "overlap", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, async ({ params }) => { - if (params?.cursor === "old-page-2") { - markOldContinuationStarted?.() - await oldContinuation - return { tools: [] } - } - listing++ - return { - tools: [ - { - name: "lookup", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: listing === 1 ? "string" : "number" } }, - required: ["value"], - }, - }, - ], - nextCursor: listing === 1 ? "old-page-2" : undefined, - } - }) - server.setRequestHandler(CallToolRequestSchema, () => - Promise.resolve({ content: [], structuredContent: { value: 42 } }), - ) - - const client = new Client({ name: "overlap-test", version: "1.0.0" }) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) - - try { - const old = Effect.runPromise(McpCatalog.defs(client)) - await oldContinuationStarted - expect(await Effect.runPromise(McpCatalog.defs(client))).toHaveLength(1) - releaseOld?.() - expect(await old).toHaveLength(1) - await expect(client.callTool({ name: "lookup", arguments: {} })).resolves.toMatchObject({ - structuredContent: { value: 42 }, - }) - } finally { - releaseOld?.() - await Promise.all([client.close(), server.close()]) - } -}) - -test("commits an older overlapping listing when the newer listing fails", async () => { - let listing = 0 - let releaseOld: (() => void) | undefined - let markOldContinuationStarted: (() => void) | undefined - const oldContinuationStarted = new Promise((resolve) => (markOldContinuationStarted = resolve)) - const oldContinuation = new Promise((resolve) => (releaseOld = resolve)) - const server = new Server({ name: "overlap-failure", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, async ({ params }) => { - if (params?.cursor === "old-page-2") { - markOldContinuationStarted?.() - await oldContinuation - return { tools: [] } - } - if (params?.cursor === "new-page-2") throw new Error("newer continuation failed") - listing++ - return { - tools: [ - { - name: "lookup", - inputSchema: { type: "object" }, - outputSchema: { - type: "object", - properties: { value: { type: listing === 1 ? "string" : "number" } }, - required: ["value"], - }, - }, - ], - nextCursor: listing === 1 ? "old-page-2" : "new-page-2", - } - }) - server.setRequestHandler(CallToolRequestSchema, () => - Promise.resolve({ content: [], structuredContent: { value: 42 } }), - ) - - const client = new Client({ name: "overlap-failure-test", version: "1.0.0" }) - const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair() - await Promise.all([client.connect(clientTransport), server.connect(serverTransport)]) - - try { - const old = Effect.runPromise(McpCatalog.defs(client)) - await oldContinuationStarted - expect(await Effect.runPromise(McpCatalog.defs(client))).toBeUndefined() - releaseOld?.() - expect(await old).toHaveLength(1) - await expect(client.callTool({ name: "lookup", arguments: {} })).rejects.toThrow( - "Structured content does not match the tool's output schema", - ) - } finally { - releaseOld?.() - await Promise.all([client.close(), server.close()]) - } -}) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 5773c3302ef9..80c8fd22f886 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -360,7 +360,7 @@ it.instance("one failed server does not affect another connected server", () => }), ) -it.instance("isolates invalid output schema refs without dropping tool discovery", () => +it.instance("falls back when output schema refs fail SDK tool discovery", () => Effect.gen(function* () { const server = yield* lifecycleServer({ capabilities: { tools: {} } }) server.state.tools = [ diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index dbfd6a21b412..13b8000a0139 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -28,16 +28,7 @@ diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644 --- a/dist/cjs/client/index.js +++ b/dist/cjs/client/index.js -@@ -107,6 +107,8 @@ class Client extends protocol_js_1.Protocol { - this._cachedToolOutputValidators = new Map(); - this._cachedKnownTaskTools = new Set(); - this._cachedRequiredTaskTools = new Set(); -+ this._toolListGeneration = 0; -+ this._toolListCommittedGeneration = 0; - this._listChangedDebounceTimers = new Map(); - this._capabilities = options?.capabilities ?? {}; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new ajv_provider_js_1.AjvJsonSchemaValidator(); -@@ -288,41 +290,16 @@ class Client extends protocol_js_1.Protocol { +@@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol { } async connect(transport, options) { await super.connect(transport); @@ -83,7 +74,7 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 } catch (error) { // Disconnect if initialization fails. -@@ -330,6 +307,37 @@ class Client extends protocol_js_1.Protocol { +@@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol { throw error; } } @@ -121,73 +112,28 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -541,24 +549,30 @@ class Client extends protocol_js_1.Protocol { +@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol { * Called after listTools() to pre-compile validators for better performance. */ - cacheToolMetadata(tools) { - this._cachedToolOutputValidators.clear(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools) { -+ const outputValidators = new Map(); -+ const knownTaskTools = new Set(); -+ const requiredTaskTools = new Set(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { -- const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); -- this._cachedToolOutputValidators.set(tool.name, toolValidator); -+ try { -+ const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); -+ outputValidators.set(tool.name, toolValidator); -+ } -+ catch { } - } - // If the tool supports task-based execution, cache that information - const taskSupport = tool.execution?.taskSupport; - if (taskSupport === 'required' || taskSupport === 'optional') { -- this._cachedKnownTaskTools.add(tool.name); -+ knownTaskTools.add(tool.name); - } - if (taskSupport === 'required') { -- this._cachedRequiredTaskTools.add(tool.name); -+ requiredTaskTools.add(tool.name); - } - } -+ this._cachedToolOutputValidators = outputValidators; -+ this._cachedKnownTaskTools = knownTaskTools; -+ this._cachedRequiredTaskTools = requiredTaskTools; - } - /** - * Get cached validator for a tool -@@ -567,9 +581,28 @@ class Client extends protocol_js_1.Protocol { - return this._cachedToolOutputValidators.get(toolName); - } +@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol { async listTools(params, options) { -+ if (params?.cursor !== undefined) { -+ return this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); -+ } -+ const generation = ++this._toolListGeneration; const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); -- // Cache the tools and their output schemas for future validation + // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); -+ const cursors = new Set(); -+ for (let page = 1; result.nextCursor !== undefined; page++) { -+ if (page >= 1000) { -+ throw new Error('MCP tools/list exceeded 1000 pages'); -+ } -+ if (cursors.has(result.nextCursor)) { -+ throw new Error(`MCP tools/list returned duplicate cursor: ${result.nextCursor}`); -+ } -+ cursors.add(result.nextCursor); -+ const next = await this.request({ method: 'tools/list', params: { ...params, cursor: result.nextCursor } }, types_js_1.ListToolsResultSchema, options); -+ result.tools.push(...next.tools); -+ result.nextCursor = next.nextCursor; -+ } -+ if (generation > this._toolListCommittedGeneration) { -+ this.cacheToolMetadata(result.tools); -+ this._toolListCommittedGeneration = generation; -+ } ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); return result; } /** @@ -456,16 +402,7 @@ diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644 --- a/dist/esm/client/index.js +++ b/dist/esm/client/index.js -@@ -103,6 +103,8 @@ export class Client extends Protocol { - this._cachedToolOutputValidators = new Map(); - this._cachedKnownTaskTools = new Set(); - this._cachedRequiredTaskTools = new Set(); -+ this._toolListGeneration = 0; -+ this._toolListCommittedGeneration = 0; - this._listChangedDebounceTimers = new Map(); - this._capabilities = options?.capabilities ?? {}; - this._jsonSchemaValidator = options?.jsonSchemaValidator ?? new AjvJsonSchemaValidator(); -@@ -284,41 +286,16 @@ export class Client extends Protocol { +@@ -284,41 +284,16 @@ export class Client extends Protocol { } async connect(transport, options) { await super.connect(transport); @@ -511,7 +448,7 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e } catch (error) { // Disconnect if initialization fails. -@@ -326,6 +303,37 @@ export class Client extends Protocol { +@@ -326,6 +301,37 @@ export class Client extends Protocol { throw error; } } @@ -549,73 +486,28 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ -@@ -537,24 +545,30 @@ export class Client extends Protocol { +@@ -537,9 +543,11 @@ export class Client extends Protocol { * Called after listTools() to pre-compile validators for better performance. */ - cacheToolMetadata(tools) { - this._cachedToolOutputValidators.clear(); - this._cachedKnownTaskTools.clear(); - this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools) { -+ const outputValidators = new Map(); -+ const knownTaskTools = new Set(); -+ const requiredTaskTools = new Set(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } for (const tool of tools) { // If the tool has an outputSchema, create and cache the validator if (tool.outputSchema) { -- const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); -- this._cachedToolOutputValidators.set(tool.name, toolValidator); -+ try { -+ const toolValidator = this._jsonSchemaValidator.getValidator(tool.outputSchema); -+ outputValidators.set(tool.name, toolValidator); -+ } -+ catch { } - } - // If the tool supports task-based execution, cache that information - const taskSupport = tool.execution?.taskSupport; - if (taskSupport === 'required' || taskSupport === 'optional') { -- this._cachedKnownTaskTools.add(tool.name); -+ knownTaskTools.add(tool.name); - } - if (taskSupport === 'required') { -- this._cachedRequiredTaskTools.add(tool.name); -+ requiredTaskTools.add(tool.name); - } - } -+ this._cachedToolOutputValidators = outputValidators; -+ this._cachedKnownTaskTools = knownTaskTools; -+ this._cachedRequiredTaskTools = requiredTaskTools; - } - /** - * Get cached validator for a tool -@@ -563,9 +577,28 @@ export class Client extends Protocol { - return this._cachedToolOutputValidators.get(toolName); - } +@@ -565,7 +573,7 @@ export class Client extends Protocol { async listTools(params, options) { -+ if (params?.cursor !== undefined) { -+ return this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); -+ } -+ const generation = ++this._toolListGeneration; const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); -- // Cache the tools and their output schemas for future validation + // Cache the tools and their output schemas for future validation - this.cacheToolMetadata(result.tools); -+ const cursors = new Set(); -+ for (let page = 1; result.nextCursor !== undefined; page++) { -+ if (page >= 1000) { -+ throw new Error('MCP tools/list exceeded 1000 pages'); -+ } -+ if (cursors.has(result.nextCursor)) { -+ throw new Error(`MCP tools/list returned duplicate cursor: ${result.nextCursor}`); -+ } -+ cursors.add(result.nextCursor); -+ const next = await this.request({ method: 'tools/list', params: { ...params, cursor: result.nextCursor } }, ListToolsResultSchema, options); -+ result.tools.push(...next.tools); -+ result.nextCursor = next.nextCursor; -+ } -+ if (generation > this._toolListCommittedGeneration) { -+ this.cacheToolMetadata(result.tools); -+ this._toolListCommittedGeneration = generation; -+ } ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); return result; } /**