fix(core): clarify MCP timeout budgets (#35626)

This commit is contained in:
Aiden Cline 2026-07-06 17:47:20 -05:00 committed by GitHub
commit bfdbf43ef8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 120 additions and 25 deletions

View file

@ -7,8 +7,11 @@ export class Timeout extends Schema.Class<Timeout>("ConfigV2.MCP.Timeout")({
startup: PositiveInt.pipe(Schema.optional).annotate({ startup: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to establish and initialize the MCP server.", description: "Maximum time in milliseconds to establish and initialize the MCP server.",
}), }),
request: PositiveInt.pipe(Schema.optional).annotate({ catalog: PositiveInt.pipe(Schema.optional).annotate({
description: "Maximum time in milliseconds to wait for MCP catalog/list requests after initialization.", 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.",
}), }),
}) {} }) {}

View file

@ -31,7 +31,8 @@ import { ConfigMCP } from "../config/mcp"
import { InstallationVersion } from "../installation/version" import { InstallationVersion } from "../installation/version"
const DEFAULT_STARTUP_TIMEOUT = 30_000 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 type Transport = StdioClientTransport | StreamableHTTPClientTransport
@ -206,7 +207,8 @@ export const connect = Effect.fnUntraced(function* (
Effect.ignore, 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 { return {
instructions: client.getInstructions()?.trim() || undefined, instructions: client.getInstructions()?.trim() || undefined,
tools: () => tools: () =>
@ -218,11 +220,11 @@ export const connect = Effect.fnUntraced(function* (
async (cursor) => { async (cursor) => {
const params = cursor === undefined ? undefined : { cursor } const params = cursor === undefined ? undefined : { cursor }
try { try {
return await client.listTools(params, { timeout: requestTimeout }) return await client.listTools(params, { timeout: catalogTimeout })
} catch (error) { } catch (error) {
if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error if (!(error instanceof Error) || !isOutputSchemaError(error)) throw error
return client.request({ method: "tools/list", params }, TolerantListToolsResult, { return client.request({ method: "tools/list", params }, TolerantListToolsResult, {
timeout: requestTimeout, timeout: catalogTimeout,
}) })
} }
}, },
@ -248,7 +250,7 @@ export const connect = Effect.fnUntraced(function* (
async (cursor) => { async (cursor) => {
const params = cursor === undefined ? undefined : { cursor } const params = cursor === undefined ? undefined : { cursor }
return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, { return client.request({ method: "prompts/list", params }, TolerantListPromptsResult, {
timeout: requestTimeout, timeout: catalogTimeout,
}) })
}, },
(result) => result.prompts, (result) => result.prompts,
@ -273,7 +275,7 @@ export const connect = Effect.fnUntraced(function* (
client.request( client.request(
{ method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } }, { method: "prompts/get", params: { name: input.name, arguments: input.args ?? {} } },
GetPromptResultSchema, GetPromptResultSchema,
{ signal }, { signal, timeout: executionTimeout },
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(
@ -287,8 +289,8 @@ export const connect = Effect.fnUntraced(function* (
client.callTool( client.callTool(
{ name: input.name, arguments: input.args ?? {} }, { name: input.name, arguments: input.args ?? {} },
CallToolResultSchema, CallToolResultSchema,
// Keep progress tokens available without imposing a client timeout on tool execution. // Keep progress tokens available while enforcing a hard wall-clock execution timeout.
{ signal, resetTimeoutOnProgress: true, onprogress: () => {} }, { signal, timeout: executionTimeout, onprogress: () => {} },
), ),
catch: (error) => (error instanceof Error ? error : new Error(String(error))), catch: (error) => (error instanceof Error ? error : new Error(String(error))),
}).pipe( }).pipe(

View file

@ -175,7 +175,7 @@ function mcp(info: typeof ConfigV1.Info.Type) {
) )
const timeout = info.experimental?.mcp_timeout const timeout = info.experimental?.mcp_timeout
if (!timeout && !Object.keys(servers).length) return undefined 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) { function migrateMcp(info: ConfigMCPV1.Info) {
@ -187,7 +187,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
cwd: info.cwd, cwd: info.cwd,
environment: info.environment, environment: info.environment,
disabled, disabled,
timeout: info.timeout === undefined ? undefined : { request: info.timeout }, timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout },
} }
return { return {
type: info.type, type: info.type,
@ -201,7 +201,7 @@ function migrateMcp(info: ConfigMCPV1.Info) {
redirect_uri: info.oauth.redirectUri, redirect_uri: info.oauth.redirectUri,
}, },
disabled, disabled,
timeout: info.timeout === undefined ? undefined : { request: info.timeout }, timeout: info.timeout === undefined ? undefined : { catalog: info.timeout, execution: info.timeout },
} }
} }

View file

@ -142,7 +142,7 @@ describe("Config", () => {
// V2 nests under `mcp.servers`, so it must not be misdetected and re-migrated. // 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: { servers: { context7: { type: "local", command: ["npx"] } } } })).toBe(false)
expect(ConfigMigrateV1.isV1({ mcp: {} })).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 }, tool_output: { max_lines: 1000, max_bytes: 32768 },
mcp: { mcp: {
timeout: { startup: 5000, request: 60000 }, timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
servers: { servers: {
local: { local: {
type: "local", type: "local",
command: ["node", "./mcp/server.js"], command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" }, environment: { API_KEY: "secret" },
disabled: false, disabled: false,
timeout: { request: 10000 }, timeout: { catalog: 10000 },
}, },
remote: { remote: {
type: "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.tool_output).toEqual({ max_lines: 1000, max_bytes: 32768 })
expect(documents[0]?.info.mcp).toEqual({ expect(documents[0]?.info.mcp).toEqual({
timeout: { startup: 5000, request: 60000 }, timeout: { startup: 5000, catalog: 60000, execution: 43200000 },
servers: { servers: {
local: { local: {
type: "local", type: "local",
command: ["node", "./mcp/server.js"], command: ["node", "./mcp/server.js"],
environment: { API_KEY: "secret" }, environment: { API_KEY: "secret" },
disabled: false, disabled: false,
timeout: { request: 10000 }, timeout: { catalog: 10000 },
}, },
remote: { remote: {
type: "remote", type: "remote",
@ -792,19 +792,19 @@ describe("Config", () => {
buffer: 10000, buffer: 10000,
}) })
expect(documents[0]?.info.mcp).toMatchObject({ expect(documents[0]?.info.mcp).toMatchObject({
timeout: { request: 5000 }, timeout: { catalog: 5000, execution: 5000 },
servers: { servers: {
local: { local: {
type: "local", type: "local",
command: ["node", "server.js"], command: ["node", "server.js"],
disabled: true, disabled: true,
timeout: { request: 10000 }, timeout: { catalog: 10000, execution: 10000 },
}, },
remote: { remote: {
type: "remote", type: "remote",
url: "https://mcp.example.com", url: "https://mcp.example.com",
oauth: { client_id: "client", callback_port: 19876 }, oauth: { client_id: "client", callback_port: 19876 },
timeout: { request: 20000 }, timeout: { catalog: 20000, execution: 20000 },
}, },
}, },
}) })

View file

@ -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())

View file

@ -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", () => it.effect("advertises MCP output schemas to Code Mode", () =>
Effect.gen(function* () { Effect.gen(function* () {
const registry = yield* ToolRegistry.Service const registry = yield* ToolRegistry.Service

View file

@ -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. 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 ```jsonc
{ {
"mcp": { "mcp": {
"timeout": { "startup": 30000, "request": 300000 }, "timeout": { "startup": 30000, "catalog": 30000, "execution": 43200000 },
"servers": { "servers": {
"github": { "github": {
"type": "local", "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", "redirect_uri": "http://127.0.0.1:19876/mcp/oauth/callback",
}, },
"disabled": false, "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.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.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.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.<name>.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 ## Review Order