From 74364a2d1f89b56707cfd453a0b4510617aa6488 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 20:04:03 +0800 Subject: [PATCH 1/9] feat: add global default MCP server startup timeout config Add a `[mcp] startup_timeout_ms` config.toml section with a `KIMI_MCP_STARTUP_TIMEOUT_MS` env override as the global default MCP server connection (startup + tool discovery) timeout. Precedence: per-server `startupTimeoutMs` in mcp.json > env var > config.toml > built-in 30s default. --- .agents/skills/agent-core-dev/config.md | 1 + .changeset/mcp-global-startup-timeout.md | 6 ++ docs/en/configuration/config-files.md | 8 +++ docs/en/configuration/env-vars.md | 1 + docs/en/customization/mcp.md | 2 + docs/zh/configuration/config-files.md | 8 +++ docs/zh/configuration/env-vars.md | 1 + docs/zh/customization/mcp.md | 2 + .../src/agent/mcp/configSection.ts | 46 +++++++++++++ .../src/agent/mcp/connection-manager.ts | 11 ++- .../app/skillCatalog/builtin/mcp-config.md | 5 ++ .../src/session/mcp/sessionMcpService.ts | 14 +++- .../test/agent/mcp/connection-manager.test.ts | 35 ++++++++++ .../test/app/config/config.test.ts | 69 +++++++++++++++++++ 14 files changed, 205 insertions(+), 4 deletions(-) create mode 100644 .changeset/mcp-global-startup-timeout.md create mode 100644 packages/agent-core-v2/src/agent/mcp/configSection.ts diff --git a/.agents/skills/agent-core-dev/config.md b/.agents/skills/agent-core-dev/config.md index 84d66fa293..1728560c64 100644 --- a/.agents/skills/agent-core-dev/config.md +++ b/.agents/skills/agent-core-dev/config.md @@ -251,6 +251,7 @@ When `KIMI_MODEL_NAME` is set, the `provider` domain's `kimiModelEnvOverlay` (`s | `thinking` | `profile` | L4 | owner-owned | | `loopControl` | `loop` | L4 | owner-owned (read by `loop` + `profile`) | | `McpServerConfig` (type) | `mcp` | L5 | owner-owned (type only; not a registered section) | +| `mcp` | `mcp` | L5 | owner-owned | | `session` | `config` | L2 | in config | | `models` / `defaultModel` / `defaultProvider` | `kosong` | L1 | owner-owned (read by `ProviderManager`) | | `hooks` | `externalHooks` | L4 | owner-owned | diff --git a/.changeset/mcp-global-startup-timeout.md b/.changeset/mcp-global-startup-timeout.md new file mode 100644 index 0000000000..cb5f7dc00b --- /dev/null +++ b/.changeset/mcp-global-startup-timeout.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Add a global default MCP server connection timeout: set `[mcp] startup_timeout_ms` in `config.toml` or the `KIMI_MCP_STARTUP_TIMEOUT_MS` environment variable; a per-server `startupTimeoutMs` in `mcp.json` still takes precedence. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index b29cf7f2e4..c19611ef0c 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -244,6 +244,14 @@ 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. A per-server `startupTimeoutMs` in `mcp.json` always wins over this section and the environment variable; when neither is set, the default applies | + +`startup_timeout_ms` can be overridden by the `KIMI_MCP_STARTUP_TIMEOUT_MS` environment variable, which takes 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..4eae5df988 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,6 +128,7 @@ 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`) | Positive integer; 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..3405957721 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -57,6 +57,8 @@ Optional fields: | `enabledTools` | `string[]` | All | Tool allowlist | | `disabledTools` | `string[]` | All | Tool blocklist | +You do not have to set the connection timeout per server: `[mcp] startup_timeout_ms` in `config.toml` or the `KIMI_MCP_STARTUP_TIMEOUT_MS` environment variable changes the global default. Precedence is: per-server field > environment variable > `config.toml` > built-in default of `30000` milliseconds. 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..cb04360f30 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -244,6 +244,14 @@ display_name = "Kimi for Coding (custom)" `timeout_ms` 可被环境变量 `KIMI_SUBAGENT_TIMEOUT_MS` 覆盖,优先级高于配置文件。 +## `mcp` + +| 字段 | 类型 | 默认值 | 说明 | +| --- | --- | --- | --- | +| `startup_timeout_ms` | `integer` | `30000`(30 秒) | 所有 MCP server 的全局默认连接(启动 + 工具发现)超时(毫秒)。`mcp.json` 中单个 server 的 `startupTimeoutMs` 始终优先于本节与环境变量;都未设置时使用默认值 | + +`startup_timeout_ms` 可被环境变量 `KIMI_MCP_STARTUP_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..ec34cf0585 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,6 +128,7 @@ 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`) | 正整数;非法值被忽略 | | `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..660f866a77 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -57,6 +57,8 @@ MCP server 配置写在 `mcp.json` 中,分两层: | `enabledTools` | `string[]` | 全部 | 工具白名单 | | `disabledTools` | `string[]` | 全部 | 工具黑名单 | +连接超时的默认值不必逐个 server 设置:`config.toml` 的 `[mcp] startup_timeout_ms` 或环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` 可以调整全局默认值,优先级为 server 字段 > 环境变量 > `config.toml` > 内置默认 `30000` 毫秒。详见 [配置文件](../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/src/agent/mcp/configSection.ts b/packages/agent-core-v2/src/agent/mcp/configSection.ts new file mode 100644 index 0000000000..4d686816ed --- /dev/null +++ b/packages/agent-core-v2/src/agent/mcp/configSection.ts @@ -0,0 +1,46 @@ +/** + * `mcp` domain (L5) — `mcp` config-section schema and env binding. + * + * Owns the `[mcp]` configuration section (`startup_timeout_ms` on disk) + * together with the `KIMI_MCP_STARTUP_TIMEOUT_MS` env override, resolved as + * `env > config.toml > unset`. The value is the *global default* MCP server + * startup (connect + tool discovery) timeout: a per-server `startupTimeoutMs` + * in `mcp.json` always wins, and when neither is set the connection manager + * falls back to its built-in 30s default. While the env var is set, + * `stripEnvBoundFields` restores the env-free raw value before persistence, + * so the override never leaks into `config.toml`. Self-registered at module + * load via `registerConfigSection`, so the `config` domain never imports this + * domain's types. + */ + +import { z } from 'zod'; + +import { type EnvBindings, envBindings, stripEnvBoundFields } from '#/app/config/config'; +import { registerConfigSection } from '#/app/config/configSectionContributions'; + +export const MCP_SECTION = 'mcp'; + +export const McpSectionSchema = z.object({ + startupTimeoutMs: z.number().int().min(1).optional(), +}); + +export type McpSection = z.infer; + +export const MCP_STARTUP_TIMEOUT_ENV = 'KIMI_MCP_STARTUP_TIMEOUT_MS'; + +/** Parse the env override; anything but a positive integer is ignored. */ +function parseStartupTimeoutMsEnv(raw: string): number | undefined { + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; +} + +export const mcpEnvBindings: EnvBindings = envBindings(McpSectionSchema, { + startupTimeoutMs: { env: MCP_STARTUP_TIMEOUT_ENV, parse: parseStartupTimeoutMsEnv }, +}); + +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..fb20238070 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts @@ -63,6 +63,12 @@ export interface McpConnectionManagerOptions { readonly stdioCwd?: string; readonly oauthService?: McpOAuthService; 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; } export class McpConnectionManager { @@ -253,7 +259,10 @@ 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 { 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..5fd49e0210 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,11 @@ 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 the connection timeout for *every* server, +don't write `startupTimeoutMs` into each entry — the global default lives in +`config.toml` (`[mcp] startup_timeout_ms`) or the +`KIMI_MCP_STARTUP_TIMEOUT_MS` env var; per-server fields override it. + 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/sessionMcpService.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts index 6bb687df2b..eea4bf73f3 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts @@ -4,9 +4,12 @@ * 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 - * outright initial-load failure is logged (per-server failures are status - * entries). Bound at Session scope. + * agent creation can both await it), and reports connection telemetry. The + * manager's global default startup timeout comes from the `[mcp]` config + * section (`KIMI_MCP_STARTUP_TIMEOUT_MS` / `startup_timeout_ms`); a per-server + * `startupTimeoutMs` in `mcp.json` still wins. An outright initial-load + * failure is logged (per-server failures are status entries). Bound at + * Session scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -14,10 +17,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 +44,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(); } @@ -62,6 +68,8 @@ export class SessionMcpService extends Disposable implements ISessionMcpService log: this.log, oauthService, stdioCwd: this.workspace.workDir, + defaultStartupTimeoutMs: this.config.get(MCP_SECTION) + ?.startupTimeoutMs, }); this.mcpManager = manager; this._register({ dispose: () => void manager.shutdown() }); 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..9e85446539 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 @@ -381,6 +381,41 @@ describe('McpConnectionManager', () => { } }, 7000); + it('applies defaultStartupTimeoutMs when the server entry omits startupTimeoutMs', async () => { + const cm = new McpConnectionManager({ defaultStartupTimeoutMs: 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('lets a per-server startupTimeoutMs override defaultStartupTimeoutMs', async () => { + const cm = new McpConnectionManager({ defaultStartupTimeoutMs: 100 }); + try { + await cm.connectAll({ + slow: { + transport: 'stdio', + command: process.execPath, + args: [slowStdioFixture], + startupTimeoutMs: 10_000, + }, + }); + expect(cm.get('slow')?.status).toBe('connected'); + } 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, { 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 5bf59b6cfc..eef0a8f0fb 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -63,6 +63,12 @@ import { SUBAGENT_TIMEOUT_ENV, type SubagentConfig, } from '#/session/subagent/configSection'; +import '#/agent/mcp/configSection'; +import { + MCP_SECTION, + MCP_STARTUP_TIMEOUT_ENV, + 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'; @@ -1240,6 +1246,69 @@ 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('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('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 = {}; From 95261fb4caeaf1a48c71726c8d97de74a26a9440 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 20:15:52 +0800 Subject: [PATCH 2/9] feat: add global default MCP tool call timeout config Extend the `[mcp]` section with `tool_timeout_ms` and the `KIMI_MCP_TOOL_TIMEOUT_MS` env override as the global default for single MCP tool calls, mirroring the startup timeout: a per-server `toolTimeoutMs` in mcp.json still wins, and unset entries fall back to the SDK built-in 60s default. --- .changeset/mcp-global-startup-timeout.md | 2 +- docs/en/configuration/config-files.md | 3 +- docs/en/configuration/env-vars.md | 1 + docs/en/customization/mcp.md | 2 +- docs/zh/configuration/config-files.md | 3 +- docs/zh/configuration/env-vars.md | 1 + docs/zh/customization/mcp.md | 2 +- .../src/agent/mcp/configSection.ts | 32 ++++++++------- .../src/agent/mcp/connection-manager.ts | 8 +++- .../app/skillCatalog/builtin/mcp-config.md | 9 +++-- .../src/session/mcp/sessionMcpService.ts | 15 +++---- .../test/agent/mcp/connection-manager.test.ts | 39 +++++++++++++++++++ .../mcp/fixtures/slow-tool-stdio-server.mjs | 23 +++++++++++ .../agent-core-v2/test/agent/mcp/stubs.ts | 4 ++ .../test/app/config/config.test.ts | 15 +++++++ 15 files changed, 128 insertions(+), 31 deletions(-) create mode 100644 packages/agent-core-v2/test/agent/mcp/fixtures/slow-tool-stdio-server.mjs diff --git a/.changeset/mcp-global-startup-timeout.md b/.changeset/mcp-global-startup-timeout.md index cb5f7dc00b..29bbe8838b 100644 --- a/.changeset/mcp-global-startup-timeout.md +++ b/.changeset/mcp-global-startup-timeout.md @@ -3,4 +3,4 @@ "@moonshot-ai/kimi-code": patch --- -Add a global default MCP server connection timeout: set `[mcp] startup_timeout_ms` in `config.toml` or the `KIMI_MCP_STARTUP_TIMEOUT_MS` environment variable; a per-server `startupTimeoutMs` in `mcp.json` still takes precedence. +Add global default MCP server timeouts: set `[mcp] startup_timeout_ms` (connection) or `[mcp] tool_timeout_ms` (single tool call) in `config.toml`, or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` environment variables; per-server fields in `mcp.json` still take precedence. diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index c19611ef0c..e968a09fa3 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -249,8 +249,9 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent | Field | Type | Default | Description | | --- | --- | --- | --- | | `startup_timeout_ms` | `integer` | `30000` (30 seconds) | Global default connection (startup + tool discovery) timeout in milliseconds for all MCP servers. 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. 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` can be overridden by the `KIMI_MCP_STARTUP_TIMEOUT_MS` environment variable, which takes higher priority than `config.toml`. See [MCP](../customization/mcp.md) for the full MCP server configuration. +`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` diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index 4eae5df988..e057c871a7 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -129,6 +129,7 @@ Switches that control the behavior of subsystems such as telemetry, background t | `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`) | Positive integer; 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`) | Positive integer; 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 3405957721..5033fdae16 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -57,7 +57,7 @@ Optional fields: | `enabledTools` | `string[]` | All | Tool allowlist | | `disabledTools` | `string[]` | All | Tool blocklist | -You do not have to set the connection timeout per server: `[mcp] startup_timeout_ms` in `config.toml` or the `KIMI_MCP_STARTUP_TIMEOUT_MS` environment variable changes the global default. Precedence is: per-server field > environment variable > `config.toml` > built-in default of `30000` milliseconds. See [Configuration files](../configuration/config-files.md#mcp). +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. diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index cb04360f30..02d38e6b1b 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -249,8 +249,9 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | | `startup_timeout_ms` | `integer` | `30000`(30 秒) | 所有 MCP server 的全局默认连接(启动 + 工具发现)超时(毫秒)。`mcp.json` 中单个 server 的 `startupTimeoutMs` 始终优先于本节与环境变量;都未设置时使用默认值 | +| `tool_timeout_ms` | `integer` | `60000`(60 秒) | 所有 MCP server 的全局默认单次工具调用超时(毫秒)。`mcp.json` 中单个 server 的 `toolTimeoutMs` 始终优先于本节与环境变量;都未设置时使用客户端内置默认值 | -`startup_timeout_ms` 可被环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` 覆盖,优先级高于配置文件。MCP server 的完整配置方式见 [MCP](../customization/mcp.md)。 +`startup_timeout_ms` 和 `tool_timeout_ms` 可分别被环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` 和 `KIMI_MCP_TOOL_TIMEOUT_MS` 覆盖,优先级高于配置文件。MCP server 的完整配置方式见 [MCP](../customization/mcp.md)。 ## `tools` diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index ec34cf0585..6c61706992 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -129,6 +129,7 @@ kimi | `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`) | 正整数;非法值被忽略 | +| `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | 正整数;非法值被忽略 | | `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 660f866a77..b8027df563 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -57,7 +57,7 @@ MCP server 配置写在 `mcp.json` 中,分两层: | `enabledTools` | `string[]` | 全部 | 工具白名单 | | `disabledTools` | `string[]` | 全部 | 工具黑名单 | -连接超时的默认值不必逐个 server 设置:`config.toml` 的 `[mcp] startup_timeout_ms` 或环境变量 `KIMI_MCP_STARTUP_TIMEOUT_MS` 可以调整全局默认值,优先级为 server 字段 > 环境变量 > `config.toml` > 内置默认 `30000` 毫秒。详见 [配置文件](../configuration/config-files.md#mcp)。 +连接超时和单次工具调用超时的默认值都不必逐个 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 ` 完成浏览器授权。 diff --git a/packages/agent-core-v2/src/agent/mcp/configSection.ts b/packages/agent-core-v2/src/agent/mcp/configSection.ts index 4d686816ed..7cd0509747 100644 --- a/packages/agent-core-v2/src/agent/mcp/configSection.ts +++ b/packages/agent-core-v2/src/agent/mcp/configSection.ts @@ -1,16 +1,17 @@ /** - * `mcp` domain (L5) — `mcp` config-section schema and env binding. + * `mcp` domain (L5) — `mcp` config-section schema and env bindings. * - * Owns the `[mcp]` configuration section (`startup_timeout_ms` on disk) - * together with the `KIMI_MCP_STARTUP_TIMEOUT_MS` env override, resolved as - * `env > config.toml > unset`. The value is the *global default* MCP server - * startup (connect + tool discovery) timeout: a per-server `startupTimeoutMs` - * in `mcp.json` always wins, and when neither is set the connection manager - * falls back to its built-in 30s default. While the env var is set, - * `stripEnvBoundFields` restores the env-free raw value before persistence, - * so the override never leaks into `config.toml`. Self-registered at module - * load via `registerConfigSection`, so the `config` domain never imports this - * domain's types. + * Owns the `[mcp]` configuration section (`startup_timeout_ms` / + * `tool_timeout_ms` on disk) together with the `KIMI_MCP_STARTUP_TIMEOUT_MS` + * / `KIMI_MCP_TOOL_TIMEOUT_MS` env overrides, resolved as + * `env > config.toml > unset`. The values are the *global default* MCP server + * startup (connect + tool discovery) and single tool-call timeouts: the + * per-server `startupTimeoutMs` / `toolTimeoutMs` in `mcp.json` always win, + * and when neither is set the connection manager falls back to its built-in + * defaults. While an env var is set, `stripEnvBoundFields` restores the + * env-free raw value before persistence, so the override never leaks into + * `config.toml`. Self-registered at module load via `registerConfigSection`, + * so the `config` domain never imports this domain's types. */ import { z } from 'zod'; @@ -22,20 +23,23 @@ export const MCP_SECTION = 'mcp'; export const McpSectionSchema = z.object({ startupTimeoutMs: z.number().int().min(1).optional(), + toolTimeoutMs: z.number().int().min(1).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'; -/** Parse the env override; anything but a positive integer is ignored. */ -function parseStartupTimeoutMsEnv(raw: string): number | undefined { +/** Parse an env override; anything but a positive integer is ignored. */ +function parseTimeoutMsEnv(raw: string): number | undefined { const parsed = Number(raw); return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; } export const mcpEnvBindings: EnvBindings = envBindings(McpSectionSchema, { - startupTimeoutMs: { env: MCP_STARTUP_TIMEOUT_ENV, parse: parseStartupTimeoutMsEnv }, + startupTimeoutMs: { env: MCP_STARTUP_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, + toolTimeoutMs: { env: MCP_TOOL_TIMEOUT_ENV, parse: parseTimeoutMsEnv }, }); export const stripMcpEnv = stripEnvBoundFields(mcpEnvBindings); 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 fb20238070..43aa99f363 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts @@ -69,6 +69,12 @@ export interface McpConnectionManagerOptions { * 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; } export class McpConnectionManager { @@ -333,7 +339,7 @@ export class McpConnectionManager { } private async createClient(config: McpServerConfig, name: string): Promise { - const toolCallTimeoutMs = config.toolTimeoutMs; + const toolCallTimeoutMs = config.toolTimeoutMs ?? this.options.defaultToolTimeoutMs; if (config.transport === 'stdio') { return new StdioMcpClient(config, { toolCallTimeoutMs, defaultCwd: this.options.stdioCwd }); } 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 5fd49e0210..0aa35217b0 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,10 +85,11 @@ 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 the connection timeout for *every* server, -don't write `startupTimeoutMs` into each entry — the global default lives in -`config.toml` (`[mcp] startup_timeout_ms`) or the -`KIMI_MCP_STARTUP_TIMEOUT_MS` env var; per-server fields override it. +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. 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 diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts index eea4bf73f3..7f06cc8e73 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts @@ -5,11 +5,11 @@ * 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. The - * manager's global default startup timeout comes from the `[mcp]` config - * section (`KIMI_MCP_STARTUP_TIMEOUT_MS` / `startup_timeout_ms`); a per-server - * `startupTimeoutMs` in `mcp.json` still wins. An outright initial-load - * failure is logged (per-server failures are status entries). Bound at - * Session scope. + * manager's global default startup / tool-call timeouts come from the `[mcp]` + * config section (`KIMI_MCP_STARTUP_TIMEOUT_MS` / `startup_timeout_ms` and + * `KIMI_MCP_TOOL_TIMEOUT_MS` / `tool_timeout_ms`); the per-server fields in + * `mcp.json` still win. An outright initial-load failure is logged + * (per-server failures are status entries). Bound at Session scope. */ import { InstantiationType } from '#/_base/di/extensions'; @@ -64,12 +64,13 @@ export class SessionMcpService extends Disposable implements ISessionMcpService const oauthService = new McpOAuthService({ store: createMcpOAuthStore(this.atomicDocs), }); + const mcpSection = this.config.get(MCP_SECTION); const manager = new McpConnectionManager({ log: this.log, oauthService, stdioCwd: this.workspace.workDir, - defaultStartupTimeoutMs: this.config.get(MCP_SECTION) - ?.startupTimeoutMs, + defaultStartupTimeoutMs: mcpSection?.startupTimeoutMs, + defaultToolTimeoutMs: mcpSection?.toolTimeoutMs, }); this.mcpManager = manager; this._register({ dispose: () => void manager.shutdown() }); 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 9e85446539..9597a97a9f 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 @@ -28,6 +28,7 @@ import { cwdStdioFixture, hangingListStdioFixture, slowStdioFixture, + slowToolStdioFixture, stderrThenExitFixture, stdioFixture, } from './stubs'; @@ -416,6 +417,44 @@ describe('McpConnectionManager', () => { } }, 20000); + it('applies defaultToolTimeoutMs when the server entry omits toolTimeoutMs', async () => { + const cm = new McpConnectionManager({ defaultToolTimeoutMs: 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 defaultToolTimeoutMs', async () => { + const cm = new McpConnectionManager({ defaultToolTimeoutMs: 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, { 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 eef0a8f0fb..c4b291e428 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -67,6 +67,7 @@ import '#/agent/mcp/configSection'; import { MCP_SECTION, MCP_STARTUP_TIMEOUT_ENV, + MCP_TOOL_TIMEOUT_ENV, type McpSection, } from '#/agent/mcp/configSection'; import { ILogService } from '#/_base/log/log'; @@ -1291,6 +1292,20 @@ describe('mcp config section', () => { 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'); From 70777eae19f940b96986bbc93eba249a92c1629c Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 20:19:11 +0800 Subject: [PATCH 3/9] chore: shorten the mcp timeouts changeset --- .changeset/mcp-global-startup-timeout.md | 6 ------ .changeset/mcp-global-timeouts.md | 6 ++++++ 2 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 .changeset/mcp-global-startup-timeout.md create mode 100644 .changeset/mcp-global-timeouts.md diff --git a/.changeset/mcp-global-startup-timeout.md b/.changeset/mcp-global-startup-timeout.md deleted file mode 100644 index 29bbe8838b..0000000000 --- a/.changeset/mcp-global-startup-timeout.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"@moonshot-ai/agent-core-v2": patch -"@moonshot-ai/kimi-code": patch ---- - -Add global default MCP server timeouts: set `[mcp] startup_timeout_ms` (connection) or `[mcp] tool_timeout_ms` (single tool call) in `config.toml`, or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` environment variables; per-server fields in `mcp.json` still take precedence. diff --git a/.changeset/mcp-global-timeouts.md b/.changeset/mcp-global-timeouts.md new file mode 100644 index 0000000000..b72da33ac2 --- /dev/null +++ b/.changeset/mcp-global-timeouts.md @@ -0,0 +1,6 @@ +--- +"@moonshot-ai/agent-core-v2": patch +"@moonshot-ai/kimi-code": patch +--- + +Add global default MCP server timeouts: `[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms` in `config.toml`, or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` env vars. From 98d2e538ffd714002cd9e6da6708031d7b787b9b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 20:25:57 +0800 Subject: [PATCH 4/9] feat(agent-core): add global default MCP server timeout configs Port the `[mcp]` section (`startup_timeout_ms` / `tool_timeout_ms`) and the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` env overrides to agent-core (v1), mirroring the v2 semantics: per-server fields in mcp.json > env vars > config.toml > built-in defaults. The v1 TOML loader gains explicit `mcp` read/write mappings, and both connection-manager construction sites (Session, testGlobalMcpServer RPC) pass the resolved defaults through. --- .changeset/mcp-global-timeouts.md | 1 + packages/agent-core/src/config/schema.ts | 22 ++++ packages/agent-core/src/config/toml.ts | 12 ++ .../agent-core/src/mcp/connection-manager.ts | 60 ++++++++- packages/agent-core/src/rpc/core-impl.ts | 4 + packages/agent-core/src/session/index.ts | 4 + .../src/skill/builtin/mcp-config.md | 6 + .../agent-core/test/config/configs.test.ts | 5 + .../test/mcp/connection-manager.test.ts | 117 +++++++++++++++++- .../mcp/fixtures/slow-tool-stdio-server.mjs | 23 ++++ 10 files changed, 250 insertions(+), 4 deletions(-) create mode 100644 packages/agent-core/test/mcp/fixtures/slow-tool-stdio-server.mjs diff --git a/.changeset/mcp-global-timeouts.md b/.changeset/mcp-global-timeouts.md index b72da33ac2..59cf70cf89 100644 --- a/.changeset/mcp-global-timeouts.md +++ b/.changeset/mcp-global-timeouts.md @@ -1,4 +1,5 @@ --- +"@moonshot-ai/agent-core": patch "@moonshot-ai/agent-core-v2": patch "@moonshot-ai/kimi-code": patch --- diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 16c54fd558..3629511be2 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -172,6 +172,25 @@ export const SubagentConfigSchema = z.object({ export type SubagentConfig = z.infer; +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: z.number().int().min(1).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: z.number().int().min(1).optional(), +}); + +export type McpConfig = z.infer; + export const ImageConfigSchema = z.object({ /** * Longest-edge ceiling (px) applied when compressing images for the model. @@ -319,6 +338,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 +355,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 +384,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/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 258c191073..cd34ed9a7d 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -40,6 +40,47 @@ 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 a positive integer is ignored. */ +function parseTimeoutMsEnv(raw: string): number | undefined { + const parsed = Number(raw); + return Number.isInteger(parsed) && parsed >= 1 ? 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 +101,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,7 +320,10 @@ 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 { @@ -344,7 +400,7 @@ export class McpConnectionManager { } private createClient(config: McpServerConfig, name: string): RuntimeMcpClient { - const toolCallTimeoutMs = config.toolTimeoutMs; + const toolCallTimeoutMs = config.toolTimeoutMs ?? this.options.defaultToolTimeoutMs; if (config.transport === 'stdio') { return new StdioMcpClient(config, { toolCallTimeoutMs, defaultCwd: this.options.stdioCwd }); } 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..0aa35217b0 100644 --- a/packages/agent-core/src/skill/builtin/mcp-config.md +++ b/packages/agent-core/src/skill/builtin/mcp-config.md @@ -85,6 +85,12 @@ 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. + 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..e86bac82c1 100644 --- a/packages/agent-core/test/config/configs.test.ts +++ b/packages/agent-core/test/config/configs.test.ts @@ -110,6 +110,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 +197,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([ { diff --git a/packages/agent-core/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 504a35b1d8..7ea4d8932b 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -7,7 +7,7 @@ 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'; @@ -23,7 +23,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'; @@ -367,6 +374,83 @@ 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('lets a per-server startupTimeoutMs override defaultStartupTimeoutMs', 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], + startupTimeoutMs: 10_000, + }, + }); + expect(cm.get('slow')?.status).toBe('connected'); + } finally { + await cm.shutdown(); + } + }, 20000); + + 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, { @@ -993,3 +1077,32 @@ 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); + }); + + 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); + }); +}); 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()); From c38ae99aa6f8a68f213a7d1758cc561f3757308e Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 22:06:29 +0800 Subject: [PATCH 5/9] fix(agent-core): validate and apply MCP timeout defaults --- .changeset/mcp-global-timeouts.md | 2 - docs/en/configuration/config-files.md | 4 +- docs/en/configuration/env-vars.md | 4 +- docs/en/customization/mcp.md | 4 +- docs/zh/configuration/config-files.md | 4 +- docs/zh/configuration/env-vars.md | 4 +- docs/zh/customization/mcp.md | 4 +- .../src/agent/mcp/config-schema.ts | 7 +- .../src/agent/mcp/configSection.ts | 26 ++--- .../src/agent/mcp/connection-manager.ts | 13 +-- .../app/skillCatalog/builtin/mcp-config.md | 3 +- .../src/session/mcp/sessionMcp.ts | 20 +--- .../src/session/mcp/sessionMcpService.ts | 35 +++--- .../test/agent/mcp/config-loader.test.ts | 49 ++++++++ .../test/agent/mcp/connection-manager.test.ts | 109 +++++++++++++++++- .../test/app/config/config.test.ts | 38 ++++++ packages/agent-core/src/config/schema.ts | 11 +- .../agent-core/src/mcp/connection-manager.ts | 6 +- .../src/skill/builtin/mcp-config.md | 3 +- .../agent-core/test/config/configs.test.ts | 47 ++++++++ .../test/mcp/connection-manager.test.ts | 46 ++++++++ .../klient/src/contract/global/plugins.ts | 37 +----- packages/klient/src/contract/mcp.ts | 44 +++++++ .../klient/src/contract/session/lifecycle.ts | 42 +------ packages/klient/test/contract.test.ts | 50 ++++++++ 25 files changed, 451 insertions(+), 161 deletions(-) create mode 100644 packages/klient/src/contract/mcp.ts create mode 100644 packages/klient/test/contract.test.ts diff --git a/.changeset/mcp-global-timeouts.md b/.changeset/mcp-global-timeouts.md index 59cf70cf89..135f044864 100644 --- a/.changeset/mcp-global-timeouts.md +++ b/.changeset/mcp-global-timeouts.md @@ -1,6 +1,4 @@ --- -"@moonshot-ai/agent-core": patch -"@moonshot-ai/agent-core-v2": patch "@moonshot-ai/kimi-code": patch --- diff --git a/docs/en/configuration/config-files.md b/docs/en/configuration/config-files.md index e968a09fa3..f37a841f41 100644 --- a/docs/en/configuration/config-files.md +++ b/docs/en/configuration/config-files.md @@ -248,8 +248,8 @@ In print mode (`kimi -p ""`), Kimi Code stays alive after the main agent | Field | Type | Default | Description | | --- | --- | --- | --- | -| `startup_timeout_ms` | `integer` | `30000` (30 seconds) | Global default connection (startup + tool discovery) timeout in milliseconds for all MCP servers. 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. 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` | `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. diff --git a/docs/en/configuration/env-vars.md b/docs/en/configuration/env-vars.md index e057c871a7..7669fd8544 100644 --- a/docs/en/configuration/env-vars.md +++ b/docs/en/configuration/env-vars.md @@ -128,8 +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`) | Positive integer; 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`) | Positive integer; invalid values are ignored | +| `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 5033fdae16..2768220074 100644 --- a/docs/en/customization/mcp.md +++ b/docs/en/customization/mcp.md @@ -52,8 +52,8 @@ 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 | diff --git a/docs/zh/configuration/config-files.md b/docs/zh/configuration/config-files.md index 02d38e6b1b..d18288b9c8 100644 --- a/docs/zh/configuration/config-files.md +++ b/docs/zh/configuration/config-files.md @@ -248,8 +248,8 @@ display_name = "Kimi for Coding (custom)" | 字段 | 类型 | 默认值 | 说明 | | --- | --- | --- | --- | -| `startup_timeout_ms` | `integer` | `30000`(30 秒) | 所有 MCP server 的全局默认连接(启动 + 工具发现)超时(毫秒)。`mcp.json` 中单个 server 的 `startupTimeoutMs` 始终优先于本节与环境变量;都未设置时使用默认值 | -| `tool_timeout_ms` | `integer` | `60000`(60 秒) | 所有 MCP server 的全局默认单次工具调用超时(毫秒)。`mcp.json` 中单个 server 的 `toolTimeoutMs` 始终优先于本节与环境变量;都未设置时使用客户端内置默认值 | +| `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)。 diff --git a/docs/zh/configuration/env-vars.md b/docs/zh/configuration/env-vars.md index 6c61706992..cb3001df7a 100644 --- a/docs/zh/configuration/env-vars.md +++ b/docs/zh/configuration/env-vars.md @@ -128,8 +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`) | 正整数;非法值被忽略 | -| `KIMI_MCP_TOOL_TIMEOUT_MS` | 所有 MCP server 的全局默认单次工具调用超时(毫秒);优先级高于 `config.toml` 的 `[mcp] tool_timeout_ms`,但低于 `mcp.json` 中单个 server 的 `toolTimeoutMs`(默认 `60000`) | 正整数;非法值被忽略 | +| `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 b8027df563..93cb8031c9 100644 --- a/docs/zh/customization/mcp.md +++ b/docs/zh/customization/mcp.md @@ -52,8 +52,8 @@ 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[]` | 全部 | 工具黑名单 | 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 index 7cd0509747..82a624b7df 100644 --- a/packages/agent-core-v2/src/agent/mcp/configSection.ts +++ b/packages/agent-core-v2/src/agent/mcp/configSection.ts @@ -1,29 +1,22 @@ /** - * `mcp` domain (L5) — `mcp` config-section schema and env bindings. + * `mcp` domain (L5) — registers MCP timeout preferences into `config`. * - * Owns the `[mcp]` configuration section (`startup_timeout_ms` / - * `tool_timeout_ms` on disk) together with the `KIMI_MCP_STARTUP_TIMEOUT_MS` - * / `KIMI_MCP_TOOL_TIMEOUT_MS` env overrides, resolved as - * `env > config.toml > unset`. The values are the *global default* MCP server - * startup (connect + tool discovery) and single tool-call timeouts: the - * per-server `startupTimeoutMs` / `toolTimeoutMs` in `mcp.json` always win, - * and when neither is set the connection manager falls back to its built-in - * defaults. While an env var is set, `stripEnvBoundFields` restores the - * env-free raw value before persistence, so the override never leaks into - * `config.toml`. Self-registered at module load via `registerConfigSection`, - * so the `config` domain never imports this domain's types. + * 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: z.number().int().min(1).optional(), - toolTimeoutMs: z.number().int().min(1).optional(), + startupTimeoutMs: McpTimeoutMsSchema.optional(), + toolTimeoutMs: McpTimeoutMsSchema.optional(), }); export type McpSection = z.infer; @@ -31,10 +24,11 @@ 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'; -/** Parse an env override; anything but a positive integer is ignored. */ function parseTimeoutMsEnv(raw: string): number | undefined { const parsed = Number(raw); - return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; + return Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_MCP_TIMEOUT_MS + ? parsed + : undefined; } export const mcpEnvBindings: EnvBindings = envBindings(McpSectionSchema, { 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 43aa99f363..405aec0a75 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'; @@ -63,17 +64,7 @@ export interface McpConnectionManagerOptions { readonly stdioCwd?: string; readonly oauthService?: McpOAuthService; 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; } 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 0aa35217b0..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 @@ -89,7 +89,8 @@ 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. +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 diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts b/packages/agent-core-v2/src/session/mcp/sessionMcp.ts index fb707d3255..f82f2552ee 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'; @@ -17,17 +12,8 @@ import type { McpServerConfig } from '#/agent/mcp/config-schema'; export interface ISessionMcpService { readonly _serviceBrand: undefined; - /** - * 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. - */ ensureMcpReady(callerServers?: Readonly>): Promise; - /** The session's shared connection manager (built lazily, cached). */ 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 7f06cc8e73..068f985ec2 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts @@ -1,20 +1,16 @@ /** - * `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. The - * manager's global default startup / tool-call timeouts come from the `[mcp]` - * config section (`KIMI_MCP_STARTUP_TIMEOUT_MS` / `startup_timeout_ms` and - * `KIMI_MCP_TOOL_TIMEOUT_MS` / `tool_timeout_ms`); the per-server fields in - * `mcp.json` still win. An outright initial-load failure is logged - * (per-server failures are status entries). Bound at Session scope. + * Owns the shared session connection manager and initial connection lifecycle. + * Resolves server sources through `bootstrap`, `workspace`, and `plugin`, + * timeout preferences through `config`, OAuth storage through `persistence`, + * and reports through `log` and `telemetry`. Bound at Session scope. */ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; +import { Error2, ErrorCodes } from '#/errors'; import { McpConnectionManager } from '#/agent/mcp/connection-manager'; import type { McpServerConfig } from '#/agent/mcp/config-schema'; import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; @@ -51,8 +47,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(callerServers).catch((error: unknown) => { this.log.error('mcp initial load failed', { error }); }); this.mcpInitialLoad = initialLoad; @@ -60,7 +55,19 @@ export class SessionMcpService extends Disposable implements ISessionMcpService } connectionManager(): McpConnectionManager { - if (this.mcpManager !== undefined) return this.mcpManager; + if (this.mcpManager === undefined) { + throw new Error2( + ErrorCodes.MCP_STARTUP_FAILED, + 'MCP connection manager is not ready; await ensureMcpReady() first', + ); + } + return this.mcpManager; + } + + private async initializeMcp( + callerServers?: Readonly>, + ): Promise { + await this.config.ready; const oauthService = new McpOAuthService({ store: createMcpOAuthStore(this.atomicDocs), }); @@ -74,7 +81,7 @@ export class SessionMcpService extends Disposable implements ISessionMcpService }); this.mcpManager = manager; this._register({ dispose: () => void manager.shutdown() }); - return manager; + await this.connectMcpServers(manager, callerServers); } private async connectMcpServers( 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 9597a97a9f..7d567d92e6 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,12 @@ +/** + * 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 only external 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'; @@ -15,12 +24,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 } 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, @@ -755,3 +777,86 @@ 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('keeps the connection manager unavailable while config remains unready', async () => { + let resolveConfigReady!: () => void; + const ready = new Promise((resolve) => { + resolveConfigReady = resolve; + }); + const service = createSessionMcpService(ready); + const initialLoad = service.ensureMcpReady(); + try { + await Promise.resolve(); + expect(() => service.connectionManager()).toThrow(/not ready/i); + + resolveConfigReady(); + await initialLoad; + manager = service.connectionManager(); + expect(manager.list()).toEqual([]); + } finally { + resolveConfigReady(); + await initialLoad; + } + }); + + 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/app/config/config.test.ts b/packages/agent-core-v2/test/app/config/config.test.ts index c4b291e428..bf668f96eb 100644 --- a/packages/agent-core-v2/test/app/config/config.test.ts +++ b/packages/agent-core-v2/test/app/config/config.test.ts @@ -68,6 +68,7 @@ import { MCP_SECTION, MCP_STARTUP_TIMEOUT_ENV, MCP_TOOL_TIMEOUT_ENV, + McpSectionSchema, type McpSection, } from '#/agent/mcp/configSection'; import { ILogService } from '#/_base/log/log'; @@ -1281,6 +1282,43 @@ describe('mcp config section', () => { 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'); diff --git a/packages/agent-core/src/config/schema.ts b/packages/agent-core/src/config/schema.ts index 3629511be2..8b7baea6ca 100644 --- a/packages/agent-core/src/config/schema.ts +++ b/packages/agent-core/src/config/schema.ts @@ -172,6 +172,9 @@ 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 @@ -179,14 +182,14 @@ export const McpConfigSchema = z.object({ * KIMI_MCP_STARTUP_TIMEOUT_MS env var both win over this value. Defaults * to 30s when unset. */ - startupTimeoutMs: z.number().int().min(1).optional(), + 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: z.number().int().min(1).optional(), + toolTimeoutMs: McpTimeoutMsSchema.optional(), }); export type McpConfig = z.infer; @@ -252,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; diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index cd34ed9a7d..0c8701bb4b 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'; @@ -46,7 +46,9 @@ export const MCP_TOOL_TIMEOUT_ENV = 'KIMI_MCP_TOOL_TIMEOUT_MS'; /** Parse an env override; anything but a positive integer is ignored. */ function parseTimeoutMsEnv(raw: string): number | undefined { const parsed = Number(raw); - return Number.isInteger(parsed) && parsed >= 1 ? parsed : undefined; + return Number.isInteger(parsed) && parsed >= 1 && parsed <= MAX_MCP_TIMEOUT_MS + ? parsed + : undefined; } /** diff --git a/packages/agent-core/src/skill/builtin/mcp-config.md b/packages/agent-core/src/skill/builtin/mcp-config.md index 0aa35217b0..fc8a02e77b 100644 --- a/packages/agent-core/src/skill/builtin/mcp-config.md +++ b/packages/agent-core/src/skill/builtin/mcp-config.md @@ -89,7 +89,8 @@ 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. +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 diff --git a/packages/agent-core/test/config/configs.test.ts b/packages/agent-core/test/config/configs.test.ts index e86bac82c1..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, @@ -505,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 7ea4d8932b..83f00ebb37 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -1,3 +1,11 @@ +/** + * 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. 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'; @@ -41,6 +49,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 = { @@ -930,6 +939,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[] = []; @@ -1093,6 +1133,9 @@ describe('MCP timeout env resolution', () => { 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', () => { @@ -1104,5 +1147,8 @@ describe('MCP timeout env resolution', () => { 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/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); + }); +}); From 5fe32b3d2c322db54f8254449a8b419326bb735b Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Wed, 22 Jul 2026 23:46:33 +0800 Subject: [PATCH 6/9] fix: propagate MCP startup timeout to SDK requests --- .../src/agent/mcp/client-http.ts | 13 ++- .../src/agent/mcp/client-shared.ts | 6 +- .../agent-core-v2/src/agent/mcp/client-sse.ts | 13 ++- .../src/agent/mcp/client-stdio.ts | 13 ++- .../src/agent/mcp/connection-manager.ts | 16 +++- .../test/agent/mcp/connection-manager.test.ts | 79 ++++++++++++++----- packages/agent-core/src/mcp/client-http.ts | 13 ++- packages/agent-core/src/mcp/client-shared.ts | 14 ++-- packages/agent-core/src/mcp/client-sse.ts | 13 ++- packages/agent-core/src/mcp/client-stdio.ts | 13 ++- .../agent-core/src/mcp/connection-manager.ts | 16 +++- .../test/mcp/connection-manager.test.ts | 78 +++++++++++++----- 12 files changed, 223 insertions(+), 64 deletions(-) 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/connection-manager.ts b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts index 405aec0a75..0efc766689 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts @@ -263,7 +263,7 @@ export class McpConnectionManager { 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( @@ -329,19 +329,29 @@ export class McpConnectionManager { return entry.attemptId; } - private async createClient(config: McpServerConfig, name: string): Promise { + private async createClient( + config: McpServerConfig, + name: string, + startupTimeoutMs: number, + ): Promise { 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: 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/test/agent/mcp/connection-manager.test.ts b/packages/agent-core-v2/test/agent/mcp/connection-manager.test.ts index 7d567d92e6..cce4b1c62d 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 @@ -2,7 +2,8 @@ * 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 only external boundary. + * 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`. */ @@ -17,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 { @@ -24,7 +26,7 @@ import type { OAuthTokens, } from '@modelcontextprotocol/sdk/shared/auth.js'; import { z } from 'zod'; -import { afterEach, beforeEach, 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'; @@ -422,22 +424,63 @@ describe('McpConnectionManager', () => { } }, 15000); - it('lets a per-server startupTimeoutMs override defaultStartupTimeoutMs', async () => { - const cm = new McpConnectionManager({ defaultStartupTimeoutMs: 100 }); - try { - await cm.connectAll({ - slow: { - transport: 'stdio', - command: process.execPath, - args: [slowStdioFixture], - startupTimeoutMs: 10_000, - }, - }); - expect(cm.get('slow')?.status).toBe('connected'); - } finally { - await cm.shutdown(); - } - }, 20000); + 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 }); 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 0c8701bb4b..6c492fefdd 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -329,7 +329,7 @@ export class McpConnectionManager { 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( @@ -401,19 +401,29 @@ export class McpConnectionManager { return entry.attemptId; } - private createClient(config: McpServerConfig, name: string): RuntimeMcpClient { + 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/test/mcp/connection-manager.test.ts b/packages/agent-core/test/mcp/connection-manager.test.ts index 83f00ebb37..dabd666011 100644 --- a/packages/agent-core/test/mcp/connection-manager.test.ts +++ b/packages/agent-core/test/mcp/connection-manager.test.ts @@ -2,7 +2,8 @@ * 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. Run with `pnpm --filter + * 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`. */ @@ -21,6 +22,7 @@ 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 { @@ -402,23 +404,63 @@ describe('McpConnectionManager', () => { } }, 15000); - it('lets a per-server startupTimeoutMs override defaultStartupTimeoutMs', 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], - startupTimeoutMs: 10_000, - }, - }); - expect(cm.get('slow')?.status).toBe('connected'); - } finally { - await cm.shutdown(); - } - }, 20000); + 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 }); From 6cdba0498a28fe771ed5a4dfd5925f60d2136ce8 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 23 Jul 2026 11:59:42 +0800 Subject: [PATCH 7/9] refactor(agent-core-v2): resolve MCP default timeouts at connect time Keep the session connection manager synchronously lazy instead of gating its existence on config readiness: resolveDefaultTimeouts is read from the mcp config section at each (re)connect, so AgentMcpService's eager construction stays unconditionally safe and reconnects pick up changed preferences. The initial connect still awaits config.ready for a deterministic snapshot. Also restore the ISessionMcpService method docs, bump the changeset to minor, and fix the v1 env-parse comment. --- .changeset/mcp-global-timeouts.md | 2 +- .../src/agent/mcp/connection-manager.ts | 18 +++++-- .../src/session/mcp/sessionMcp.ts | 15 ++++++ .../src/session/mcp/sessionMcpService.ts | 48 +++++++++-------- .../test/agent/mcp/connection-manager.test.ts | 52 +++++++++++++------ .../agent-core/src/mcp/connection-manager.ts | 2 +- 6 files changed, 93 insertions(+), 44 deletions(-) diff --git a/.changeset/mcp-global-timeouts.md b/.changeset/mcp-global-timeouts.md index 135f044864..398234847c 100644 --- a/.changeset/mcp-global-timeouts.md +++ b/.changeset/mcp-global-timeouts.md @@ -1,5 +1,5 @@ --- -"@moonshot-ai/kimi-code": patch +"@moonshot-ai/kimi-code": minor --- Add global default MCP server timeouts: `[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms` in `config.toml`, or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` env vars. 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 0efc766689..5aaa4ec89b 100644 --- a/packages/agent-core-v2/src/agent/mcp/connection-manager.ts +++ b/packages/agent-core-v2/src/agent/mcp/connection-manager.ts @@ -59,13 +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 defaultStartupTimeoutMs?: number; - readonly defaultToolTimeoutMs?: number; + readonly resolveDefaultTimeouts?: () => McpDefaultTimeouts; } export class McpConnectionManager { @@ -258,7 +267,7 @@ export class McpConnectionManager { private async connectOne(entry: InternalEntry, attemptId: number): Promise { const timeoutMs = entry.config.startupTimeoutMs ?? - this.options.defaultStartupTimeoutMs ?? + this.options.resolveDefaultTimeouts?.().startupTimeoutMs ?? DEFAULT_STARTUP_TIMEOUT_MS; let client: RuntimeMcpClient | undefined; @@ -334,7 +343,8 @@ export class McpConnectionManager { name: string, startupTimeoutMs: number, ): Promise { - const toolCallTimeoutMs = config.toolTimeoutMs ?? this.options.defaultToolTimeoutMs; + const toolCallTimeoutMs = + config.toolTimeoutMs ?? this.options.resolveDefaultTimeouts?.().toolTimeoutMs; if (config.transport === 'stdio') { return new StdioMcpClient(config, { startupTimeoutMs, diff --git a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts b/packages/agent-core-v2/src/session/mcp/sessionMcp.ts index f82f2552ee..b0ec61cbf4 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcp.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcp.ts @@ -12,8 +12,23 @@ import type { McpServerConfig } from '#/agent/mcp/config-schema'; export interface ISessionMcpService { readonly _serviceBrand: undefined; + /** + * Resolve the session/plugin MCP config and wait for the initial connection + * 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 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 068f985ec2..0f2489f91f 100644 --- a/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts +++ b/packages/agent-core-v2/src/session/mcp/sessionMcpService.ts @@ -1,16 +1,19 @@ /** * `mcp` domain (L5) — `ISessionMcpService` implementation. * - * Owns the shared session connection manager and initial connection lifecycle. - * Resolves server sources through `bootstrap`, `workspace`, and `plugin`, - * timeout preferences through `config`, OAuth storage through `persistence`, - * and reports through `log` and `telemetry`. Bound at Session scope. + * 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. 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. */ import { InstantiationType } from '#/_base/di/extensions'; import { Disposable } from '#/_base/di/lifecycle'; import { LifecycleScope, registerScopedService } from '#/_base/di/scope'; -import { Error2, ErrorCodes } from '#/errors'; import { McpConnectionManager } from '#/agent/mcp/connection-manager'; import type { McpServerConfig } from '#/agent/mcp/config-schema'; import { MCP_SECTION, type McpSection } from '#/agent/mcp/configSection'; @@ -47,7 +50,8 @@ export class SessionMcpService extends Disposable implements ISessionMcpService ensureMcpReady(callerServers?: Readonly>): Promise { if (this.mcpInitialLoad !== undefined) return this.mcpInitialLoad; - const initialLoad = this.initializeMcp(callerServers).catch((error: unknown) => { + const manager = this.connectionManager(); + const initialLoad = this.initializeMcp(manager, callerServers).catch((error: unknown) => { this.log.error('mcp initial load failed', { error }); }); this.mcpInitialLoad = initialLoad; @@ -55,32 +59,32 @@ export class SessionMcpService extends Disposable implements ISessionMcpService } connectionManager(): McpConnectionManager { - if (this.mcpManager === undefined) { - throw new Error2( - ErrorCodes.MCP_STARTUP_FAILED, - 'MCP connection manager is not ready; await ensureMcpReady() first', - ); - } - return this.mcpManager; - } - - private async initializeMcp( - callerServers?: Readonly>, - ): Promise { - await this.config.ready; + if (this.mcpManager !== undefined) return this.mcpManager; const oauthService = new McpOAuthService({ store: createMcpOAuthStore(this.atomicDocs), }); - const mcpSection = this.config.get(MCP_SECTION); const manager = new McpConnectionManager({ log: this.log, oauthService, stdioCwd: this.workspace.workDir, - defaultStartupTimeoutMs: mcpSection?.startupTimeoutMs, - defaultToolTimeoutMs: mcpSection?.toolTimeoutMs, + 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); } 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 cce4b1c62d..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 @@ -406,8 +406,10 @@ describe('McpConnectionManager', () => { } }, 7000); - it('applies defaultStartupTimeoutMs when the server entry omits startupTimeoutMs', async () => { - const cm = new McpConnectionManager({ defaultStartupTimeoutMs: 100 }); + 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: { @@ -429,11 +431,13 @@ describe('McpConnectionManager', () => { ['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', + '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({ defaultStartupTimeoutMs: 120_000 }); + const cm = new McpConnectionManager({ + resolveDefaultTimeouts: () => ({ startupTimeoutMs: 120_000 }), + }); try { await cm.connectAll({ server: config }); expect(connect).toHaveBeenCalledWith( @@ -461,7 +465,9 @@ describe('McpConnectionManager', () => { 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 }); + const cm = new McpConnectionManager({ + resolveDefaultTimeouts: () => ({ startupTimeoutMs: 120_000 }), + }); try { await cm.connectAll({ server: { ...config, startupTimeoutMs: 180_000 }, @@ -482,8 +488,10 @@ describe('McpConnectionManager', () => { }, ); - it('applies defaultToolTimeoutMs when the server entry omits toolTimeoutMs', async () => { - const cm = new McpConnectionManager({ defaultToolTimeoutMs: 100 }); + 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: { @@ -500,8 +508,10 @@ describe('McpConnectionManager', () => { } }, 15000); - it('lets a per-server toolTimeoutMs override defaultToolTimeoutMs', async () => { - const cm = new McpConnectionManager({ defaultToolTimeoutMs: 100 }); + 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: { @@ -866,26 +876,36 @@ describe('Session MCP initialization', () => { return ix.get(ISessionMcpService); } - it('keeps the connection manager unavailable while config remains unready', async () => { + 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); - const initialLoad = service.ensureMcpReady(); + // 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 { - await Promise.resolve(); - expect(() => service.connectionManager()).toThrow(/not ready/i); + // The initial connect is gated on config.ready: no entry exists yet. + await sleep(50); + expect(manager.list()).toEqual([]); resolveConfigReady(); await initialLoad; - manager = service.connectionManager(); - expect(manager.list()).toEqual([]); + 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 }); diff --git a/packages/agent-core/src/mcp/connection-manager.ts b/packages/agent-core/src/mcp/connection-manager.ts index 6c492fefdd..152d3a8131 100644 --- a/packages/agent-core/src/mcp/connection-manager.ts +++ b/packages/agent-core/src/mcp/connection-manager.ts @@ -43,7 +43,7 @@ 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 a positive integer is ignored. */ +/** 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 From 2d3b883202fc9792d0c2a2998b38b359b8cfcf68 Mon Sep 17 00:00:00 2001 From: 7Sageer <7sageer@djwcb.cn> Date: Thu, 23 Jul 2026 12:26:17 +0800 Subject: [PATCH 8/9] chore(agent-core-v2): regenerate config manifest for the mcp section --- .../agent-core-v2/docs/config-manifest.toml | 17 ++++++++++++++++- 1 file changed, 16 insertions(+), 1 deletion(-) 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 From 4f1797b1e2b77db4a2bb95e68dbd381d439aaf84 Mon Sep 17 00:00:00 2001 From: 7Sageer Date: Thu, 23 Jul 2026 12:27:04 +0800 Subject: [PATCH 9/9] Add global default MCP server timeouts configuration Specify the new global default MCP server timeouts in both the config file and environment variables. Signed-off-by: 7Sageer --- .changeset/mcp-global-timeouts.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/mcp-global-timeouts.md b/.changeset/mcp-global-timeouts.md index 398234847c..16a9f1b152 100644 --- a/.changeset/mcp-global-timeouts.md +++ b/.changeset/mcp-global-timeouts.md @@ -2,4 +2,4 @@ "@moonshot-ai/kimi-code": minor --- -Add global default MCP server timeouts: `[mcp] startup_timeout_ms` / `[mcp] tool_timeout_ms` in `config.toml`, or the `KIMI_MCP_STARTUP_TIMEOUT_MS` / `KIMI_MCP_TOOL_TIMEOUT_MS` env vars. +Add global default MCP server timeouts in `config.toml` and env vars.