diff --git a/packages/core/src/config/mcp.ts b/packages/core/src/config/mcp.ts index 53e23d6013..9e86fbbe82 100644 --- a/packages/core/src/config/mcp.ts +++ b/packages/core/src/config/mcp.ts @@ -7,8 +7,11 @@ export class Timeout extends Schema.Class("ConfigV2.MCP.Timeout")({ startup: PositiveInt.pipe(Schema.optional).annotate({ description: "Maximum time in milliseconds to establish and initialize the MCP server.", }), - request: PositiveInt.pipe(Schema.optional).annotate({ - description: "Maximum time in milliseconds to wait for MCP catalog/list requests after initialization.", + catalog: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for MCP discovery requests such as tools/list and prompts/list.", + }), + execution: PositiveInt.pipe(Schema.optional).annotate({ + description: "Maximum time in milliseconds to wait for MCP tool and prompt execution.", }), }) {} diff --git a/packages/core/src/mcp/client.ts b/packages/core/src/mcp/client.ts index 02362f6428..0b10ecd7e4 100644 --- a/packages/core/src/mcp/client.ts +++ b/packages/core/src/mcp/client.ts @@ -31,7 +31,8 @@ import { ConfigMCP } from "../config/mcp" import { InstallationVersion } from "../installation/version" const DEFAULT_STARTUP_TIMEOUT = 30_000 -const DEFAULT_REQUEST_TIMEOUT = 30_000 +const DEFAULT_CATALOG_TIMEOUT = 30_000 +const DEFAULT_EXECUTION_TIMEOUT = 12 * 60 * 60 * 1_000 // 12 hours type Transport = StdioClientTransport | StreamableHTTPClientTransport @@ -206,7 +207,8 @@ export const connect = Effect.fnUntraced(function* ( Effect.ignore, ), ) - const requestTimeout = config.timeout?.request ?? DEFAULT_REQUEST_TIMEOUT + const catalogTimeout = config.timeout?.catalog ?? DEFAULT_CATALOG_TIMEOUT + const executionTimeout = config.timeout?.execution ?? DEFAULT_EXECUTION_TIMEOUT return { instructions: client.getInstructions()?.trim() || undefined, tools: () => @@ -218,11 +220,11 @@ export const connect = Effect.fnUntraced(function* ( async (cursor) => { const params = cursor === undefined ? undefined : { cursor } try { - return await client.listTools(params, { timeout: requestTimeout }) + return await client.listTools(params, { timeout: catalogTimeout }) } catch (error) { if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error return client.request({ method: "tools/list", params }, TolerantListToolsResult, { - timeout: requestTimeout, + timeout: catalogTimeout, }) } }, @@ -248,7 +250,7 @@ export const connect = Effect.fnUntraced(function* ( async (cursor) => { const params = cursor === undefined ? undefined : { cursor } return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, { - timeout: requestTimeout, + timeout: catalogTimeout, }) }, (result) => result.prompts, @@ -273,7 +275,7 @@ export const connect = Effect.fnUntraced(function* ( client.request( { method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } }, GetPromptResultSchema, - { signal }, + { signal, timeout: executionTimeout }, ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( @@ -287,8 +289,8 @@ export const connect = Effect.fnUntraced(function* ( client.callTool( { name: input.name, arguments: input.args ?? {} }, CallToolResultSchema, - // Keep progress tokens available without imposing a client timeout on tool execution. - { signal, resetTimeoutOnProgress: true, onprogress: () => {} }, + // Keep progress tokens available while enforcing a hard wall-clock execution timeout. + { signal, timeout: executionTimeout, onprogress: () => {} }, ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }).pipe( diff --git a/packages/core/src/v1/config/migrate.ts b/packages/core/src/v1/config/migrate.ts index 1e7e3464dc..15b464f776 100644 --- a/packages/core/src/v1/config/migrate.ts +++ b/packages/core/src/v1/config/migrate.ts @@ -175,7 +175,7 @@ function mcp(info: typeof ConfigV1.Info.Type) { ) const timeout = info.experimental?.mcp_timeout if (!timeout && !Object.keys(servers).length) return undefined - return { timeout: timeout === undefined ? undefined : { request: timeout }, servers } + return { timeout: timeout === undefined ? undefined : { catalog: timeout, execution: timeout }, servers } } function migrateMcp(info: ConfigMCPV1.Info) { @@ -187,7 +187,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { cwd: info.cwd, environment: info.environment, disabled, - timeout: info.timeout === undefined ? undefined : { request: info.timeout }, + timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout }, } return { type: info.type, @@ -201,7 +201,7 @@ function migrateMcp(info: ConfigMCPV1.Info) { redirect_uri: info.oauth.redirectUri, }, disabled, - timeout: info.timeout === undefined ? undefined : { request: info.timeout }, + timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout }, } } diff --git a/packages/core/test/config/config.test.ts b/packages/core/test/config/config.test.ts index ae7322292c..85db0a6bf3 100644 --- a/packages/core/test/config/config.test.ts +++ b/packages/core/test/config/config.test.ts @@ -142,7 +142,7 @@ describe("Config", () => { // V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated. expect(ConfigMigrateV1.isV1({ mcp: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false) expect(ConfigMigrateV1.isV1({ mcp: {} })).toBe(false) - expect(ConfigMigrateV1.isV1({ mcp: { timeout: { request: 1000 } } })).toBe(false) + expect(ConfigMigrateV1.isV1({ mcp: { timeout: { execution: 1000 } } })).toBe(false) }), ) @@ -467,14 +467,14 @@ describe("Config", () => { }, tool_output: { max_lines: 1000, max_bytes: 32768 }, mcp: { - timeout: { startup: 5000, request: 60000 }, + timeout: { startup: 5000, catalog: 60000, execution: 43200000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: { request: 10000 }, + timeout: { catalog: 10000 }, }, remote: { type: "remote", @@ -552,14 +552,14 @@ describe("Config", () => { }) expect(documents[0]?.info.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 }) expect(documents[0]?.info.mcp).toEqual({ - timeout: { startup: 5000, request: 60000 }, + timeout: { startup: 5000, catalog: 60000, execution: 43200000 }, servers: { local: { type: "local", command: ["node", "./mcp/server.js"], environment: { API_KEY: "secret" }, disabled: false, - timeout: { request: 10000 }, + timeout: { catalog: 10000 }, }, remote: { type: "remote", @@ -792,19 +792,19 @@ describe("Config", () => { buffer: 10000, }) expect(documents[0]?.info.mcp).toMatchObject({ - timeout: { request: 5000 }, + timeout: { catalog: 5000, execution: 5000 }, servers: { local: { type: "local", command: ["node", "server.js"], disabled: true, - timeout: { request: 10000 }, + timeout: { catalog: 10000, execution: 10000 }, }, remote: { type: "remote", url: "https://mcp.example.com", oauth: { client_id: "client", callback_port: 19876 }, - timeout: { request: 20000 }, + timeout: { catalog: 20000, execution: 20000 }, }, }, }) diff --git a/packages/core/test/fixture/mcp-timeout.ts b/packages/core/test/fixture/mcp-timeout.ts new file mode 100644 index 0000000000..15c851236c --- /dev/null +++ b/packages/core/test/fixture/mcp-timeout.ts @@ -0,0 +1,26 @@ +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { + CallToolRequestSchema, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListToolsRequestSchema, +} from "@modelcontextprotocol/sdk/types.js" + +const server = new Server({ name: "timeout", version: "1.0.0" }, { capabilities: { prompts: {}, tools: {} } }) + +server.setRequestHandler(ListToolsRequestSchema, async () => { + if (process.env.MCP_TIMEOUT_TARGET === "catalog") await Bun.sleep(100) + return { tools: [{ name: "slow", inputSchema: { type: "object" } }] } +}) +server.setRequestHandler(ListPromptsRequestSchema, () => Promise.resolve({ prompts: [{ name: "slow" }] })) +server.setRequestHandler(CallToolRequestSchema, async () => { + await Bun.sleep(100) + return { content: [] } +}) +server.setRequestHandler(GetPromptRequestSchema, async () => { + await Bun.sleep(100) + return { messages: [] } +}) + +await server.connect(new StdioServerTransport()) diff --git a/packages/core/test/mcp.test.ts b/packages/core/test/mcp.test.ts index e43ac3062b..26977a8729 100644 --- a/packages/core/test/mcp.test.ts +++ b/packages/core/test/mcp.test.ts @@ -177,6 +177,70 @@ test("retains output schemas across paginated MCP discovery", async () => { ]) }) +test("applies the configured MCP catalog timeout", async () => { + const result = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "catalog-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + environment: { MCP_TIMEOUT_TARGET: "catalog" }, + timeout: new ConfigMCP.Timeout({ catalog: 10 }), + }), + import.meta.dir, + ) + return yield* connection.tools() + }), + ), + ) + + await expect(result).rejects.toThrow("Request timed out") +}) + +test("applies the configured MCP execution timeout", async () => { + const result = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "execution-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + timeout: new ConfigMCP.Timeout({ execution: 10 }), + }), + import.meta.dir, + ) + return yield* connection.callTool({ name: "slow" }) + }), + ), + ) + + await expect(result).rejects.toThrow("Request timed out") +}) + +test("applies the configured MCP execution timeout to prompts", async () => { + const result = Effect.runPromise( + Effect.scoped( + Effect.gen(function* () { + const connection = yield* MCPClient.connect( + "prompt-timeout", + new ConfigMCP.Local({ + type: "local", + command: [process.execPath, path.join(import.meta.dir, "fixture/mcp-timeout.ts")], + timeout: new ConfigMCP.Timeout({ execution: 10 }), + }), + import.meta.dir, + ) + return yield* connection.prompt({ name: "slow" }) + }), + ), + ) + + await expect(result).rejects.toThrow("Request timed out") +}) + it.effect("advertises MCP output schemas to Code Mode", () => Effect.gen(function* () { const registry = yield* ToolRegistry.Service diff --git a/specs/v2/config.md b/specs/v2/config.md index 96b0f3ac90..9a7afee3a0 100644 --- a/specs/v2/config.md +++ b/specs/v2/config.md @@ -312,12 +312,12 @@ External protocol and server integration configuration. Keep the opencode MCP server entry format instead of adopting the common `mcpServers` copy/paste shape. Local servers remain explicit `type: "local"` entries with command arrays and `environment`; remote servers remain explicit `type: "remote"` entries with `url`, `headers`, and optional `oauth`. Nest the server map under `mcp.servers` so protocol-wide settings such as timeout defaults can live under the same subsystem. -MCP timeouts have separate startup and request budgets, expressed in milliseconds. `startup` covers establishing the transport and completing MCP initialization. `request` applies independently to each post-initialization MCP request. A server may override either default without repeating the other. +MCP timeouts have separate startup, catalog, and execution budgets, expressed in milliseconds. `startup` covers establishing the transport and completing MCP initialization. `catalog` applies independently to discovery requests such as listing tools and prompts. `execution` covers potentially interactive operations such as tool calls and prompt evaluation. A server may override any default without repeating the others. ```jsonc { "mcp": { - "timeout": { "startup": 30000, "request": 300000 }, + "timeout": { "startup": 30000, "catalog": 30000, "execution": 43200000 }, "servers": { "github": { "type": "local", @@ -338,7 +338,7 @@ MCP timeouts have separate startup and request budgets, expressed in millisecond "redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback", }, "disabled": false, - "timeout": { "request": 600000 }, + "timeout": { "execution": 600000 }, }, }, }, @@ -380,7 +380,7 @@ Fields that should not be ported by inertia; each needs an explicit justificatio | `experimental.openTelemetry` | Enable AI SDK telemetry spans | remove | Do not port; observability is process-level and should use standard OpenTelemetry environment or declarative configuration. | | `experimental.primary_tools` | Restrict tools to primary agents | remove | Do not port obsolete gating; agent tool access is configured through permissions. | | `experimental.continue_loop_on_deny` | Continue loop after denied tool call | remove | Do not port legacy denied-tool loop behavior. | -| `experimental.mcp_timeout` | MCP request timeout | redesign | Move to `mcp.timeout.request` for the default and `mcp.servers..timeout.request` for per-server overrides. | +| `experimental.mcp_timeout` | MCP request timeout | redesign | Migrate to both `mcp.timeout.catalog` and `mcp.timeout.execution`, with corresponding per-server overrides. | ## Review Order