diff --git a/.changeset/mcp-global-timeouts.md b/.changeset/mcp-global-timeouts.md new file mode 100644 index 0000000000..16a9f1b152 --- /dev/null +++ b/.changeset/mcp-global-timeouts.md @@ -0,0 +1,5 @@ +--- +"@moonshot-ai/kimi-code": minor +--- + +Add global default MCP server timeouts in `config.toml` and env vars. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index b29cf7f2e4..f37a841f41 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -244,6 +244,15 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent `timeout_ms` can be overridden by the `KIMI_SUBAGENT_TIMEOUT_MS` environment variable, which takes higher priority than `config.toml`. +## `mcp` + +| Field | Type | Default | Description | +| --- | --- | --- | --- | +| `startup_timeout_ms` | `integer` | `30000` (30 seconds) | Global default connection (startup + tool discovery) timeout in milliseconds for all MCP servers. Accepts `1`–`2147483647`. A per-server `startupTimeoutMs` in `mcp.json` always wins over this section and the environment variable; when neither is set, the default applies | +| `tool_timeout_ms` | `integer` | `60000` (60 seconds) | Global default single tool-call timeout in milliseconds for all MCP servers. Accepts `1`–`2147483647`. A per-server `toolTimeoutMs` in `mcp.json` always wins over this section and the environment variable; when neither is set, the client built-in default applies | + +`startup_timeout_ms` and `tool_timeout_ms` can be overridden by the `KIMI_MCP_STARTUP_TIMEOUT_MS` and `KIMI_MCP_TOOL_TIMEOUT_MS` environment variables respectively, which take higher priority than `config.toml`. See [MCP](../customization/mcp.md) for the full MCP server configuration. + ## `tools` `tools` is the global tool switch: it applies to every agent in all sessions and intersects with each agent's own `tools` / `disallowedTools` policy. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 7c2dcd3c17..7669fd8544 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,6 +128,8 @@ Switches that control the behavior of subsystems such as telemetry, background t | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | Override the plugin marketplace JSON loaded by `/plugins`; useful for dev loopback servers, staging CDN files, or alternate marketplace directories | `https://code.kimi.com/kimi-code/plugins/marketplace.json`; also accepts `http://`, `file://` URLs, and local paths | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | Cap how many AgentSwarm subagents run concurrently during the initial ramp; leave unset for no cap | Positive integer; invalid values fail fast | | `KIMI_SUBAGENT_TIMEOUT_MS` | Maximum wall-clock time (ms) a single subagent (`Agent` / `AgentSwarm`) may run; takes higher priority than `[subagent] timeout_ms` in `config.toml` (default `7200000`, i.e. 2 hours) | Positive integer; invalid values fall back to the config or default | +| `KIMI_MCP_STARTUP_TIMEOUT_MS` | Global default connection timeout (ms) for all MCP servers; takes higher priority than `[mcp] startup_timeout_ms` in `config.toml`, but a per-server `startupTimeoutMs` in `mcp.json` still wins (default `30000`) | Integer from `1` to `2147483647`; invalid values are ignored | +| `KIMI_MCP_TOOL_TIMEOUT_MS` | Global default single tool-call timeout (ms) for all MCP servers; takes higher priority than `[mcp] tool_timeout_ms` in `config.toml`, but a per-server `toolTimeoutMs` in `mcp.json` still wins (default `60000`) | Integer from `1` to `2147483647`; invalid values are ignored | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Maximum Agent steps per turn; takes higher priority than `[loop_control] max_steps_per_turn` in `config.toml` (unset or `0` means unlimited) | Non-negative integer; invalid values are ignored | | `KIMI_LOOP_MAX_RETRIES_PER_STEP` | Maximum retries after a step failure; takes higher priority than `[loop_control] max_retries_per_step` in `config.toml` (default `10`) | Non-negative integer; invalid values are ignored | | `KIMI_CODE_EXPERIMENTAL_FLAG` | Enable all registered experimental features for this process | `1`, `true`, `yes`, `on` | diff --git a/docs/en/customization/mcp.md b/docs/en/customization/mcp.md index dfad6acd60..2768220074 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -52,11 +52,13 @@ Optional fields: | `headers` | `Record` | HTTP, SSE | Static request headers appended to every request | | `bearerTokenEnvVar` | `string` | HTTP, SSE | Name of an environment variable that contains a bearer token | | `enabled` | `boolean` | All | Set to `false` to disable this server | -| `startupTimeoutMs` | `number` | All | Connection timeout; default `30000` milliseconds | -| `toolTimeoutMs` | `number` | All | Timeout for a single tool call | +| `startupTimeoutMs` | `number` | All | Connection timeout from `1` to `2147483647` milliseconds; default `30000` | +| `toolTimeoutMs` | `number` | All | Timeout from `1` to `2147483647` milliseconds for a single tool call | | `enabledTools` | `string[]` | All | Tool allowlist | | `disabledTools` | `string[]` | All | Tool blocklist | +You do not have to set the connection timeout or the single tool-call timeout per server: `[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms` in `config.toml` or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` environment variables change the global defaults. Precedence is: per-server field > environment variable > `config.toml` > built-in default. See [Configuration files](../configuration/config-files.md#mcp). + HTTP and SSE servers support providing static credentials via `headers` or `bearerTokenEnvVar`. When OAuth is needed, run `/mcp-config login ` to complete browser-based authorization. Plugins can also declare MCP servers in their manifest. Servers declared by a plugin are enabled by default and can be disabled or re-enabled in `/plugins`, then a new session must be started. See [Plugins](./plugins.md) for details. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index fbc68620d0..d18288b9c8 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -244,6 +244,15 @@ display_name = "Kimi for Coding (custom)" `timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,优先级高于配置文件。 +## `mcp` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `startup_timeout_ms` | `integer` | `30000`(30 秒) | 所有 MCP server 的全局默认连接(启动 + 工具发现)超时(毫秒),取值范围为 `1`–`2147483647`。`mcp.json` 中单个 server 的 `startupTimeoutMs` 始终优先于本节与环境变量;都未设置时使用默认值 | +| `tool_timeout_ms` | `integer` | `60000`(60 秒) | 所有 MCP server 的全局默认单次工具调用超时(毫秒),取值范围为 `1`–`2147483647`。`mcp.json` 中单个 server 的 `toolTimeoutMs` 始终优先于本节与环境变量;都未设置时使用客户端内置默认值 | + +`startup_timeout_ms` 和 `tool_timeout_ms` 可分别被环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` 和 `KIMI_MCP_TOOL_TIMEOUT_MS` 覆盖,优先级高于配置文件。MCP server 的完整配置方式见 [MCP](../customization/mcp.md)。 + ## `tools` `tools` 设置全局工具开关,对所有会话中的每个 Agent 生效,并在 Agent 自身的 `tools` / `disallowedTools` 策略之上再取一次交集。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 7e67c5c4f3..cb3001df7a 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,6 +128,8 @@ kimi | `KIMI_CODE_PLUGIN_MARKETPLACE_URL` | 覆盖 `/plugins` 加载的 plugin marketplace JSON,适合 dev loopback server、测试 CDN 文件或替换 marketplace 目录 | `https://code.kimi.com/kimi-code/plugins/marketplace.json`;也接受 `http://`、`file://` URL 和本地路径 | | `KIMI_CODE_AGENT_SWARM_MAX_CONCURRENCY` | 限制 AgentSwarm 初始提升并发阶段可同时运行的子 Agent 数量;不设置表示不限制 | 正整数;非法值会立即失败 | | `KIMI_SUBAGENT_TIMEOUT_MS` | 单个子 Agent(`Agent` / `AgentSwarm`)可运行的最长时间(毫秒);优先级高于 `config.toml` 的 `[subagent] timeout_ms`(默认 `7200000`,即 2 小时) | 正整数;非法值回退到配置或默认值 | +| `KIMI_MCP_STARTUP_TIMEOUT_MS` | 所有 MCP server 的全局默认连接超时(毫秒);优先级高于 `config.toml` 的 `[mcp] startup_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `startupTimeoutMs`(默认 `30000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | +| `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | `1` 到 `2147483647` 的整数;非法值被忽略 | | `KIMI_LOOP_MAX_STEPS_PER_TURN` | Agent 单轮最大步数;优先级高于 `config.toml` 的 `[loop_control] max_steps_per_turn`(不设或 `0` 表示无上限) | 非负整数;非法值被忽略 | | `KIMI_LOOP_MAX_RETRIES_PER_STEP` | 单步失败后的最大重试次数;优先级高于 `config.toml` 的 `[loop_control] max_retries_per_step`(默认 `10`) | 非负整数;非法值被忽略 | | `KIMI_CODE_EXPERIMENTAL_FLAG` | 在当前进程启用所有已注册的实验功能 | `1`、`true`、`yes`、`on` | diff --git a/docs/zh/customization/mcp.md b/docs/zh/customization/mcp.md index 96a4e66dd9..93cb8031c9 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -52,11 +52,13 @@ MCP server 配置写在 `mcp.json` 中,分两层: | `headers` | `Record` | HTTP、SSE | 附加到每次请求的静态请求头 | | `bearerTokenEnvVar` | `string` | HTTP、SSE | 存放 bearer token 的环境变量名 | | `enabled` | `boolean` | 全部 | 设为 `false` 可禁用该 server | -| `startupTimeoutMs` | `number` | 全部 | 连接超时,默认 `30000` 毫秒 | -| `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时 | +| `startupTimeoutMs` | `number` | 全部 | 连接超时,取值范围为 `1` 到 `2147483647` 毫秒,默认 `30000` | +| `toolTimeoutMs` | `number` | 全部 | 单次工具调用超时,取值范围为 `1` 到 `2147483647` 毫秒 | | `enabledTools` | `string[]` | 全部 | 工具白名单 | | `disabledTools` | `string[]` | 全部 | 工具黑名单 | +连接超时和单次工具调用超时的默认值都不必逐个 server 设置:`config.toml` 的 `[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms` 或环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` 可以调整全局默认值,优先级为 server 字段 > 环境变量 > `config.toml` > 内置默认。详见 [配置文件](../configuration/config-files.md#mcp)。 + HTTP 与 SSE server 支持通过 `headers` 或 `bearerTokenEnvVar` 提供静态凭证。需要 OAuth 时,运行 `/mcp-config login ` 完成浏览器授权。 Plugins 也可以在 manifest 中声明 MCP servers。Plugin 声明的 servers 默认启用,可以在 `/plugins` 中禁用或重新启用,然后开启新会话。详见 [Plugins](./plugins.md)。 diff --git a/packages/agent-core-v2/docs/config-manifest.toml b/packages/agent-core-v2/docs/config-manifest.toml index e0b9ddc1fe..6b23000c48 100644 --- a/packages/agent-core-v2/docs/config-manifest.toml +++ b/packages/agent-core-v2/docs/config-manifest.toml @@ -8,7 +8,7 @@ # commented "# field: type" lines describe the remaining schema fields. # Values resolve as: default -> config.toml -> env overlay -> memory. -# Index (20 sections · 1 overlay(s)) +# Index (21 sections · 1 overlay(s)) # background src/agent/task/configSection.ts # cron src/app/cron/configSection.ts # defaultPermissionMode src/agent/permissionMode/configSection.ts @@ -19,6 +19,7 @@ # hooks src/agent/externalHooks/configSection.ts # image src/agent/media/configSection.ts # loopControl src/agent/loop/configSection.ts +# mcp src/agent/mcp/configSection.ts # mergeAllAvailableSkills src/app/skillCatalog/configSection.ts # modelCatalog src/app/kosongConfig/configSection.ts # models src/app/kosongConfig/configSection.ts @@ -161,6 +162,20 @@ extra_skill_dirs = [] # reserved_context_size: integer # compaction_trigger_ratio: number +# ########################################################################## +# mcp +# owner: src/agent/mcp/configSection.ts +# scope: core +# hooks: stripEnv +# env: +# startup_timeout_ms <- KIMI_MCP_STARTUP_TIMEOUT_MS (custom parse) +# tool_timeout_ms <- KIMI_MCP_TOOL_TIMEOUT_MS (custom parse) +# ########################################################################## + +[mcp] +# startup_timeout_ms: integer +# tool_timeout_ms: integer + # ########################################################################## # mergeAllAvailableSkills (config.toml: merge_all_available_skills) # owner: src/app/skillCatalog/configSection.ts diff --git a/packages/agent-core-v2/src/agent/mcp/client-http.ts b/packages/agent-core-v2/src/agent/mcp/client-http.ts index 367a32834d..bdfa0a40f7 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-http.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-http.ts @@ -19,6 +19,7 @@ import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; export interface HttpMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; + readonly startupTimeoutMs?: number; readonly toolCallTimeoutMs?: number; readonly envLookup?: (name: string) => string | undefined; readonly fetch?: typeof fetch; @@ -28,6 +29,7 @@ export interface HttpMcpClientOptions { export class HttpMcpClient implements MCPClient { private readonly client: Client; private readonly transport: StreamableHTTPClientTransport; + private readonly startupTimeoutMs?: number; private readonly toolCallTimeoutMs?: number; private started = false; private closed = false; @@ -51,6 +53,7 @@ export class HttpMcpClient implements MCPClient { name: options.clientName ?? KIMI_MCP_CLIENT_NAME, version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, }); + this.startupTimeoutMs = options.startupTimeoutMs; this.toolCallTimeoutMs = options.toolCallTimeoutMs; } @@ -62,7 +65,10 @@ export class HttpMcpClient implements MCPClient { this.started = true; this.installTransportHooks(); try { - await this.client.connect(this.transport); + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); } catch (error) { await this.closeStartedClient(); throw error; @@ -90,7 +96,10 @@ export class HttpMcpClient implements MCPClient { } async listTools(): Promise { - const result = await this.client.listTools(); + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); return result.tools.map(toMcpToolDefinition); } diff --git a/packages/agent-core-v2/src/agent/mcp/client-shared.ts b/packages/agent-core-v2/src/agent/mcp/client-shared.ts index cd51273e96..ce3e1db662 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-shared.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-shared.ts @@ -76,11 +76,11 @@ export interface McpRequestOptions { } export function buildRequestOptions( - toolCallTimeoutMs: number | undefined, + timeoutMs: number | undefined, signal: AbortSignal | undefined, ): McpRequestOptions | undefined { - if (toolCallTimeoutMs === undefined && signal === undefined) return undefined; - return { timeout: toolCallTimeoutMs, signal }; + if (timeoutMs === undefined && signal === undefined) return undefined; + return { timeout: timeoutMs, signal }; } interface SdkListedTool { diff --git a/packages/agent-core-v2/src/agent/mcp/client-sse.ts b/packages/agent-core-v2/src/agent/mcp/client-sse.ts index e1ba7daaaf..5da36c0a79 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-sse.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-sse.ts @@ -19,6 +19,7 @@ import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; export interface SseMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; + readonly startupTimeoutMs?: number; readonly toolCallTimeoutMs?: number; readonly envLookup?: (name: string) => string | undefined; readonly fetch?: typeof fetch; @@ -28,6 +29,7 @@ export interface SseMcpClientOptions { export class SseMcpClient implements MCPClient { private readonly client: Client; private readonly transport: SSEClientTransport; + private readonly startupTimeoutMs?: number; private readonly toolCallTimeoutMs?: number; private started = false; private closed = false; @@ -51,6 +53,7 @@ export class SseMcpClient implements MCPClient { name: options.clientName ?? KIMI_MCP_CLIENT_NAME, version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, }); + this.startupTimeoutMs = options.startupTimeoutMs; this.toolCallTimeoutMs = options.toolCallTimeoutMs; } @@ -62,7 +65,10 @@ export class SseMcpClient implements MCPClient { this.started = true; this.installTransportHooks(); try { - await this.client.connect(this.transport); + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); } catch (error) { await this.closeStartedClient(); throw error; @@ -90,7 +96,10 @@ export class SseMcpClient implements MCPClient { } async listTools(): Promise { - const result = await this.client.listTools(); + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); return result.tools.map(toMcpToolDefinition); } diff --git a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts b/packages/agent-core-v2/src/agent/mcp/client-stdio.ts index bfe726ee57..d5d5ebab49 100644 --- a/packages/agent-core-v2/src/agent/mcp/client-stdio.ts +++ b/packages/agent-core-v2/src/agent/mcp/client-stdio.ts @@ -20,6 +20,7 @@ import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; export interface StdioMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; + readonly startupTimeoutMs?: number; readonly toolCallTimeoutMs?: number; readonly defaultCwd?: string; } @@ -29,6 +30,7 @@ const STDERR_BUFFER_CAPACITY = 4 * 1024; export class StdioMcpClient implements MCPClient { private readonly client: Client; private readonly transport: StdioClientTransport; + private readonly startupTimeoutMs?: number; private readonly toolCallTimeoutMs?: number; private readonly stderrBuffer = new BoundedTail(STDERR_BUFFER_CAPACITY); private started = false; @@ -59,6 +61,7 @@ export class StdioMcpClient implements MCPClient { name: options.clientName ?? KIMI_MCP_CLIENT_NAME, version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, }); + this.startupTimeoutMs = options.startupTimeoutMs; this.toolCallTimeoutMs = options.toolCallTimeoutMs; } @@ -70,7 +73,10 @@ export class StdioMcpClient implements MCPClient { this.started = true; this.installTransportHooks(); try { - await this.client.connect(this.transport); + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); } catch (error) { await this.closeStartedClient(); throw error; @@ -102,7 +108,10 @@ export class StdioMcpClient implements MCPClient { } async listTools(): Promise { - const result = await this.client.listTools(); + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); return result.tools.map(toMcpToolDefinition); } diff --git a/packages/agent-core-v2/src/agent/mcp/config-schema.ts b/packages/agent-core-v2/src/agent/mcp/config-schema.ts index 2c8d48544f..db31f929f2 100644 --- a/packages/agent-core-v2/src/agent/mcp/config-schema.ts +++ b/packages/agent-core-v2/src/agent/mcp/config-schema.ts @@ -11,10 +11,13 @@ import { z } from 'zod'; const StringRecordSchema = z.record(z.string(), z.string()); +export const MAX_MCP_TIMEOUT_MS = 2_147_483_647; +export const McpTimeoutMsSchema = z.number().int().min(1).max(MAX_MCP_TIMEOUT_MS); + const McpServerCommonFields = { enabled: z.boolean().optional(), - startupTimeoutMs: z.number().int().min(1).optional(), - toolTimeoutMs: z.number().int().min(1).optional(), + startupTimeoutMs: McpTimeoutMsSchema.optional(), + toolTimeoutMs: McpTimeoutMsSchema.optional(), enabledTools: z.array(z.string()).optional(), disabledTools: z.array(z.string()).optional(), } as const; diff --git a/packages/agent-core-v2/src/agent/mcp/configSection.ts b/packages/agent-core-v2/src/agent/mcp/configSection.ts new file mode 100644 index 0000000000..82a624b7df --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/configSection.ts @@ -0,0 +1,44 @@ +/** + * `mcp` domain (L5) — registers MCP timeout preferences into `config`. + * + * Owns the global MCP startup and tool-call timeout preferences, including + * their environment bindings and persistence guard. Registered into `config` + * at module load. Bound at App scope. + */ + +import { z } from 'zod'; + +import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; +import { MAX_MCP_TIMEOUT_MS, McpTimeoutMsSchema } from './config-schema'; + +export const MCP_SECTION = 'mcp'; + +export const McpSectionSchema = z.object({ + startupTimeoutMs: McpTimeoutMsSchema.optional(), + toolTimeoutMs: McpTimeoutMsSchema.optional(), +}); + +export type McpSection = z.infer; + +export const MCP_STARTUP_TIMEOUT_ENV = 'KIMI_MCP_STARTUP_TIMEOUT_MS'; +export const MCP_TOOL_TIMEOUT_ENV = 'KIMI_MCP_TOOL_TIMEOUT_MS'; + +function parseTimeoutMsEnv(raw: string): number | undefined { + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_MCP_TIMEOUT_MS + ? parsed + : undefined; +} + +export const mcpEnvBindings: EnvBindings = envBindings(McpSectionSchema, { + startupTimeoutMs: { env: MCP_STARTUP_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, + toolTimeoutMs: { env: MCP_TOOL_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, +}); + +export const stripMcpEnv = stripEnvBoundFields(mcpEnvBindings); + +registerConfigSection(MCP_SECTION, McpSectionSchema, { + env: mcpEnvBindings, + stripEnv: stripMcpEnv, +}); diff --git a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts index 19d46fd87f..5aaa4ec89b 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts @@ -6,7 +6,8 @@ * (stdio / SSE / HTTP), discovers and registers tools, attaches the OAuth * provider through `mcp/oauth` when tokens are present, flips failing * servers into `needs-auth` on 401, and reconnects after authentication. - * Emits server status changes to subscribers. Constructed by `AgentMcpService`. + * Applies per-server settings over session defaults and emits status changes + * to subscribers. Constructed by `SessionMcpService`. */ import { ErrorCodes, Error2 } from '#/errors'; @@ -58,11 +59,22 @@ const defaultLog: Logger = { child: () => defaultLog, }; +/** + * Global default timeouts applied when a server entry does not set its own + * `startupTimeoutMs` / `toolTimeoutMs`. Resolved at each (re)connect, not at + * construction, so late-ready or changed configuration is picked up. + */ +export interface McpDefaultTimeouts { + readonly startupTimeoutMs?: number; + readonly toolTimeoutMs?: number; +} + export interface McpConnectionManagerOptions { readonly envLookup?: (name: string) => string | undefined; readonly stdioCwd?: string; readonly oauthService?: McpOAuthService; readonly log?: Logger; + readonly resolveDefaultTimeouts?: () => McpDefaultTimeouts; } export class McpConnectionManager { @@ -253,11 +265,14 @@ export class McpConnectionManager { } private async connectOne(entry: InternalEntry, attemptId: number): Promise { - const timeoutMs = entry.config.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; + const timeoutMs = + entry.config.startupTimeoutMs ?? + this.options.resolveDefaultTimeouts?.().startupTimeoutMs ?? + DEFAULT_STARTUP_TIMEOUT_MS; let client: RuntimeMcpClient | undefined; try { - const startupClient = await this.createClient(entry.config, entry.name); + const startupClient = await this.createClient(entry.config, entry.name, timeoutMs); client = startupClient; entry.client = startupClient; const discovered = await withTimeout( @@ -323,19 +338,30 @@ export class McpConnectionManager { return entry.attemptId; } - private async createClient(config: McpServerConfig, name: string): Promise { - const toolCallTimeoutMs = config.toolTimeoutMs; + private async createClient( + config: McpServerConfig, + name: string, + startupTimeoutMs: number, + ): Promise { + const toolCallTimeoutMs = + config.toolTimeoutMs ?? this.options.resolveDefaultTimeouts?.().toolTimeoutMs; if (config.transport === 'stdio') { - return new StdioMcpClient(config, { toolCallTimeoutMs, defaultCwd: this.options.stdioCwd }); + return new StdioMcpClient(config, { + startupTimeoutMs, + toolCallTimeoutMs, + defaultCwd: this.options.stdioCwd, + }); } if (config.transport === 'sse') { return new SseMcpClient(config, { + startupTimeoutMs, toolCallTimeoutMs, envLookup: this.options.envLookup, oauthProvider: await this.resolveOAuthProvider(config, name), }); } return new HttpMcpClient(config, { + startupTimeoutMs, toolCallTimeoutMs, envLookup: this.options.envLookup, oauthProvider: await this.resolveOAuthProvider(config, name), diff --git a/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.md b/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.md index 0e2fe7f41e..fc8a02e77b 100644 --- a/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.md +++ b/packages/agent-core-v2/src/app/skillCatalog/builtin/mcp-config.md @@ -85,6 +85,13 @@ omit it. For less common fields (`enabled`, `startupTimeoutMs`, truth is `McpServerStdioConfigSchema` / `McpServerHttpConfigSchema` in `packages/agent-core/src/config/schema.ts`. +When the user wants to change a timeout for *every* server, don't write +`startupTimeoutMs` / `toolTimeoutMs` into each entry — the global defaults +live in `config.toml` (`[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms`) +or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` env vars; +per-server fields override them. Every timeout must be an integer from `1` to +`2147483647` milliseconds. + If the user only wants to **see** what's configured, read all three files, show a merged view with enough source-path context to inspect or remove a server from the file that actually declared it, and stop — no scope diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts b/packages/agent-core-v2/src/session/mcp/sessionMcp.ts index fb707d3255..b0ec61cbf4 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcp.ts @@ -1,13 +1,8 @@ /** - * `mcp` domain (L5), Session scope — the session's shared MCP subsystem. + * `mcp` domain (L5) — session-scoped MCP subsystem contract. * - * Owns the session-wide `McpConnectionManager` (one per session, shared by - * every agent, matching v1's session-scoped MCP and avoiding a reconnect - * storm per agent), the initial connect attempt (`ensureMcpReady`), and its - * telemetry. Split out of `agentLifecycle`: agent existence and MCP - * connections are independent concerns — the lifecycle only needs to await - * the initial connect before an agent's first turn and to seed the shared - * manager into each agent scope. Bound at Session scope. + * Defines `ISessionMcpService` for connecting the session's servers and + * exposing their shared connection manager. Bound at Session scope. */ import { createDecorator, type ServiceIdentifier } from '#/_base/di/instantiation'; @@ -19,15 +14,21 @@ export interface ISessionMcpService { /** * Resolve the session/plugin MCP config and wait for the initial connection - * attempt to finish. Per-server failures are reflected in MCP status entries - * rather than rejecting this promise; an outright failure is logged. - * `callerServers` (caller-supplied servers from session create) merge into - * the initial connect between file config and plugin servers; the first - * call wins — the initial load is cached and later calls ignore the arg. + * attempt to finish. The initial connect waits for `config.ready` so global + * timeout preferences apply deterministically. Per-server failures are + * reflected in MCP status entries rather than rejecting this promise; an + * outright failure is logged. `callerServers` (caller-supplied servers from + * session create) merge into the initial connect between file config and + * plugin servers; the first call wins — the initial load is cached and + * later calls ignore the arg. */ ensureMcpReady(callerServers?: Readonly>): Promise; - /** The session's shared connection manager (built lazily, cached). */ + /** + * The session's shared connection manager. Built lazily on first call and + * always available, independent of the initial connect's progress; global + * timeout defaults are read from `config` at each (re)connect. + */ connectionManager(): McpConnectionManager; } diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts index 6bb687df2b..0f2489f91f 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts @@ -1,10 +1,12 @@ /** - * `mcp` domain (L5), Session scope — `ISessionMcpService` implementation. + * `mcp` domain (L5) — `ISessionMcpService` implementation. * * Owns the session-wide `McpConnectionManager` (built lazily, shared by every * agent), resolves the session + caller-supplied + plugin MCP config, drives * the initial connect (`ensureMcpReady`, cached so session creation and first - * agent creation can both await it), and reports connection telemetry. An + * agent creation can both await it), and reports connection telemetry. The + * initial connect waits for `config.ready` so global timeout preferences are + * deterministic; the manager reads them again at each (re)connect. An * outright initial-load failure is logged (per-server failures are status * entries). Bound at Session scope. */ @@ -14,10 +16,12 @@ import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; import { McpConnectionManager } from '#/agent/mcp/connection-manager'; import type { McpServerConfig } from '#/agent/mcp/config-schema'; +import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; import { McpOAuthService } from '#/agent/mcp/oauth/service'; import { createMcpOAuthStore } from '#/agent/mcp/oauth/store'; import { mergeCallerMcpServers, resolveSessionMcpConfig } from '#/agent/mcp/session-config'; import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; import { IPluginService } from '#/app/plugin/plugin'; import { ITelemetryService } from '#/app/telemetry/telemetry'; import { ILogService } from '#/_base/log/log'; @@ -39,6 +43,7 @@ export class SessionMcpService extends Disposable implements ISessionMcpService @IAtomicDocumentStore private readonly atomicDocs: IAtomicDocumentStore, @ILogService private readonly log: ILogService, @ITelemetryService private readonly telemetry: ITelemetryService, + @IConfigService private readonly config: IConfigService, ) { super(); } @@ -46,7 +51,7 @@ export class SessionMcpService extends Disposable implements ISessionMcpService ensureMcpReady(callerServers?: Readonly>): Promise { if (this.mcpInitialLoad !== undefined) return this.mcpInitialLoad; const manager = this.connectionManager(); - const initialLoad = this.connectMcpServers(manager, callerServers).catch((error: unknown) => { + const initialLoad = this.initializeMcp(manager, callerServers).catch((error: unknown) => { this.log.error('mcp initial load failed', { error }); }); this.mcpInitialLoad = initialLoad; @@ -62,12 +67,27 @@ export class SessionMcpService extends Disposable implements ISessionMcpService log: this.log, oauthService, stdioCwd: this.workspace.workDir, + resolveDefaultTimeouts: () => { + const section = this.config.get(MCP_SECTION); + return { + startupTimeoutMs: section?.startupTimeoutMs, + toolTimeoutMs: section?.toolTimeoutMs, + }; + }, }); this.mcpManager = manager; this._register({ dispose: () => void manager.shutdown() }); return manager; } + private async initializeMcp( + manager: McpConnectionManager, + callerServers?: Readonly>, + ): Promise { + await this.config.ready; + await this.connectMcpServers(manager, callerServers); + } + private async connectMcpServers( manager: McpConnectionManager, callerServers?: Readonly>, diff --git a/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts b/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts index 51926e74c3..29759bc100 100644 --- a/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/config-loader.test.ts @@ -1,3 +1,11 @@ +/** + * Scenario: MCP config discovery, precedence, normalization, and validation. + * + * Exercises the real loader against temporary JSON files. Run with `pnpm + * --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/mcp/config-loader.test.ts`. + */ + import { mkdtempSync } from 'node:fs'; import { mkdir, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -211,6 +219,47 @@ describe('loadMcpServers', () => { }); }); + it('throws Error2(config.invalid) when an MCP timeout exceeds the Node.js timer limit', async () => { + const home = makeTempDir(); + const cwd = makeTempDir(); + await writeJson(join(home, 'mcp.json'), { + mcpServers: { + bad: { + transport: 'stdio', + command: 'node', + startupTimeoutMs: 2_147_483_648, + toolTimeoutMs: 2_147_483_648, + }, + }, + }); + await expect(loadMcpServers({ cwd, homeDir: home })).rejects.toMatchObject({ + code: ErrorCodes.CONFIG_INVALID, + }); + }); + + it('loads MCP timeouts at the Node.js timer upper boundary', async () => { + const home = makeTempDir(); + const cwd = makeTempDir(); + await writeJson(join(home, 'mcp.json'), { + mcpServers: { + boundary: { + transport: 'stdio', + command: 'node', + startupTimeoutMs: 2_147_483_647, + toolTimeoutMs: 2_147_483_647, + }, + }, + }); + await expect(loadMcpServers({ cwd, homeDir: home })).resolves.toEqual({ + boundary: { + transport: 'stdio', + command: 'node', + startupTimeoutMs: 2_147_483_647, + toolTimeoutMs: 2_147_483_647, + }, + }); + }); + it('infers transport=stdio when an entry omits transport but has command', async () => { const home = makeTempDir(); const cwd = makeTempDir(); diff --git a/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts b/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts index ec317c429e..fe561192f3 100644 --- a/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts +++ b/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts @@ -1,3 +1,13 @@ +/** + * Scenario: MCP connection lifecycle, timeout defaults, and session config readiness. + * + * Exercises the real connection manager and resolves the real session MCP + * service through DI. Stdio MCP processes are the external boundary; timeout + * forwarding tests stub only the MCP SDK client boundary. + * Run with `pnpm --filter @moonshot-ai/agent-core-v2 exec vitest run + * test/agent/mcp/connection-manager.test.ts`. + */ + import { randomUUID } from 'node:crypto'; import { mkdtempSync, realpathSync } from 'node:fs'; import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'; @@ -8,6 +18,7 @@ import { pathToFileURL } from 'node:url'; import { setTimeout as sleep } from 'node:timers/promises'; import { join } from 'pathe'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { @@ -15,12 +26,25 @@ import type { OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; import { z } from 'zod'; -import { describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { DisposableStore } from '#/_base/di/lifecycle'; +import { createServices } from '#/_base/di/test'; +import { ILogService } from '#/_base/log/log'; import { Error2 } from '#/errors'; import { McpConnectionManager, type McpServerEntry } from '#/agent/mcp/connection-manager'; +import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; import { McpOAuthService } from '#/agent/mcp/oauth/service'; - +import { IBootstrapService } from '#/app/bootstrap/bootstrap'; +import { IConfigService } from '#/app/config/config'; +import { IPluginService } from '#/app/plugin/plugin'; +import { ITelemetryService, noopTelemetryService } from '#/app/telemetry/telemetry'; +import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore'; +import { ISessionMcpService } from '#/session/mcp/sessionMcp'; +import { SessionMcpService } from '#/session/mcp/sessionMcpService'; +import { ISessionWorkspaceContext } from '#/session/workspaceContext/workspaceContext'; + +import { stubLog } from '../../_base/log/stubs'; import { closeServer, crashAfterConnectFixture, @@ -28,6 +52,7 @@ import { cwdStdioFixture, hangingListStdioFixture, slowStdioFixture, + slowToolStdioFixture, stderrThenExitFixture, stdioFixture, } from './stubs'; @@ -381,6 +406,130 @@ describe('McpConnectionManager', () => { } }, 7000); + it('applies the resolved default startup timeout when the server entry omits startupTimeoutMs', async () => { + const cm = new McpConnectionManager({ + resolveDefaultTimeouts: () => ({ startupTimeoutMs: 100 }), + }); + try { + await cm.connectAll({ + slow: { + transport: 'stdio', + command: process.execPath, + args: [slowStdioFixture], + }, + }); + const entry = cm.get('slow'); + expect(entry?.status).toBe('failed'); + expect(entry?.error?.toLowerCase()).toContain('timed out'); + } finally { + await cm.shutdown(); + } + }, 15000); + + it.each([ + ['stdio', stdioConfig()], + ['http', { transport: 'http' as const, url: 'https://example.test/mcp' }], + ['sse', { transport: 'sse' as const, url: 'https://example.test/sse' }], + ])( + 'forwards the resolved default startup timeout above the SDK default over %s', + async (_transport, config) => { + const connect = vi.spyOn(Client.prototype, 'connect').mockResolvedValue(); + const listTools = vi.spyOn(Client.prototype, 'listTools').mockResolvedValue({ tools: [] }); + const cm = new McpConnectionManager({ + resolveDefaultTimeouts: () => ({ startupTimeoutMs: 120_000 }), + }); + try { + await cm.connectAll({ server: config }); + expect(connect).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 120_000 }), + ); + expect(listTools).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ timeout: 120_000 }), + ); + } finally { + await cm.shutdown(); + connect.mockRestore(); + listTools.mockRestore(); + } + }, + ); + + it.each([ + ['stdio', stdioConfig()], + ['http', { transport: 'http' as const, url: 'https://example.test/mcp' }], + ['sse', { transport: 'sse' as const, url: 'https://example.test/sse' }], + ])( + 'forwards per-server startupTimeoutMs above the SDK default over %s', + async (_transport, config) => { + const connect = vi.spyOn(Client.prototype, 'connect').mockResolvedValue(); + const listTools = vi.spyOn(Client.prototype, 'listTools').mockResolvedValue({ tools: [] }); + const cm = new McpConnectionManager({ + resolveDefaultTimeouts: () => ({ startupTimeoutMs: 120_000 }), + }); + try { + await cm.connectAll({ + server: { ...config, startupTimeoutMs: 180_000 }, + }); + expect(connect).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 180_000 }), + ); + expect(listTools).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ timeout: 180_000 }), + ); + } finally { + await cm.shutdown(); + connect.mockRestore(); + listTools.mockRestore(); + } + }, + ); + + it('applies the resolved default tool timeout when the server entry omits toolTimeoutMs', async () => { + const cm = new McpConnectionManager({ + resolveDefaultTimeouts: () => ({ toolTimeoutMs: 100 }), + }); + try { + await cm.connectAll({ + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolStdioFixture], + }, + }); + const client = cm.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); + } finally { + await cm.shutdown(); + } + }, 15000); + + it('lets a per-server toolTimeoutMs override the resolved default tool timeout', async () => { + const cm = new McpConnectionManager({ + resolveDefaultTimeouts: () => ({ toolTimeoutMs: 100 }), + }); + try { + await cm.connectAll({ + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolStdioFixture], + toolTimeoutMs: 10_000, + }, + }); + const client = cm.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + const result = await client.callTool('slow_echo', { text: 'hi' }); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + } finally { + await cm.shutdown(); + } + }, 20000); + it('flips HTTP servers into needs-auth when the server returns 401 and no static token is set', async () => { const server: HttpServer = createHttpServer((_req, res) => { res.writeHead(401, { @@ -681,3 +830,96 @@ describe('McpConnectionManager', () => { } }, 15000); }); + +describe('Session MCP initialization', () => { + let cwd: string; + let homeDir: string; + let disposables: DisposableStore; + let manager: McpConnectionManager | undefined; + + beforeEach(() => { + cwd = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-cwd-')); + homeDir = mkdtempSync(join(tmpdir(), 'kimi-session-mcp-home-')); + disposables = new DisposableStore(); + manager = undefined; + }); + + afterEach(async () => { + await manager?.shutdown(); + disposables.dispose(); + await Promise.all([ + rm(cwd, { recursive: true, force: true }), + rm(homeDir, { recursive: true, force: true }), + ]); + }); + + function createSessionMcpService(ready: Promise, mcpSection?: McpSection) { + const ix = createServices(disposables, { + strict: true, + additionalServices: (reg) => { + reg.definePartialInstance(IBootstrapService, { homeDir }); + reg.definePartialInstance(ISessionWorkspaceContext, { workDir: cwd }); + reg.definePartialInstance(IPluginService, { + enabledMcpServers: async () => ({}), + }); + reg.definePartialInstance(IAtomicDocumentStore, {}); + reg.defineInstance(ILogService, stubLog()); + reg.defineInstance(ITelemetryService, noopTelemetryService); + reg.definePartialInstance(IConfigService, { + ready, + get: ((domain: string): T => + (domain === MCP_SECTION ? mcpSection : undefined) as T), + }); + reg.define(ISessionMcpService, SessionMcpService); + }, + }); + return ix.get(ISessionMcpService); + } + + it('exposes the connection manager before config is ready and starts connecting once ready', async () => { + let resolveConfigReady!: () => void; + const ready = new Promise((resolve) => { + resolveConfigReady = resolve; + }); + const service = createSessionMcpService(ready); + // The manager is available synchronously, independent of config readiness. + manager = service.connectionManager(); + expect(manager.list()).toEqual([]); + + const initialLoad = service.ensureMcpReady({ + alpha: { + transport: 'stdio', + command: process.execPath, + args: [stdioFixture], + }, + }); + try { + // The initial connect is gated on config.ready: no entry exists yet. + await sleep(50); + expect(manager.list()).toEqual([]); + + resolveConfigReady(); + await initialLoad; + expect(manager.get('alpha')?.status).toBe('connected'); + } finally { + resolveConfigReady(); + await initialLoad; + } + }, 15000); + + it('times out tool calls using the session MCP timeout preference', async () => { + const service = createSessionMcpService(Promise.resolve(), { toolTimeoutMs: 1 }); + await service.ensureMcpReady({ + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolStdioFixture], + env: { KIMI_TEST_MCP_TOOL_DELAY_MS: '300' }, + }, + }); + manager = service.connectionManager(); + const client = manager.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); + }, 15000); +}); diff --git a/packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs b/packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs new file mode 100644 index 0000000000..3ab144cca4 --- /dev/null +++ b/packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs @@ -0,0 +1,23 @@ +import { setTimeout as sleep } from 'node:timers/promises'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const delayMs = Number.parseInt(process.env['KIMI_TEST_MCP_TOOL_DELAY_MS'] ?? '2000', 10); + +const server = new McpServer({ name: 'slow-tool-stdio', version: '0.0.1' }); + +server.registerTool( + 'slow_echo', + { + description: 'Echoes input text after a delay', + inputSchema: { text: z.string() }, + }, + async ({ text }) => { + await sleep(delayMs); + return { content: [{ type: 'text', text }] }; + }, +); + +await server.connect(new StdioServerTransport()); diff --git a/packages/agent-core-v2/test/agent/mcp/stubs.ts b/packages/agent-core-v2/test/agent/mcp/stubs.ts index caaf20569a..4dea9aa1d9 100644 --- a/packages/agent-core-v2/test/agent/mcp/stubs.ts +++ b/packages/agent-core-v2/test/agent/mcp/stubs.ts @@ -21,6 +21,10 @@ export const fixturesDir = new URL('./fixtures/', import.meta.url).pathname; export const stdioFixture = new URL('./fixtures/mock-stdio-server.mjs', import.meta.url).pathname; export const cwdStdioFixture = new URL('./fixtures/cwd-stdio-server.mjs', import.meta.url).pathname; export const slowStdioFixture = new URL('./fixtures/slow-stdio-server.mjs', import.meta.url).pathname; +export const slowToolStdioFixture = new URL( + './fixtures/slow-tool-stdio-server.mjs', + import.meta.url, +).pathname; export const hangingListStdioFixture = new URL( './fixtures/hanging-list-stdio-server.mjs', import.meta.url, diff --git a/packages/agent-core-v2/test/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index f957d72e75..a76c3d2bdb 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -61,6 +61,14 @@ import { SUBAGENT_TIMEOUT_ENV, type SubagentConfig, } from '#/session/subagent/configSection'; +import '#/agent/mcp/configSection'; +import { + MCP_SECTION, + MCP_STARTUP_TIMEOUT_ENV, + MCP_TOOL_TIMEOUT_ENV, + McpSectionSchema, + type McpSection, +} from '#/agent/mcp/configSection'; import { ILogService } from '#/_base/log/log'; import { InMemoryStorageService } from '#/persistence/backends/memory/inMemoryStorageService'; import { IFileSystemStorageService } from '#/persistence/interface/storage'; @@ -1238,6 +1246,120 @@ describe('subagent config section', () => { }); }); +describe('mcp config section', () => { + async function createConfig(env: Record, toml?: string) { + const disposables = new DisposableStore(); + const ix = disposables.add(new TestInstantiationService()); + const storage = new InMemoryStorageService(); + if (toml !== undefined) { + await storage.write('', 'config.toml', new TextEncoder().encode(toml)); + } + ix.stub(ILogService, stubLog()); + ix.stub(IBootstrapService, stubBootstrap('/tmp/kimi-cfg', env)); + ix.stub(IFileSystemStorageService, storage); + ix.set(IAtomicTomlDocumentStore, new SyncDescriptor(TomlAtomicDocumentStore)); + ix.set(IConfigRegistry, new SyncDescriptor(ConfigRegistry)); + ix.set(IConfigService, new SyncDescriptor(ConfigService)); + const config = ix.get(IConfigService); + await config.ready; + return { config, disposables }; + } + + it('is unset by default and honours the env override', async () => { + const env: Record = {}; + const { config, disposables } = await createConfig(env); + + expect(config.get(MCP_SECTION)?.startupTimeoutMs).toBeUndefined(); + + env[MCP_STARTUP_TIMEOUT_ENV] = 'abc'; + expect(config.get(MCP_SECTION)?.startupTimeoutMs).toBeUndefined(); + + env[MCP_STARTUP_TIMEOUT_ENV] = '60000'; + expect(config.get(MCP_SECTION)?.startupTimeoutMs).toBe(60000); + + disposables.dispose(); + }); + + it('accepts the Node.js timer upper boundary', () => { + expect( + McpSectionSchema.safeParse({ + startupTimeoutMs: 2_147_483_647, + toolTimeoutMs: 2_147_483_647, + }).success, + ).toBe(true); + }); + + it('rejects config timeouts above the Node.js timer limit', () => { + expect( + McpSectionSchema.safeParse({ + startupTimeoutMs: 2_147_483_648, + toolTimeoutMs: 2_147_483_648, + }).success, + ).toBe(false); + }); + + it('falls back to config when env timeouts exceed the Node.js timer limit', async () => { + const env: Record = { + [MCP_STARTUP_TIMEOUT_ENV]: '2147483648', + [MCP_TOOL_TIMEOUT_ENV]: '2147483648', + }; + const { config, disposables } = await createConfig( + env, + '[mcp]\nstartup_timeout_ms = 5000\ntool_timeout_ms = 60000\n', + ); + try { + expect(config.get(MCP_SECTION)).toEqual({ + startupTimeoutMs: 5000, + toolTimeoutMs: 60000, + }); + } finally { + disposables.dispose(); + } + }); + + it('reads startup_timeout_ms from config.toml and lets the env var win', async () => { + const env: Record = {}; + const { config, disposables } = await createConfig(env, '[mcp]\nstartup_timeout_ms = 5000\n'); + expect(config.get(MCP_SECTION)?.startupTimeoutMs).toBe(5000); + + env[MCP_STARTUP_TIMEOUT_ENV] = '7000'; + expect(config.get(MCP_SECTION)?.startupTimeoutMs).toBe(7000); + + disposables.dispose(); + }); + + it('reads tool_timeout_ms from config.toml and lets the env var win', async () => { + const env: Record = {}; + const { config, disposables } = await createConfig(env, '[mcp]\ntool_timeout_ms = 60000\n'); + expect(config.get(MCP_SECTION)?.toolTimeoutMs).toBe(60000); + + env[MCP_TOOL_TIMEOUT_ENV] = 'abc'; + expect(config.get(MCP_SECTION)?.toolTimeoutMs).toBe(60000); + + env[MCP_TOOL_TIMEOUT_ENV] = '90000'; + expect(config.get(MCP_SECTION)?.toolTimeoutMs).toBe(90000); + + disposables.dispose(); + }); + + it('restores the env-owned timeout to the raw value on set() while the env var is set', async () => { + const env: Record = { [MCP_STARTUP_TIMEOUT_ENV]: '7000' }; + const { config, disposables } = await createConfig(env, '[mcp]\nstartup_timeout_ms = 5000\n'); + + // A client echoing the env-overlaid section back. + await config.set(MCP_SECTION, { startupTimeoutMs: 7000 }); + + // Runtime resolution still lets the env win… + expect(config.get(MCP_SECTION)?.startupTimeoutMs).toBe(7000); + // …but persistence keeps the raw value. + expect(config.inspect(MCP_SECTION).userValue).toEqual({ + startupTimeoutMs: 5000, + }); + + disposables.dispose(); + }); +}); + describe('get() freshness for overlay-written domains', () => { it('recomputes overlay values on every get()', async () => { const env: Record = {}; diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 16c54fd558..8b7baea6ca 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -172,6 +172,28 @@ export const SubagentConfigSchema = z.object({ export type SubagentConfig = z.infer; +export const MAX_MCP_TIMEOUT_MS = 2_147_483_647; +const McpTimeoutMsSchema = z.number().int().min(1).max(MAX_MCP_TIMEOUT_MS); + +export const McpConfigSchema = z.object({ + /** + * Global default MCP server startup (connect + tool discovery) timeout in + * milliseconds. A per-server `startupTimeoutMs` in `mcp.json` and the + * KIMI_MCP_STARTUP_TIMEOUT_MS env var both win over this value. Defaults + * to 30s when unset. + */ + startupTimeoutMs: McpTimeoutMsSchema.optional(), + /** + * Global default single MCP tool-call timeout in milliseconds. A + * per-server `toolTimeoutMs` in `mcp.json` and the + * KIMI_MCP_TOOL_TIMEOUT_MS env var both win over this value. Falls back to + * the client built-in default when unset. + */ + toolTimeoutMs: McpTimeoutMsSchema.optional(), +}); + +export type McpConfig = z.infer; + export const ImageConfigSchema = z.object({ /** * Longest-edge ceiling (px) applied when compressing images for the model. @@ -233,8 +255,8 @@ export type ServicesConfig = z.infer; const McpServerCommonFields = { enabled: z.boolean().optional(), - startupTimeoutMs: z.number().int().min(1).optional(), - toolTimeoutMs: z.number().int().min(1).optional(), + startupTimeoutMs: McpTimeoutMsSchema.optional(), + toolTimeoutMs: McpTimeoutMsSchema.optional(), enabledTools: z.array(z.string()).optional(), disabledTools: z.array(z.string()).optional(), } as const; @@ -319,6 +341,7 @@ export const KimiConfigSchema = z.object({ loopControl: LoopControlSchema.optional(), background: BackgroundConfigSchema.optional(), subagent: SubagentConfigSchema.optional(), + mcp: McpConfigSchema.optional(), image: ImageConfigSchema.optional(), modelCatalog: ModelCatalogConfigSchema.optional(), experimental: ExperimentalConfigSchema.optional(), @@ -335,6 +358,7 @@ const PermissionConfigPatchSchema = PermissionConfigSchema.partial(); const LoopControlPatchSchema = LoopControlSchema.partial(); const BackgroundConfigPatchSchema = BackgroundConfigSchema.partial(); const SubagentConfigPatchSchema = SubagentConfigSchema.partial(); +const McpConfigPatchSchema = McpConfigSchema.partial(); const ImageConfigPatchSchema = ImageConfigSchema.partial(); const ModelCatalogConfigPatchSchema = ModelCatalogConfigSchema.partial(); const ExperimentalConfigPatchSchema = ExperimentalConfigSchema; @@ -363,6 +387,7 @@ export const KimiConfigPatchSchema = z loopControl: LoopControlPatchSchema.optional(), background: BackgroundConfigPatchSchema.optional(), subagent: SubagentConfigPatchSchema.optional(), + mcp: McpConfigPatchSchema.optional(), image: ImageConfigPatchSchema.optional(), modelCatalog: ModelCatalogConfigPatchSchema.optional(), experimental: ExperimentalConfigPatchSchema.optional(), diff --git a/packages/agent-core/src/config/toml.ts b/packages/agent-core/src/config/toml.ts index edee21a041..ff1285a6df 100644 --- a/packages/agent-core/src/config/toml.ts +++ b/packages/agent-core/src/config/toml.ts @@ -14,6 +14,7 @@ import { type ImageConfig, type KimiConfig, type LoopControl, + type McpConfig, type ModelAlias, type MoonshotServiceConfig, type OAuthRef, @@ -320,6 +321,8 @@ export function transformTomlData(data: Record): Record { setSection(out, 'loop_control', config.loopControl, loopControlToToml); setSection(out, 'background', config.background, backgroundToToml); setSection(out, 'subagent', config.subagent, subagentToToml); + setSection(out, 'mcp', config.mcp, mcpToToml); setSection(out, 'image', config.image, imageToToml); setSection(out, 'experimental', config.experimental, experimentalToToml); setSection(out, 'permission', config.permission, permissionToToml); @@ -686,6 +690,14 @@ function subagentToToml(subagent: SubagentConfig, rawSubagent: unknown): Record< return out; } +function mcpToToml(mcp: McpConfig, rawMcp: unknown): Record { + const out = cloneRecord(rawMcp); + for (const [key, value] of Object.entries(mcp)) { + setDefined(out, camelToSnake(key), value); + } + return out; +} + function imageToToml(image: ImageConfig, rawImage: unknown): Record { const out = cloneRecord(rawImage); for (const [key, value] of Object.entries(image)) { diff --git a/packages/agent-core/src/mcp/client-http.ts b/packages/agent-core/src/mcp/client-http.ts index 214217e26e..f38b54a47d 100644 --- a/packages/agent-core/src/mcp/client-http.ts +++ b/packages/agent-core/src/mcp/client-http.ts @@ -18,6 +18,7 @@ import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; export interface HttpMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; + readonly startupTimeoutMs?: number; readonly toolCallTimeoutMs?: number; /** * Reads `process.env[name]` by default. Tests can inject a deterministic @@ -45,6 +46,7 @@ export interface HttpMcpClientOptions { export class HttpMcpClient implements MCPClient { private readonly client: Client; private readonly transport: StreamableHTTPClientTransport; + private readonly startupTimeoutMs?: number; private readonly toolCallTimeoutMs?: number; private started = false; private closed = false; @@ -76,6 +78,7 @@ export class HttpMcpClient implements MCPClient { name: options.clientName ?? KIMI_MCP_CLIENT_NAME, version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, }); + this.startupTimeoutMs = options.startupTimeoutMs; this.toolCallTimeoutMs = options.toolCallTimeoutMs; } @@ -88,7 +91,10 @@ export class HttpMcpClient implements MCPClient { // Install hooks BEFORE the SDK handshake; see StdioMcpClient.connect. this.installTransportHooks(); try { - await this.client.connect(this.transport); + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); } catch (error) { await this.closeStartedClient(); throw error; @@ -122,7 +128,10 @@ export class HttpMcpClient implements MCPClient { } async listTools(): Promise { - const result = await this.client.listTools(); + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); return result.tools.map(toMcpToolDefinition); } diff --git a/packages/agent-core/src/mcp/client-shared.ts b/packages/agent-core/src/mcp/client-shared.ts index b36d5fad88..3b9fec4429 100644 --- a/packages/agent-core/src/mcp/client-shared.ts +++ b/packages/agent-core/src/mcp/client-shared.ts @@ -32,17 +32,17 @@ export interface McpRequestOptions { } /** - * Build the `RequestOptions` object accepted by the MCP SDK's `callTool`, - * including either the configured tool-call timeout, an in-flight abort - * signal, both, or neither. Returns `undefined` when nothing needs to be - * passed so the SDK falls back to its defaults. + * Build the `RequestOptions` object accepted by MCP SDK requests, including + * either a configured timeout, an in-flight abort signal, both, or neither. + * Returns `undefined` when nothing needs to be passed so the SDK falls back + * to its defaults. */ export function buildRequestOptions( - toolCallTimeoutMs: number | undefined, + timeoutMs: number | undefined, signal: AbortSignal | undefined, ): McpRequestOptions | undefined { - if (toolCallTimeoutMs === undefined && signal === undefined) return undefined; - return { timeout: toolCallTimeoutMs, signal }; + if (timeoutMs === undefined && signal === undefined) return undefined; + return { timeout: timeoutMs, signal }; } interface SdkListedTool { diff --git a/packages/agent-core/src/mcp/client-sse.ts b/packages/agent-core/src/mcp/client-sse.ts index 4ac2e3dc02..254c0786de 100644 --- a/packages/agent-core/src/mcp/client-sse.ts +++ b/packages/agent-core/src/mcp/client-sse.ts @@ -18,6 +18,7 @@ import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; export interface SseMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; + readonly startupTimeoutMs?: number; readonly toolCallTimeoutMs?: number; /** * Reads `process.env[name]` by default. Tests can inject a deterministic @@ -44,6 +45,7 @@ export interface SseMcpClientOptions { export class SseMcpClient implements MCPClient { private readonly client: Client; private readonly transport: SSEClientTransport; + private readonly startupTimeoutMs?: number; private readonly toolCallTimeoutMs?: number; private started = false; private closed = false; @@ -69,6 +71,7 @@ export class SseMcpClient implements MCPClient { name: options.clientName ?? KIMI_MCP_CLIENT_NAME, version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, }); + this.startupTimeoutMs = options.startupTimeoutMs; this.toolCallTimeoutMs = options.toolCallTimeoutMs; } @@ -80,7 +83,10 @@ export class SseMcpClient implements MCPClient { this.started = true; this.installTransportHooks(); try { - await this.client.connect(this.transport); + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); } catch (error) { await this.closeStartedClient(); throw error; @@ -113,7 +119,10 @@ export class SseMcpClient implements MCPClient { } async listTools(): Promise { - const result = await this.client.listTools(); + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); return result.tools.map(toMcpToolDefinition); } diff --git a/packages/agent-core/src/mcp/client-stdio.ts b/packages/agent-core/src/mcp/client-stdio.ts index 998bd7ba03..267056d428 100644 --- a/packages/agent-core/src/mcp/client-stdio.ts +++ b/packages/agent-core/src/mcp/client-stdio.ts @@ -20,6 +20,7 @@ import type { MCPClient, MCPToolDefinition, MCPToolResult } from './types'; export interface StdioMcpClientOptions { readonly clientName?: string; readonly clientVersion?: string; + readonly startupTimeoutMs?: number; readonly toolCallTimeoutMs?: number; readonly defaultCwd?: string; } @@ -35,6 +36,7 @@ const STDERR_BUFFER_CAPACITY = 4 * 1024; export class StdioMcpClient implements MCPClient { private readonly client: Client; private readonly transport: StdioClientTransport; + private readonly startupTimeoutMs?: number; private readonly toolCallTimeoutMs?: number; private readonly stderrBuffer = new BoundedTail(STDERR_BUFFER_CAPACITY); private started = false; @@ -78,6 +80,7 @@ export class StdioMcpClient implements MCPClient { name: options.clientName ?? KIMI_MCP_CLIENT_NAME, version: options.clientVersion ?? KIMI_MCP_CLIENT_VERSION, }); + this.startupTimeoutMs = options.startupTimeoutMs; this.toolCallTimeoutMs = options.toolCallTimeoutMs; } @@ -93,7 +96,10 @@ export class StdioMcpClient implements MCPClient { // the handshake still flows through `client.connect()` rejecting. this.installTransportHooks(); try { - await this.client.connect(this.transport); + await this.client.connect( + this.transport, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); } catch (error) { await this.closeStartedClient(); throw error; @@ -139,7 +145,10 @@ export class StdioMcpClient implements MCPClient { } async listTools(): Promise { - const result = await this.client.listTools(); + const result = await this.client.listTools( + undefined, + buildRequestOptions(this.startupTimeoutMs, undefined), + ); return result.tools.map(toMcpToolDefinition); } diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 258c191073..152d3a8131 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -1,5 +1,5 @@ import { ErrorCodes, KimiError } from '#/errors'; -import type { McpServerConfig } from '#/config/schema'; +import { MAX_MCP_TIMEOUT_MS, type McpServerConfig } from '#/config/schema'; import { log as defaultLog } from '#/logging/logger'; import type { Logger } from '#/logging/types'; import type { Tool } from '@moonshot-ai/kosong'; @@ -40,6 +40,49 @@ export type McpStatusListener = (entry: McpServerEntry) => void; const DEFAULT_STARTUP_TIMEOUT_MS = 30_000; +export const MCP_STARTUP_TIMEOUT_ENV = 'KIMI_MCP_STARTUP_TIMEOUT_MS'; +export const MCP_TOOL_TIMEOUT_ENV = 'KIMI_MCP_TOOL_TIMEOUT_MS'; + +/** Parse an env override; anything but an integer from 1 to MAX_MCP_TIMEOUT_MS is ignored. */ +function parseTimeoutMsEnv(raw: string): number | undefined { + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_MCP_TIMEOUT_MS + ? parsed + : undefined; +} + +/** + * Resolve the global default MCP server startup (connect + tool discovery) + * timeout. Precedence: `KIMI_MCP_STARTUP_TIMEOUT_MS` (integer ms) → + * `configMs` (`[mcp] startup_timeout_ms`) → `undefined` (the manager's + * built-in default applies). A per-server `startupTimeoutMs` in `mcp.json` + * always wins over the resolved value. + */ +export function resolveMcpStartupTimeoutMs(configMs?: number): number | undefined { + const raw = process.env[MCP_STARTUP_TIMEOUT_ENV]; + if (raw !== undefined) { + const parsed = parseTimeoutMsEnv(raw); + if (parsed !== undefined) return parsed; + } + return configMs; +} + +/** + * Resolve the global default single MCP tool-call timeout. Precedence: + * `KIMI_MCP_TOOL_TIMEOUT_MS` (integer ms) → `configMs` + * (`[mcp] tool_timeout_ms`) → `undefined` (the client built-in default + * applies). A per-server `toolTimeoutMs` in `mcp.json` always wins over the + * resolved value. + */ +export function resolveMcpToolTimeoutMs(configMs?: number): number | undefined { + const raw = process.env[MCP_TOOL_TIMEOUT_ENV]; + if (raw !== undefined) { + const parsed = parseTimeoutMsEnv(raw); + if (parsed !== undefined) return parsed; + } + return configMs; +} + type RuntimeMcpClient = StdioMcpClient | HttpMcpClient | SseMcpClient; export interface McpConnectionManagerOptions { @@ -60,6 +103,18 @@ export interface McpConnectionManagerOptions { * `session.log` so MCP events land in the session log too. */ readonly log?: Logger; + /** + * Global default startup (connect + tool discovery) timeout applied when a + * server entry does not set its own `startupTimeoutMs`. Falls back to the + * built-in default when unset. + */ + readonly defaultStartupTimeoutMs?: number; + /** + * Global default single tool-call timeout applied when a server entry does + * not set its own `toolTimeoutMs`. Falls back to the client built-in when + * unset. + */ + readonly defaultToolTimeoutMs?: number; } /** @@ -267,11 +322,14 @@ export class McpConnectionManager { } private async connectOne(entry: InternalEntry, attemptId: number): Promise { - const timeoutMs = entry.config.startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; + const timeoutMs = + entry.config.startupTimeoutMs ?? + this.options.defaultStartupTimeoutMs ?? + DEFAULT_STARTUP_TIMEOUT_MS; let client: RuntimeMcpClient | undefined; try { - const startupClient = this.createClient(entry.config, entry.name); + const startupClient = this.createClient(entry.config, entry.name, timeoutMs); client = startupClient; entry.client = startupClient; const discovered = await withTimeout( @@ -343,19 +401,29 @@ export class McpConnectionManager { return entry.attemptId; } - private createClient(config: McpServerConfig, name: string): RuntimeMcpClient { - const toolCallTimeoutMs = config.toolTimeoutMs; + private createClient( + config: McpServerConfig, + name: string, + startupTimeoutMs: number, + ): RuntimeMcpClient { + const toolCallTimeoutMs = config.toolTimeoutMs ?? this.options.defaultToolTimeoutMs; if (config.transport === 'stdio') { - return new StdioMcpClient(config, { toolCallTimeoutMs, defaultCwd: this.options.stdioCwd }); + return new StdioMcpClient(config, { + startupTimeoutMs, + toolCallTimeoutMs, + defaultCwd: this.options.stdioCwd, + }); } if (config.transport === 'sse') { return new SseMcpClient(config, { + startupTimeoutMs, toolCallTimeoutMs, envLookup: this.options.envLookup, oauthProvider: this.resolveOAuthProvider(config, name), }); } return new HttpMcpClient(config, { + startupTimeoutMs, toolCallTimeoutMs, envLookup: this.options.envLookup, oauthProvider: this.resolveOAuthProvider(config, name), diff --git a/packages/agent-core/src/rpc/core-impl.ts b/packages/agent-core/src/rpc/core-impl.ts index 4b34590d38..116ad6123e 100644 --- a/packages/agent-core/src/rpc/core-impl.ts +++ b/packages/agent-core/src/rpc/core-impl.ts @@ -42,6 +42,8 @@ import { GlobalMcpConfigStore, McpConnectionManager, McpOAuthService, + resolveMcpStartupTimeoutMs, + resolveMcpToolTimeoutMs, resolveSessionMcpConfig, mergeCallerMcpServers, type BeginAuthorizationResult, @@ -788,6 +790,8 @@ export class KimiCore implements PromisableMethods { const manager = new McpConnectionManager({ stdioCwd: cwd, oauthService: this.globalMcpOAuth, + defaultStartupTimeoutMs: resolveMcpStartupTimeoutMs(this.config.mcp?.startupTimeoutMs), + defaultToolTimeoutMs: resolveMcpToolTimeoutMs(this.config.mcp?.toolTimeoutMs), }); try { await manager.connectAll({ [server.name]: config }); diff --git a/packages/agent-core/src/session/index.ts b/packages/agent-core/src/session/index.ts index 63f8d6152e..92f9cd291e 100644 --- a/packages/agent-core/src/session/index.ts +++ b/packages/agent-core/src/session/index.ts @@ -29,6 +29,8 @@ import { makeErrorPayload } from '../errors'; import { McpConnectionManager, McpOAuthService, + resolveMcpStartupTimeoutMs, + resolveMcpToolTimeoutMs, type McpServerEntry, type SessionMcpConfig, } from '../mcp'; @@ -232,6 +234,8 @@ export class Session { oauthService: new McpOAuthService({ kimiHomeDir: options.kimiHomeDir }), log: this.log, stdioCwd: options.kaos.getcwd(), + defaultStartupTimeoutMs: resolveMcpStartupTimeoutMs(options.config?.mcp?.startupTimeoutMs), + defaultToolTimeoutMs: resolveMcpToolTimeoutMs(options.config?.mcp?.toolTimeoutMs), }); this.mcp.onStatusChange((entry) => { this.onMcpServerStatusChange(entry); diff --git a/packages/agent-core/src/skill/builtin/mcp-config.md b/packages/agent-core/src/skill/builtin/mcp-config.md index 0e2fe7f41e..fc8a02e77b 100644 --- a/packages/agent-core/src/skill/builtin/mcp-config.md +++ b/packages/agent-core/src/skill/builtin/mcp-config.md @@ -85,6 +85,13 @@ omit it. For less common fields (`enabled`, `startupTimeoutMs`, truth is `McpServerStdioConfigSchema` / `McpServerHttpConfigSchema` in `packages/agent-core/src/config/schema.ts`. +When the user wants to change a timeout for *every* server, don't write +`startupTimeoutMs` / `toolTimeoutMs` into each entry — the global defaults +live in `config.toml` (`[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms`) +or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` env vars; +per-server fields override them. Every timeout must be an integer from `1` to +`2147483647` milliseconds. + If the user only wants to **see** what's configured, read all three files, show a merged view with enough source-path context to inspect or remove a server from the file that actually declared it, and stop — no scope diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index d626f7d5fb..39612d53aa 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -1,3 +1,11 @@ +/** + * Scenario: public config parsing, validation, TOML round-trips, and runtime overrides. + * + * Exercises the real config API with temporary files as the persistence + * boundary. Run with `pnpm --filter @moonshot-ai/agent-core exec vitest run + * test/config/configs.test.ts`. + */ + import { mkdtempSync } from 'node:fs'; import { readFile, rm, writeFile } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -8,6 +16,7 @@ import { afterEach, describe, expect, it } from 'vitest'; import { ErrorCodes, KimiError } from '../../src/errors'; import { KimiConfigSchema, + McpServerConfigSchema, applyPrintModeConfigDefaults, configToTomlData, ensureConfigFile, @@ -110,6 +119,10 @@ print_wait_ceiling_s = 3600 [subagent] timeout_ms = 600000 +[mcp] +startup_timeout_ms = 45000 +tool_timeout_ms = 120000 + [image] max_edge_px = 1500 read_byte_budget = 131072 @@ -193,6 +206,7 @@ describe('harness config TOML loader', () => { printWaitCeilingS: 3600, }); expect(config.subagent).toMatchObject({ timeoutMs: 600000 }); + expect(config.mcp).toEqual({ startupTimeoutMs: 45000, toolTimeoutMs: 120000 }); expect(config.image).toEqual({ maxEdgePx: 1500, readByteBudget: 131072 }); expect(config.hooks).toEqual([ { @@ -500,6 +514,44 @@ describe('harness config schema and patch merge', () => { ).toThrow(/max_context_size/); }); + it('accepts the Node.js timer upper boundary for MCP timeouts', () => { + expect( + KimiConfigSchema.safeParse({ + mcp: { + startupTimeoutMs: 2_147_483_647, + toolTimeoutMs: 2_147_483_647, + }, + }).success, + ).toBe(true); + expect( + McpServerConfigSchema.safeParse({ + transport: 'stdio', + command: 'node', + startupTimeoutMs: 2_147_483_647, + toolTimeoutMs: 2_147_483_647, + }).success, + ).toBe(true); + }); + + it('rejects MCP timeouts above the Node.js timer limit across config surfaces', () => { + expect( + KimiConfigSchema.safeParse({ + mcp: { + startupTimeoutMs: 2_147_483_648, + toolTimeoutMs: 2_147_483_648, + }, + }).success, + ).toBe(false); + expect( + McpServerConfigSchema.safeParse({ + transport: 'stdio', + command: 'node', + startupTimeoutMs: 2_147_483_648, + toolTimeoutMs: 2_147_483_648, + }).success, + ).toBe(false); + }); + it('deep-merges validated patches while preserving existing typed and raw data', () => { const base = parseConfigString(COMPLETE_TOML); const merged = mergeConfigPatch(base, { diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 504a35b1d8..dabd666011 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -1,3 +1,12 @@ +/** + * Scenario: MCP connection lifecycle, timeout defaults, and Session wiring. + * + * Exercises the real connection manager and Session while stdio/HTTP MCP + * processes provide the external boundary; timeout forwarding tests stub only + * the MCP SDK client boundary. Run with `pnpm --filter + * @moonshot-ai/agent-core exec vitest run test/mcp/connection-manager.test.ts`. + */ + import { realpathSync } from 'node:fs'; import { mkdtemp, readFile, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; @@ -7,12 +16,13 @@ import { fileURLToPath, pathToFileURL } from 'node:url'; import { testKaos } from '../fixtures/test-kaos'; import type { ProviderConfig } from '@moonshot-ai/kosong'; -import { describe, expect, it } from 'vitest'; +import { afterEach, describe, expect, it, vi } from 'vitest'; import { randomUUID } from 'node:crypto'; import { createServer as createHttpServer, type Server as HttpServer } from 'node:http'; import type { AddressInfo as HttpAddress } from 'node:net'; +import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'; import type { @@ -23,7 +33,14 @@ import { z } from 'zod'; import { KimiError } from '../../src/errors'; import { ProviderManager } from '../../src/session/provider-manager'; -import { McpConnectionManager, type McpServerEntry } from '../../src/mcp/connection-manager'; +import { + MCP_STARTUP_TIMEOUT_ENV, + MCP_TOOL_TIMEOUT_ENV, + McpConnectionManager, + resolveMcpStartupTimeoutMs, + resolveMcpToolTimeoutMs, + type McpServerEntry, +} from '../../src/mcp/connection-manager'; import { JsonFileStore, McpOAuthService } from '../../src/mcp/oauth'; import type { AgentEvent, SDKSessionRPC } from '../../src/rpc'; import { Session } from '../../src/session'; @@ -34,6 +51,7 @@ import { createScriptedGenerate } from '../agent/harness'; const here = import.meta.dirname; const stdioFixture = join(here, 'fixtures', 'mock-stdio-server.mjs'); const cwdStdioFixture = join(here, 'fixtures', 'cwd-stdio-server.mjs'); +const slowToolStdioFixture = join(here, 'fixtures', 'slow-tool-stdio-server.mjs'); const crashAfterConnectFixture = join(here, 'fixtures', 'crash-after-connect-stdio-server.mjs'); const stderrThenExitFixture = join(here, 'fixtures', 'stderr-then-exit-stdio-server.mjs'); const MOCK_PROVIDER: ProviderConfig = { @@ -367,6 +385,123 @@ describe('McpConnectionManager', () => { } }, 7000); + it('applies defaultStartupTimeoutMs when the server entry omits startupTimeoutMs', async () => { + const cm = new McpConnectionManager({ defaultStartupTimeoutMs: 100 }); + try { + const slowFixture = join(here, 'fixtures', 'slow-stdio-server.mjs'); + await cm.connectAll({ + slow: { + transport: 'stdio', + command: process.execPath, + args: [slowFixture], + }, + }); + const entry = cm.get('slow'); + expect(entry?.status).toBe('failed'); + expect(entry?.error?.toLowerCase()).toContain('timed out'); + } finally { + await cm.shutdown(); + } + }, 15000); + + it.each([ + ['stdio', stdioConfig()], + ['http', { transport: 'http' as const, url: 'https://example.test/mcp' }], + ['sse', { transport: 'sse' as const, url: 'https://example.test/sse' }], + ])( + 'forwards defaultStartupTimeoutMs above the SDK default over %s', + async (_transport, config) => { + const connect = vi.spyOn(Client.prototype, 'connect').mockResolvedValue(); + const listTools = vi.spyOn(Client.prototype, 'listTools').mockResolvedValue({ tools: [] }); + const cm = new McpConnectionManager({ defaultStartupTimeoutMs: 120_000 }); + try { + await cm.connectAll({ server: config }); + expect(connect).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 120_000 }), + ); + expect(listTools).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ timeout: 120_000 }), + ); + } finally { + await cm.shutdown(); + connect.mockRestore(); + listTools.mockRestore(); + } + }, + ); + + it.each([ + ['stdio', stdioConfig()], + ['http', { transport: 'http' as const, url: 'https://example.test/mcp' }], + ['sse', { transport: 'sse' as const, url: 'https://example.test/sse' }], + ])( + 'forwards per-server startupTimeoutMs above the SDK default over %s', + async (_transport, config) => { + const connect = vi.spyOn(Client.prototype, 'connect').mockResolvedValue(); + const listTools = vi.spyOn(Client.prototype, 'listTools').mockResolvedValue({ tools: [] }); + const cm = new McpConnectionManager({ defaultStartupTimeoutMs: 120_000 }); + try { + await cm.connectAll({ + server: { ...config, startupTimeoutMs: 180_000 }, + }); + expect(connect).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ timeout: 180_000 }), + ); + expect(listTools).toHaveBeenCalledWith( + undefined, + expect.objectContaining({ timeout: 180_000 }), + ); + } finally { + await cm.shutdown(); + connect.mockRestore(); + listTools.mockRestore(); + } + }, + ); + + it('applies defaultToolTimeoutMs when the server entry omits toolTimeoutMs', async () => { + const cm = new McpConnectionManager({ defaultToolTimeoutMs: 100 }); + try { + const slowToolFixture = join(here, 'fixtures', 'slow-tool-stdio-server.mjs'); + await cm.connectAll({ + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolFixture], + }, + }); + const client = cm.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); + } finally { + await cm.shutdown(); + } + }, 15000); + + it('lets a per-server toolTimeoutMs override defaultToolTimeoutMs', async () => { + const cm = new McpConnectionManager({ defaultToolTimeoutMs: 100 }); + try { + const slowToolFixture = join(here, 'fixtures', 'slow-tool-stdio-server.mjs'); + await cm.connectAll({ + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolFixture], + toolTimeoutMs: 10_000, + }, + }); + const client = cm.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + const result = await client.callTool('slow_echo', { text: 'hi' }); + expect(result.content).toEqual([{ type: 'text', text: 'hi' }]); + } finally { + await cm.shutdown(); + } + }, 20000); + it('marks an explicitly OAuth HTTP server as needs-auth when non-auth headers accompany a 401', async () => { const server: HttpServer = createHttpServer((_req, res) => { res.writeHead(401, { @@ -846,6 +981,37 @@ describe('Session MCP startup', () => { } }, 7000); + it("times out tool calls using the Session's global MCP config", async () => { + const tmp = await mkdtemp(join(tmpdir(), 'kimi-session-mcp-global-timeout-')); + const session = new Session({ + id: 'test-mcp-global-timeout', + kaos: testKaos.withCwd(tmp), + homedir: join(tmp, 'session'), + rpc: sessionRpc(), + config: { providers: {}, mcp: { toolTimeoutMs: 1 } }, + mcpConfig: { + servers: { + slowTool: { + transport: 'stdio', + command: process.execPath, + args: [slowToolStdioFixture], + env: { KIMI_TEST_MCP_TOOL_DELAY_MS: '300' }, + }, + }, + }, + }); + + try { + await session.mcp.waitForInitialLoad(); + const client = session.mcp.resolved('slowTool')?.client; + if (client === undefined) throw new Error('expected a connected client'); + await expect(client.callTool('slow_echo', { text: 'hi' })).rejects.toThrow(/timed out/i); + } finally { + await session.close(); + await rm(tmp, { recursive: true, force: true, maxRetries: 3, retryDelay: 10 }); + } + }, 15000); + it('waits for initial MCP startup before the first prompt reaches the model', async () => { const tmp = await mkdtemp(join(tmpdir(), 'kimi-session-mcp-prompt-')); const events: SessionRpcEvent[] = []; @@ -993,3 +1159,38 @@ function testProviderManager(): ProviderManager { }, }); } + +describe('MCP timeout env resolution', () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it('resolves the startup timeout as env > config > undefined', () => { + expect(resolveMcpStartupTimeoutMs()).toBeUndefined(); + expect(resolveMcpStartupTimeoutMs(5_000)).toBe(5_000); + + vi.stubEnv(MCP_STARTUP_TIMEOUT_ENV, 'abc'); + expect(resolveMcpStartupTimeoutMs(5_000)).toBe(5_000); + + vi.stubEnv(MCP_STARTUP_TIMEOUT_ENV, '7000'); + expect(resolveMcpStartupTimeoutMs(5_000)).toBe(7_000); + expect(resolveMcpStartupTimeoutMs()).toBe(7_000); + + vi.stubEnv(MCP_STARTUP_TIMEOUT_ENV, '2147483648'); + expect(resolveMcpStartupTimeoutMs(5_000)).toBe(5_000); + }); + + it('resolves the tool timeout as env > config > undefined', () => { + expect(resolveMcpToolTimeoutMs()).toBeUndefined(); + expect(resolveMcpToolTimeoutMs(60_000)).toBe(60_000); + + vi.stubEnv(MCP_TOOL_TIMEOUT_ENV, '0'); + expect(resolveMcpToolTimeoutMs(60_000)).toBe(60_000); + + vi.stubEnv(MCP_TOOL_TIMEOUT_ENV, '90000'); + expect(resolveMcpToolTimeoutMs(60_000)).toBe(90_000); + + vi.stubEnv(MCP_TOOL_TIMEOUT_ENV, '2147483648'); + expect(resolveMcpToolTimeoutMs(60_000)).toBe(60_000); + }); +}); diff --git a/packages/agent-core/test/mcp/fixtures/slow-tool-stdio-server.mjs b/packages/agent-core/test/mcp/fixtures/slow-tool-stdio-server.mjs new file mode 100644 index 0000000000..3ab144cca4 --- /dev/null +++ b/packages/agent-core/test/mcp/fixtures/slow-tool-stdio-server.mjs @@ -0,0 +1,23 @@ +import { setTimeout as sleep } from 'node:timers/promises'; + +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'; +import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'; +import { z } from 'zod'; + +const delayMs = Number.parseInt(process.env['KIMI_TEST_MCP_TOOL_DELAY_MS'] ?? '2000', 10); + +const server = new McpServer({ name: 'slow-tool-stdio', version: '0.0.1' }); + +server.registerTool( + 'slow_echo', + { + description: 'Echoes input text after a delay', + inputSchema: { text: z.string() }, + }, + async ({ text }) => { + await sleep(delayMs); + return { content: [{ type: 'text', text }] }; + }, +); + +await server.connect(new StdioServerTransport()); diff --git a/packages/klient/src/contract/global/plugins.ts b/packages/klient/src/contract/global/plugins.ts index ef0e2bacaa..320d4ef638 100644 --- a/packages/klient/src/contract/global/plugins.ts +++ b/packages/klient/src/contract/global/plugins.ts @@ -10,6 +10,7 @@ import { z } from 'zod'; import { noResult } from '../helpers.js'; +import { mcpServerConfigSchema } from '../mcp.js'; import type { ServiceContract } from '../types.js'; export const pluginDiagnosticSchema = z.object({ @@ -34,42 +35,6 @@ const pluginInterfaceSchema = z.object({ websiteURL: z.string().optional(), }); -const stringRecordSchema = z.record(z.string(), z.string()); - -const mcpServerCommonFields = { - enabled: z.boolean().optional(), - startupTimeoutMs: z.number().int().min(1).optional(), - toolTimeoutMs: z.number().int().min(1).optional(), - enabledTools: z.array(z.string()).optional(), - disabledTools: z.array(z.string()).optional(), -} as const; - -const mcpServerConfigSchema = z.discriminatedUnion('transport', [ - z.object({ - transport: z.literal('stdio'), - command: z.string().min(1), - args: z.array(z.string()).optional(), - env: stringRecordSchema.optional(), - cwd: z.string().optional(), - executor: z.enum(['local', 'kaos']).optional(), - ...mcpServerCommonFields, - }), - z.object({ - transport: z.literal('http'), - url: z.string().url(), - headers: stringRecordSchema.optional(), - bearerTokenEnvVar: z.string().min(1).optional(), - ...mcpServerCommonFields, - }), - z.object({ - transport: z.literal('sse'), - url: z.string().url(), - headers: stringRecordSchema.optional(), - bearerTokenEnvVar: z.string().min(1).optional(), - ...mcpServerCommonFields, - }), -]); - const hookDefSchema = z.object({ event: z.enum([ 'PreToolUse', diff --git a/packages/klient/src/contract/mcp.ts b/packages/klient/src/contract/mcp.ts new file mode 100644 index 0000000000..d9e673433f --- /dev/null +++ b/packages/klient/src/contract/mcp.ts @@ -0,0 +1,44 @@ +/** + * Shared MCP server wire schema for session creation and plugin manifests. + * Mirrors `agent-core-v2/agent/mcp/config-schema.ts`. + */ + +import { z } from 'zod'; + +const stringRecordSchema = z.record(z.string(), z.string()); + +export const mcpTimeoutMsSchema = z.number().int().min(1).max(2_147_483_647); + +const mcpServerCommonFields = { + enabled: z.boolean().optional(), + startupTimeoutMs: mcpTimeoutMsSchema.optional(), + toolTimeoutMs: mcpTimeoutMsSchema.optional(), + enabledTools: z.array(z.string()).optional(), + disabledTools: z.array(z.string()).optional(), +} as const; + +export const mcpServerConfigSchema = z.discriminatedUnion('transport', [ + z.object({ + transport: z.literal('stdio'), + command: z.string().min(1), + args: z.array(z.string()).optional(), + env: stringRecordSchema.optional(), + cwd: z.string().optional(), + executor: z.enum(['local', 'kaos']).optional(), + ...mcpServerCommonFields, + }), + z.object({ + transport: z.literal('http'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, + }), + z.object({ + transport: z.literal('sse'), + url: z.string().url(), + headers: stringRecordSchema.optional(), + bearerTokenEnvVar: z.string().min(1).optional(), + ...mcpServerCommonFields, + }), +]); diff --git a/packages/klient/src/contract/session/lifecycle.ts b/packages/klient/src/contract/session/lifecycle.ts index 355ceb401c..4fd9c3c503 100644 --- a/packages/klient/src/contract/session/lifecycle.ts +++ b/packages/klient/src/contract/session/lifecycle.ts @@ -9,49 +9,9 @@ import { z } from 'zod'; import { maybe, noResult } from '../helpers.js'; +import { mcpServerConfigSchema } from '../mcp.js'; import type { ServiceContract } from '../types.js'; -/** - * Mirror of `mcpServerConfigSchema` in `../global/plugins.js` — kept local - * because that fragment does not export its copy; keep the two in sync. - * Mirrors `agent-core-v2/agent/mcp/config-schema.ts`. - */ -const stringRecordSchema = z.record(z.string(), z.string()); - -const mcpServerCommonFields = { - enabled: z.boolean().optional(), - startupTimeoutMs: z.number().int().min(1).optional(), - toolTimeoutMs: z.number().int().min(1).optional(), - enabledTools: z.array(z.string()).optional(), - disabledTools: z.array(z.string()).optional(), -} as const; - -const mcpServerConfigSchema = z.discriminatedUnion('transport', [ - z.object({ - transport: z.literal('stdio'), - command: z.string().min(1), - args: z.array(z.string()).optional(), - env: stringRecordSchema.optional(), - cwd: z.string().optional(), - executor: z.enum(['local', 'kaos']).optional(), - ...mcpServerCommonFields, - }), - z.object({ - transport: z.literal('http'), - url: z.string().url(), - headers: stringRecordSchema.optional(), - bearerTokenEnvVar: z.string().min(1).optional(), - ...mcpServerCommonFields, - }), - z.object({ - transport: z.literal('sse'), - url: z.string().url(), - headers: stringRecordSchema.optional(), - bearerTokenEnvVar: z.string().min(1).optional(), - ...mcpServerCommonFields, - }), -]); - export const createSessionOptionsSchema = z.object({ sessionId: z.string().optional(), workDir: z.string(), diff --git a/packages/klient/test/contract.test.ts b/packages/klient/test/contract.test.ts new file mode 100644 index 0000000000..256a1b70fb --- /dev/null +++ b/packages/klient/test/contract.test.ts @@ -0,0 +1,50 @@ +/** + * Scenario: runtime validation at Klient wire-contract boundaries. + * + * Exercises the session-creation and plugin-manifest schemas directly with no + * external collaborators. Run with `pnpm --filter @moonshot-ai/klient exec + * vitest run test/contract.test.ts`. + */ + +import { describe, expect, it } from 'vitest'; + +import { pluginManifestSchema } from '../src/contract/global/plugins.js'; +import { createSessionOptionsSchema } from '../src/contract/session/lifecycle.js'; + +type McpTimeoutField = 'startupTimeoutMs' | 'toolTimeoutMs'; + +const timeoutCases = [ + { + surface: 'session creation', + parse: (field: McpTimeoutField, value: number) => + createSessionOptionsSchema.safeParse({ + workDir: '/tmp/example', + mcpServers: { + example: { transport: 'stdio', command: 'node', [field]: value }, + }, + }), + }, + { + surface: 'plugin manifests', + parse: (field: McpTimeoutField, value: number) => + pluginManifestSchema.safeParse({ + name: 'example', + mcpServers: { + example: { transport: 'stdio', command: 'node', [field]: value }, + }, + }), + }, +].flatMap(({ surface, parse }) => [ + { surface, field: 'startupTimeoutMs' as const, parse }, + { surface, field: 'toolTimeoutMs' as const, parse }, +]); + +describe('MCP timeout contract validation', () => { + it.each(timeoutCases)('accepts the maximum $field for $surface', ({ field, parse }) => { + expect(parse(field, 2_147_483_647).success).toBe(true); + }); + + it.each(timeoutCases)('rejects an above-maximum $field for $surface', ({ field, parse }) => { + expect(parse(field, 2_147_483_648).success).toBe(false); + }); +});