From f5222639172bdd5139077a7631bf4b67f4d10452 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Sun, 5 Jul 2026 22:28:28 -0500 Subject: [PATCH 1/3] fix(mcp): preserve metadata across tool pages (#35439) --- packages/opencode/test/mcp/catalog.test.ts | 62 ++++++++++++++++++- .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 50 +++++++++++++++ 2 files changed, 111 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 55cabaef7699..7b0d6403bb16 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,59 @@ 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..13b8000a0139 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -112,6 +112,31 @@ index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c1 /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +@@ -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, 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) { +@@ -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 +- 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 +486,31 @@ index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8e /** * After initialization has completed, this will be populated with the server's reported capabilities. */ +@@ -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, 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) { +@@ -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 +- 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 98e8e3c6f45471ce8fa438e5c7088e8275d4d979 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 22:31:45 -0500 Subject: [PATCH 2/3] test(core): cover paginated mcp metadata --- packages/core/test/mcp.test.ts | 61 +++++++++++++++++++++ packages/opencode/test/mcp/catalog.test.ts | 62 +--------------------- 2 files changed, 62 insertions(+), 61 deletions(-) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index ffcc2d3b8964..2e5822f4ffe6 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -1,4 +1,8 @@ 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 { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { EventV2 } from "@opencode-ai/core/event" @@ -72,6 +76,63 @@ test("MCP tool names match V1 sanitization", () => { expect(McpTool.name("context 7", "resolve.library/id")).toBe("context_7_resolve_library_id") }) +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 first = await client.listTools() + const second = await client.listTools({ cursor: first.nextCursor }) + expect([...first.tools, ...second.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()]) + } +}) + it.effect("waits for permission before calling an MCP tool", () => Effect.gen(function* () { calls = 0 diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 7b0d6403bb16..55cabaef7699 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,10 +1,6 @@ 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" const options = { toolCallId: "call_mcp", abortSignal: new AbortController().signal } as any @@ -49,59 +45,3 @@ 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()]) - } -}) From dc4e2a876837b6cadef1b948b4af93de8f70807d Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 5 Jul 2026 22:34:40 -0500 Subject: [PATCH 3/3] test(mcp): retain paginated tool coverage --- packages/opencode/test/mcp/catalog.test.ts | 62 +++++++++++++++++++++- 1 file changed, 61 insertions(+), 1 deletion(-) diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 55cabaef7699..7b0d6403bb16 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,59 @@ 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()]) + } +})