Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
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
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 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) =>
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
35 changes: 35 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,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",
() =>
Expand Down
Loading