Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Next Next commit
fix(mcp): apply timeouts to prompts and resources
  • Loading branch information
rekram1-node committed Jun 10, 2026
commit b5ad6434b43c35d3e8afcb9b697bd79b0422e896
25 changes: 17 additions & 8 deletions packages/opencode/src/mcp/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -740,7 +740,7 @@ export const layer = Layer.effect(

const withClient = Effect.fnUntraced(function* <A>(
clientName: string,
fn: (client: MCPClient) => Promise<A>,
fn: (client: MCPClient, timeout?: number) => Promise<A>,
label: string,
meta?: Record<string, unknown>,
) {
Expand All @@ -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 entry = configured && isMcpConfigured(configured) ? configured : s.config[clientName]
return yield* Effect.tryPromise({
try: () => fn(client),
try: () => fn(client, entry?.timeout ?? cfg.experimental?.mcp_timeout),
catch: (error) => error,
}).pipe(
Effect.tapError((error) =>
Expand All @@ -770,15 +773,21 @@ export const layer = Layer.effect(
name: string,
args?: Record<string, string>,
) {
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) {
Expand Down
57 changes: 57 additions & 0 deletions packages/opencode/test/mcp/lifecycle.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ interface MockClientState {
listToolsCalls: number
listPromptsCalls: number
listResourcesCalls: number
getPromptTimeout?: number
readResourceTimeout?: number
requestCalls: number
listToolsShouldFail: boolean
listToolsError: string
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -758,6 +770,51 @@ 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(
"uses the experimental MCP timeout as the request fallback",
() =>
MCP.Service.use((mcp: MCPNS.Interface) =>
Effect.gen(function* () {
lastCreatedClientName = "fallback-server"
const serverState = getOrCreateClientState("fallback-server")

yield* mcp.add("fallback-server", {
type: "local",
command: ["echo", "test"],
})
yield* mcp.getPrompt("fallback-server", "test")
yield* mcp.readResource("fallback-server", "test://resource")

expect(serverState.getPromptTimeout).toBe(5000)
expect(serverState.readResourceTimeout).toBe(5000)
}),
),
{ config: { mcp: {}, experimental: { mcp_timeout: 5000 } } },
)

it.instance(
"resource-only servers connect without listing tools",
() =>
Expand Down
Loading