From 3a56b21b374e3fc118cf56d05c2338fd9ceda4e5 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 8 Jun 2026 19:53:27 -0500 Subject: [PATCH 1/4] fix(opencode): paginate MCP catalogs --- packages/opencode/src/mcp/index.ts | 87 +++++++++++------ packages/opencode/test/mcp/lifecycle.test.ts | 99 +++++++++++++++++++- 2 files changed, 152 insertions(+), 34 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 3a348abb8ed3..d8efcc0eb26d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -112,6 +112,7 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { } const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") +const MAX_LIST_PAGES = 1_000 function remoteURL(key: string, value: string) { if (URL.canParse(value)) return new URL(value) @@ -123,31 +124,48 @@ function isOutputSchemaValidationError(error: Error) { ) } -function listTools(key: string, client: MCPClient, timeout: number) { +async function paginate( + list: (cursor?: string) => Promise, + items: (result: R) => T[], +) { + const result: T[] = [] + const cursors = new Set() + let cursor: string | undefined + + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const page = await list(cursor) + result.push(...items(page)) + if (!page.nextCursor) return result + if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`) + cursors.add(page.nextCursor) + cursor = page.nextCursor + } + + throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) +} + +function listTools(client: MCPClient, timeout: number) { return Effect.tryPromise({ - try: () => client.listTools(undefined, { timeout }), + try: () => + paginate( + async (cursor) => { + const params = cursor ? { cursor } : undefined + return client.listTools(params, { timeout }).catch(async (error) => { + if (!isOutputSchemaValidationError(error)) throw error + return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) + }) + }, + (result) => result.tools, + ), catch: (err) => (err instanceof Error ? err : new Error(String(err))), }).pipe( - Effect.map((result) => result.tools), - Effect.catch((error) => { - if (!isOutputSchemaValidationError(error)) return Effect.fail(error) - - return Effect.tryPromise({ - try: () => - client.request({ method: "tools/list" }, TolerantListToolsResultSchema, { - timeout, - }), - catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((result) => - result.tools.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - })), - ), - ) - }), + Effect.map((tools) => + tools.map((tool) => ({ + name: tool.name, + description: tool.description, + inputSchema: tool.inputSchema, + })), + ), ) } @@ -182,8 +200,8 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number }) } -function defs(key: string, client: MCPClient, timeout?: number) { - return listTools(key, client, timeout ?? DEFAULT_TIMEOUT).pipe( +function defs(client: MCPClient, timeout?: number) { + return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe( Effect.catch((err) => { return Effect.succeed(undefined) }), @@ -448,7 +466,7 @@ export const layer = Layer.effect( return { status } satisfies CreateResult } - const listed = mcpClient.getServerCapabilities()?.tools ? yield* defs(key, mcpClient, mcp.timeout) : [] + const listed = mcpClient.getServerCapabilities()?.tools ? yield* defs(mcpClient, mcp.timeout) : [] if (!listed) { yield* Effect.tryPromise(() => mcpClient.close()).pipe(Effect.ignore) return { status: { status: "failed", error: "Failed to get tools" } } satisfies CreateResult @@ -487,7 +505,7 @@ export const layer = Layer.effect( client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (s.clients[name] !== client || s.status[name]?.status !== "connected") return - const listed = await bridge.promise(defs(name, client, timeout)) + const listed = await bridge.promise(defs(client, timeout)) if (!listed) return if (s.clients[name] !== client || s.status[name]?.status !== "connected") return @@ -693,7 +711,13 @@ export const layer = Layer.effect( const s = yield* InstanceState.get(state) return yield* collectFromConnected( s, - (c) => (c.getServerCapabilities()?.prompts ? c.listPrompts().then((r) => r.prompts) : Promise.resolve([])), + (c) => + c.getServerCapabilities()?.prompts + ? paginate( + (cursor) => c.listPrompts(cursor ? { cursor } : undefined), + (result) => result.prompts, + ) + : Promise.resolve([]), "prompts", ) }) @@ -703,7 +727,12 @@ export const layer = Layer.effect( return yield* collectFromConnected( s, (c) => - c.getServerCapabilities()?.resources ? c.listResources().then((r) => r.resources) : Promise.resolve([]), + c.getServerCapabilities()?.resources + ? paginate( + (cursor) => c.listResources(cursor ? { cursor } : undefined), + (result) => result.resources, + ) + : Promise.resolve([]), "resources", ) }) @@ -830,7 +859,7 @@ export const layer = Layer.effect( const listed = client ? client.getServerCapabilities()?.tools - ? yield* defs(mcpName, client, mcpConfig.timeout) + ? yield* defs(client, mcpConfig.timeout) : [] : undefined if (!client || !listed) { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 7bd5e4f00b4d..35efc4d28617 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -19,6 +19,18 @@ interface MockClientState { listResourcesShouldFail: boolean prompts: Array<{ name: string; description?: string }> resources: Array<{ name: string; uri: string; description?: string }> + toolPages: Record< + string, + { + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> + nextCursor?: string + } + > + promptPages: Record; nextCursor?: string }> + resourcePages: Record< + string, + { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } + > closed: boolean notificationHandlers: Map any> } @@ -50,6 +62,9 @@ function getOrCreateClientState(name?: string): MockClientState { listResourcesShouldFail: false, prompts: [], resources: [], + toolPages: {}, + promptPages: {}, + resourcePages: {}, closed: false, notificationHandlers: new Map(), } @@ -143,33 +158,44 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return this._state?.capabilities } - async listTools() { + async listTools(params?: { cursor?: string }) { if (this._state) this._state.listToolsCalls++ if (this._state?.listToolsShouldFail) { throw new Error(this._state.listToolsError) } + const page = this._state?.toolPages[params?.cursor ?? ""] + if (page) return page return { tools: this._state?.tools ?? [] } } - async request(request: { method: string }, schema: { parse: (value: unknown) => unknown }) { + async request( + request: { method: string; params?: { cursor?: string } }, + schema: { parse: (value: unknown) => unknown }, + ) { if (this._state) this._state.requestCalls++ - if (request.method === "tools/list") return schema.parse({ tools: this._state?.tools ?? [] }) + if (request.method === "tools/list") { + return schema.parse(this._state?.toolPages[request.params?.cursor ?? ""] ?? { tools: this._state?.tools ?? [] }) + } throw new Error(`unsupported request: ${request.method}`) } - async listPrompts() { + async listPrompts(params?: { cursor?: string }) { if (this._state) this._state.listPromptsCalls++ if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") } + const page = this._state?.promptPages[params?.cursor ?? ""] + if (page) return page return { prompts: this._state?.prompts ?? [] } } - async listResources() { + async listResources(params?: { cursor?: string }) { if (this._state) this._state.listResourcesCalls++ if (this._state?.listResourcesShouldFail) { throw new Error("listResources failed") } + const page = this._state?.resourcePages[params?.cursor ?? ""] + if (page) return page return { resources: this._state?.resources ?? [] } } @@ -234,6 +260,69 @@ it.instance( { config: { mcp: {} } }, ) +it.instance( + "follows cursors when listing tools, prompts, and resources", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "paged-server" + const serverState = getOrCreateClientState("paged-server") + serverState.toolPages = { + "": { + tools: [{ name: "tool-one", inputSchema: { type: "object", properties: {} } }], + nextCursor: "tools-2", + }, + "tools-2": { tools: [{ name: "tool-two", inputSchema: { type: "object", properties: {} } }] }, + } + serverState.promptPages = { + "": { prompts: [{ name: "prompt-one" }], nextCursor: "prompts-2" }, + "prompts-2": { prompts: [{ name: "prompt-two" }] }, + } + serverState.resourcePages = { + "": { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, + "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] }, + } + + yield* mcp.add("paged-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) + expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) + expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"]) + expect(serverState.listToolsCalls).toBe(2) + expect(serverState.listPromptsCalls).toBe(2) + expect(serverState.listResourcesCalls).toBe(2) + }), + ), + { config: { mcp: {} } }, +) + +it.instance( + "stops listing when a server repeats a cursor", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "looping-server" + const serverState = getOrCreateClientState("looping-server") + serverState.toolPages = { + "": { tools: [], nextCursor: "repeat" }, + repeat: { tools: [], nextCursor: "repeat" }, + } + + yield* mcp.add("looping-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(serverState.listToolsCalls).toBe(2) + expect(yield* mcp.tools()).toEqual({}) + }), + ), + { config: { mcp: {} } }, +) + // ======================================================================== // Test: tool change notifications refresh the cache // ======================================================================== From 35063107b1ed108f838f4c2e45f1674caaa0907c Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 8 Jun 2026 20:08:51 -0500 Subject: [PATCH 2/4] fix(opencode): preserve MCP pagination metadata --- packages/opencode/src/mcp/index.ts | 14 ++++-- packages/opencode/test/mcp/lifecycle.test.ts | 53 ++++++++++++++++---- 2 files changed, 52 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index d8efcc0eb26d..be3f8ee16f1f 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -135,7 +135,7 @@ async function paginate( for (let page = 0; page < MAX_LIST_PAGES; page++) { const page = await list(cursor) result.push(...items(page)) - if (!page.nextCursor) return result + if (page.nextCursor === undefined) return result if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`) cursors.add(page.nextCursor) cursor = page.nextCursor @@ -149,8 +149,12 @@ function listTools(client: MCPClient, timeout: number) { try: () => paginate( async (cursor) => { - const params = cursor ? { cursor } : undefined - return client.listTools(params, { timeout }).catch(async (error) => { + const params = cursor === undefined ? undefined : { cursor } + return ( + cursor === undefined + ? client.listTools(params, { timeout }) + : client.request({ method: "tools/list", params }, ListToolsResultSchema, { timeout }) + ).catch(async (error) => { if (!isOutputSchemaValidationError(error)) throw error return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) }) @@ -714,7 +718,7 @@ export const layer = Layer.effect( (c) => c.getServerCapabilities()?.prompts ? paginate( - (cursor) => c.listPrompts(cursor ? { cursor } : undefined), + (cursor) => c.listPrompts(cursor === undefined ? undefined : { cursor }), (result) => result.prompts, ) : Promise.resolve([]), @@ -729,7 +733,7 @@ export const layer = Layer.effect( (c) => c.getServerCapabilities()?.resources ? paginate( - (cursor) => c.listResources(cursor ? { cursor } : undefined), + (cursor) => c.listResources(cursor === undefined ? undefined : { cursor }), (result) => result.resources, ) : Promise.resolve([]), diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 35efc4d28617..2c8ee9a5cdf9 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -163,7 +163,7 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ if (this._state?.listToolsShouldFail) { throw new Error(this._state.listToolsError) } - const page = this._state?.toolPages[params?.cursor ?? ""] + const page = this._state?.toolPages[params === undefined ? "initial" : (params.cursor ?? "")] if (page) return page return { tools: this._state?.tools ?? [] } } @@ -174,7 +174,11 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ ) { if (this._state) this._state.requestCalls++ if (request.method === "tools/list") { - return schema.parse(this._state?.toolPages[request.params?.cursor ?? ""] ?? { tools: this._state?.tools ?? [] }) + return schema.parse( + this._state?.toolPages[request.params === undefined ? "initial" : (request.params.cursor ?? "")] ?? { + tools: this._state?.tools ?? [], + }, + ) } throw new Error(`unsupported request: ${request.method}`) } @@ -184,7 +188,7 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ if (this._state?.listPromptsShouldFail) { throw new Error("listPrompts failed") } - const page = this._state?.promptPages[params?.cursor ?? ""] + const page = this._state?.promptPages[params === undefined ? "initial" : (params.cursor ?? "")] if (page) return page return { prompts: this._state?.prompts ?? [] } } @@ -194,7 +198,7 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ if (this._state?.listResourcesShouldFail) { throw new Error("listResources failed") } - const page = this._state?.resourcePages[params?.cursor ?? ""] + const page = this._state?.resourcePages[params === undefined ? "initial" : (params.cursor ?? "")] if (page) return page return { resources: this._state?.resources ?? [] } } @@ -268,18 +272,18 @@ it.instance( lastCreatedClientName = "paged-server" const serverState = getOrCreateClientState("paged-server") serverState.toolPages = { - "": { + initial: { tools: [{ name: "tool-one", inputSchema: { type: "object", properties: {} } }], nextCursor: "tools-2", }, "tools-2": { tools: [{ name: "tool-two", inputSchema: { type: "object", properties: {} } }] }, } serverState.promptPages = { - "": { prompts: [{ name: "prompt-one" }], nextCursor: "prompts-2" }, + initial: { prompts: [{ name: "prompt-one" }], nextCursor: "prompts-2" }, "prompts-2": { prompts: [{ name: "prompt-two" }] }, } serverState.resourcePages = { - "": { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, + initial: { resources: [{ name: "resource-one", uri: "test://one" }], nextCursor: "resources-2" }, "resources-2": { resources: [{ name: "resource-two", uri: "test://two" }] }, } @@ -291,7 +295,8 @@ it.instance( expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"]) - expect(serverState.listToolsCalls).toBe(2) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) expect(serverState.listPromptsCalls).toBe(2) expect(serverState.listResourcesCalls).toBe(2) }), @@ -307,7 +312,7 @@ it.instance( lastCreatedClientName = "looping-server" const serverState = getOrCreateClientState("looping-server") serverState.toolPages = { - "": { tools: [], nextCursor: "repeat" }, + initial: { tools: [], nextCursor: "repeat" }, repeat: { tools: [], nextCursor: "repeat" }, } @@ -316,13 +321,41 @@ it.instance( command: ["echo", "test"], }) - expect(serverState.listToolsCalls).toBe(2) + expect(serverState.listToolsCalls).toBe(1) + expect(serverState.requestCalls).toBe(1) expect(yield* mcp.tools()).toEqual({}) }), ), { config: { mcp: {} } }, ) +it.instance( + "follows empty cursors", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "empty-cursor-server" + const serverState = getOrCreateClientState("empty-cursor-server") + serverState.promptPages = { + initial: { prompts: [{ name: "prompt-one" }], nextCursor: "" }, + "": { prompts: [{ name: "prompt-two" }] }, + } + + yield* mcp.add("empty-cursor-server", { + type: "local", + command: ["echo", "test"], + }) + + expect(Object.keys(yield* mcp.prompts())).toEqual([ + "empty-cursor-server:prompt-one", + "empty-cursor-server:prompt-two", + ]) + expect(serverState.listPromptsCalls).toBe(2) + }), + ), + { config: { mcp: {} } }, +) + // ======================================================================== // Test: tool change notifications refresh the cache // ======================================================================== From a5e545b251e797532398ab18763a20d4120d8890 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 8 Jun 2026 20:33:13 -0500 Subject: [PATCH 3/4] fix(opencode): enforce paginated MCP metadata --- packages/opencode/src/mcp/index.ts | 45 ++++++++----- packages/opencode/test/mcp/lifecycle.test.ts | 70 +++++++++++++++++++- 2 files changed, 95 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index be3f8ee16f1f..ec5ba49d32a9 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,6 +6,7 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv" import { CallToolResultSchema, ListToolsResultSchema, @@ -113,6 +114,7 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") const MAX_LIST_PAGES = 1_000 +const outputValidator = new AjvJsonSchemaValidator() function remoteURL(key: string, value: string) { if (URL.canParse(value)) return new URL(value) @@ -148,29 +150,21 @@ function listTools(client: MCPClient, timeout: number) { return Effect.tryPromise({ try: () => paginate( - async (cursor) => { + async (cursor): Promise<{ tools: MCPToolDef[]; nextCursor?: string }> => { const params = cursor === undefined ? undefined : { cursor } - return ( - cursor === undefined - ? client.listTools(params, { timeout }) - : client.request({ method: "tools/list", params }, ListToolsResultSchema, { timeout }) - ).catch(async (error) => { - if (!isOutputSchemaValidationError(error)) throw error + try { + return cursor === undefined + ? await client.listTools(params, { timeout }) + : await client.request({ method: "tools/list", params }, ListToolsResultSchema, { timeout }) + } catch (error) { + if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) - }) + } }, (result) => result.tools, ), catch: (err) => (err instanceof Error ? err : new Error(String(err))), - }).pipe( - Effect.map((tools) => - tools.map((tool) => ({ - name: tool.name, - description: tool.description, - inputSchema: tool.inputSchema, - })), - ), - ) + }) } // Convert MCP tool definition to AI SDK Tool type @@ -189,7 +183,11 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number description: mcpTool.description ?? "", inputSchema: jsonSchema(schema), execute: async (args: unknown) => { - return client.callTool( + if (mcpTool.execution?.taskSupport === "required") { + throw new Error(`Tool "${mcpTool.name}" requires MCP Tasks, which OpenCode does not support`) + } + + const result = await client.callTool( { name: mcpTool.name, arguments: (args || {}) as Record, @@ -200,6 +198,17 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number timeout, }, ) + if (!mcpTool.outputSchema || result.isError) return result + if (!result.structuredContent) { + throw new Error(`Tool "${mcpTool.name}" has an output schema but did not return structured content`) + } + const validation = outputValidator.getValidator(mcpTool.outputSchema)(result.structuredContent) + if (!validation.valid) { + throw new Error( + `Structured content does not match tool "${mcpTool.name}" output schema: ${validation.errorMessage}`, + ) + } + return result }, }) } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 2c8ee9a5cdf9..8af18e1e274b 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -8,7 +8,13 @@ import { testEffect } from "../lib/effect" // Per-client state for controlling mock behavior interface MockClientState { capabilities: { tools?: object; prompts?: object; resources?: object } - tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> + tools: Array<{ + name: string + description?: string + inputSchema: object + outputSchema?: object + execution?: { taskSupport?: "required" | "optional" | "forbidden" } + }> listToolsCalls: number listPromptsCalls: number listResourcesCalls: number @@ -22,7 +28,13 @@ interface MockClientState { toolPages: Record< string, { - tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> + tools: Array<{ + name: string + description?: string + inputSchema: object + outputSchema?: object + execution?: { taskSupport?: "required" | "optional" | "forbidden" } + }> nextCursor?: string } > @@ -31,6 +43,8 @@ interface MockClientState { string, { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > + callToolResult: { content: Array<{ type: "text"; text: string }>; structuredContent?: Record } + callToolCalls: number closed: boolean notificationHandlers: Map any> } @@ -65,6 +79,8 @@ function getOrCreateClientState(name?: string): MockClientState { toolPages: {}, promptPages: {}, resourcePages: {}, + callToolResult: { content: [{ type: "text", text: "ok" }] }, + callToolCalls: 0, closed: false, notificationHandlers: new Map(), } @@ -203,6 +219,11 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { resources: this._state?.resources ?? [] } } + async callTool() { + if (this._state) this._state.callToolCalls++ + return this._state?.callToolResult + } + async close() { if (this._state) this._state.closed = true } @@ -356,6 +377,51 @@ it.instance( { config: { mcp: {} } }, ) +it.instance( + "enforces metadata from later tool pages", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "metadata-server" + const serverState = getOrCreateClientState("metadata-server") + serverState.toolPages = { + initial: { tools: [], nextCursor: "next" }, + next: { + tools: [ + { + name: "structured", + inputSchema: { type: "object", properties: {} }, + outputSchema: { type: "object", required: ["value"], properties: { value: { type: "string" } } }, + }, + { + name: "task", + inputSchema: { type: "object", properties: {} }, + execution: { taskSupport: "required" }, + }, + ], + }, + } + + yield* mcp.add("metadata-server", { + type: "local", + command: ["echo", "test"], + }) + + const tools = yield* mcp.tools() + const structured = yield* Effect.tryPromise(() => + tools["metadata-server_structured"].execute!({}, {} as never), + ).pipe(Effect.exit) + const task = yield* Effect.tryPromise(() => tools["metadata-server_task"].execute!({}, {} as never)).pipe( + Effect.exit, + ) + expect(Exit.isFailure(structured)).toBe(true) + expect(Exit.isFailure(task)).toBe(true) + expect(serverState.callToolCalls).toBe(1) + }), + ), + { config: { mcp: {} } }, +) + // ======================================================================== // Test: tool change notifications refresh the cache // ======================================================================== From 6750613fddb670705d5df6d817266a3931c54f77 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 8 Jun 2026 22:22:20 -0500 Subject: [PATCH 4/4] refactor(opencode): keep MCP pagination focused --- packages/opencode/src/mcp/index.ts | 25 +------ packages/opencode/test/mcp/lifecycle.test.ts | 76 ++------------------ 2 files changed, 7 insertions(+), 94 deletions(-) diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index ec5ba49d32a9..7d0ec3ecd679 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -6,7 +6,6 @@ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/ import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" -import { AjvJsonSchemaValidator } from "@modelcontextprotocol/sdk/validation/ajv" import { CallToolResultSchema, ListToolsResultSchema, @@ -114,7 +113,6 @@ function isMcpConfigured(entry: McpEntry): entry is ConfigMCPV1.Info { const sanitize = (s: string) => s.replace(/[^a-zA-Z0-9_-]/g, "_") const MAX_LIST_PAGES = 1_000 -const outputValidator = new AjvJsonSchemaValidator() function remoteURL(key: string, value: string) { if (URL.canParse(value)) return new URL(value) @@ -150,12 +148,10 @@ function listTools(client: MCPClient, timeout: number) { return Effect.tryPromise({ try: () => paginate( - async (cursor): Promise<{ tools: MCPToolDef[]; nextCursor?: string }> => { + async (cursor) => { const params = cursor === undefined ? undefined : { cursor } try { - return cursor === undefined - ? await client.listTools(params, { timeout }) - : await client.request({ method: "tools/list", params }, ListToolsResultSchema, { timeout }) + 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 }) @@ -183,11 +179,7 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number description: mcpTool.description ?? "", inputSchema: jsonSchema(schema), execute: async (args: unknown) => { - if (mcpTool.execution?.taskSupport === "required") { - throw new Error(`Tool "${mcpTool.name}" requires MCP Tasks, which OpenCode does not support`) - } - - const result = await client.callTool( + return client.callTool( { name: mcpTool.name, arguments: (args || {}) as Record, @@ -198,17 +190,6 @@ function convertMcpTool(mcpTool: MCPToolDef, client: MCPClient, timeout?: number timeout, }, ) - if (!mcpTool.outputSchema || result.isError) return result - if (!result.structuredContent) { - throw new Error(`Tool "${mcpTool.name}" has an output schema but did not return structured content`) - } - const validation = outputValidator.getValidator(mcpTool.outputSchema)(result.structuredContent) - if (!validation.valid) { - throw new Error( - `Structured content does not match tool "${mcpTool.name}" output schema: ${validation.errorMessage}`, - ) - } - return result }, }) } diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 8af18e1e274b..d4ae15c26b88 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -8,13 +8,7 @@ import { testEffect } from "../lib/effect" // Per-client state for controlling mock behavior interface MockClientState { capabilities: { tools?: object; prompts?: object; resources?: object } - tools: Array<{ - name: string - description?: string - inputSchema: object - outputSchema?: object - execution?: { taskSupport?: "required" | "optional" | "forbidden" } - }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> listToolsCalls: number listPromptsCalls: number listResourcesCalls: number @@ -28,13 +22,7 @@ interface MockClientState { toolPages: Record< string, { - tools: Array<{ - name: string - description?: string - inputSchema: object - outputSchema?: object - execution?: { taskSupport?: "required" | "optional" | "forbidden" } - }> + tools: Array<{ name: string; description?: string; inputSchema: object; outputSchema?: object }> nextCursor?: string } > @@ -43,8 +31,6 @@ interface MockClientState { string, { resources: Array<{ name: string; uri: string; description?: string }>; nextCursor?: string } > - callToolResult: { content: Array<{ type: "text"; text: string }>; structuredContent?: Record } - callToolCalls: number closed: boolean notificationHandlers: Map any> } @@ -79,8 +65,6 @@ function getOrCreateClientState(name?: string): MockClientState { toolPages: {}, promptPages: {}, resourcePages: {}, - callToolResult: { content: [{ type: "text", text: "ok" }] }, - callToolCalls: 0, closed: false, notificationHandlers: new Map(), } @@ -219,11 +203,6 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { resources: this._state?.resources ?? [] } } - async callTool() { - if (this._state) this._state.callToolCalls++ - return this._state?.callToolResult - } - async close() { if (this._state) this._state.closed = true } @@ -316,8 +295,7 @@ it.instance( expect(Object.keys(yield* mcp.tools())).toEqual(["paged-server_tool-one", "paged-server_tool-two"]) expect(Object.keys(yield* mcp.prompts())).toEqual(["paged-server:prompt-one", "paged-server:prompt-two"]) expect(Object.keys(yield* mcp.resources())).toEqual(["paged-server:resource-one", "paged-server:resource-two"]) - expect(serverState.listToolsCalls).toBe(1) - expect(serverState.requestCalls).toBe(1) + expect(serverState.listToolsCalls).toBe(2) expect(serverState.listPromptsCalls).toBe(2) expect(serverState.listResourcesCalls).toBe(2) }), @@ -342,8 +320,7 @@ it.instance( command: ["echo", "test"], }) - expect(serverState.listToolsCalls).toBe(1) - expect(serverState.requestCalls).toBe(1) + expect(serverState.listToolsCalls).toBe(2) expect(yield* mcp.tools()).toEqual({}) }), ), @@ -377,51 +354,6 @@ it.instance( { config: { mcp: {} } }, ) -it.instance( - "enforces metadata from later tool pages", - () => - MCP.Service.use((mcp: MCPNS.Interface) => - Effect.gen(function* () { - lastCreatedClientName = "metadata-server" - const serverState = getOrCreateClientState("metadata-server") - serverState.toolPages = { - initial: { tools: [], nextCursor: "next" }, - next: { - tools: [ - { - name: "structured", - inputSchema: { type: "object", properties: {} }, - outputSchema: { type: "object", required: ["value"], properties: { value: { type: "string" } } }, - }, - { - name: "task", - inputSchema: { type: "object", properties: {} }, - execution: { taskSupport: "required" }, - }, - ], - }, - } - - yield* mcp.add("metadata-server", { - type: "local", - command: ["echo", "test"], - }) - - const tools = yield* mcp.tools() - const structured = yield* Effect.tryPromise(() => - tools["metadata-server_structured"].execute!({}, {} as never), - ).pipe(Effect.exit) - const task = yield* Effect.tryPromise(() => tools["metadata-server_task"].execute!({}, {} as never)).pipe( - Effect.exit, - ) - expect(Exit.isFailure(structured)).toBe(true) - expect(Exit.isFailure(task)).toBe(true) - expect(serverState.callToolCalls).toBe(1) - }), - ), - { config: { mcp: {} } }, -) - // ======================================================================== // Test: tool change notifications refresh the cache // ========================================================================