diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 5ce4719ee5b2..d40594bace17 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -740,7 +740,7 @@ export const layer = Layer.effect( const withClient = Effect.fnUntraced(function* ( clientName: string, - fn: (client: MCPClient) => Promise, + fn: (client: MCPClient, timeout?: number) => Promise, label: string, meta?: Record, ) { @@ -750,8 +750,11 @@ export const layer = Layer.effect( yield* Effect.logWarning(`client not found for ${label}`, { clientName }) return undefined } + const cfg = yield* cfgSvc.get() + const configured = cfg.mcp?.[clientName] + const staticTimeout = configured && isMcpConfigured(configured) ? configured.timeout : undefined return yield* Effect.tryPromise({ - try: () => fn(client), + try: () => fn(client, s.config[clientName]?.timeout ?? staticTimeout ?? cfg.experimental?.mcp_timeout), catch: (error) => error, }).pipe( Effect.tapError((error) => @@ -770,15 +773,21 @@ export const layer = Layer.effect( name: string, args?: Record, ) { - return yield* withClient(clientName, (client) => client.getPrompt({ name, arguments: args }), "getPrompt", { - promptName: name, - }) + return yield* withClient( + clientName, + (client, timeout) => client.getPrompt({ name, arguments: args }, { timeout }), + "getPrompt", + { promptName: name }, + ) }) const readResource = Effect.fn("MCP.readResource")(function* (clientName: string, resourceUri: string) { - return yield* withClient(clientName, (client) => client.readResource({ uri: resourceUri }), "readResource", { - resourceUri, - }) + return yield* withClient( + clientName, + (client, timeout) => client.readResource({ uri: resourceUri }, { timeout }), + "readResource", + { resourceUri }, + ) }) const getMcpConfig = Effect.fnUntraced(function* (mcpName: string) { diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index da525d166e9b..004909fb6e38 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -13,6 +13,8 @@ interface MockClientState { listToolsCalls: number listPromptsCalls: number listResourcesCalls: number + getPromptTimeout?: number + readResourceTimeout?: number requestCalls: number listToolsShouldFail: boolean listToolsError: string @@ -206,6 +208,16 @@ void mock.module("@modelcontextprotocol/sdk/client/index.js", () => ({ return { resources: this._state?.resources ?? [] } } + async getPrompt(_params: unknown, options?: { timeout?: number }) { + if (this._state) this._state.getPromptTimeout = options?.timeout + return { messages: [] } + } + + async readResource(params: { uri: string }, options?: { timeout?: number }) { + if (this._state) this._state.readResourceTimeout = options?.timeout + return { contents: [{ uri: params.uri, text: "test" }] } + } + async close() { if (this._state) this._state.closed = true } @@ -758,6 +770,29 @@ it.instance( }, ) +it.instance( + "uses per-server timeouts for prompt and resource requests", + () => + MCP.Service.use((mcp: MCPNS.Interface) => + Effect.gen(function* () { + lastCreatedClientName = "timeout-server" + const serverState = getOrCreateClientState("timeout-server") + + yield* mcp.add("timeout-server", { + type: "local", + command: ["echo", "test"], + timeout: 2500, + }) + yield* mcp.getPrompt("timeout-server", "test") + yield* mcp.readResource("timeout-server", "test://resource") + + expect(serverState.getPromptTimeout).toBe(2500) + expect(serverState.readResourceTimeout).toBe(2500) + }), + ), + { config: { mcp: {}, experimental: { mcp_timeout: 5000 } } }, +) + it.instance( "resource-only servers connect without listing tools", () =>