From 2425fa4f84d4eef948c68b26a3e3b24457a38892 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 31 Aug 2026 20:01:40 +0800 Subject: [PATCH] feat(mcp-client): liveness health pings on established connections Status downgrades were driven by the transport close signal alone. A stdio child's death closes the transport, but Streamable HTTP holds no connection between requests, so a dead server kept showing connected until the next actual call. An established generation now sends a protocol ping every healthCheckIntervalMs (default 30s, the same value bounding each answer); a failed or timed-out ping closes the generation into the existing reconnect path and its diagnostic rides the reconnecting status. The manager passes healthCheckIntervalMs through per server entry. --- docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 22 +++- docs/config-catalog.zh.md | 22 +++- packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 3 +- packages/mcp/mcp-client/README.zh.md | 3 +- packages/mcp/mcp-client/src/connection.ts | 56 ++++++++- packages/mcp/mcp-client/src/index.ts | 33 ++++- packages/mcp/mcp-client/tests/apply.spec.ts | 3 + .../mcp/mcp-client/tests/mcp-client.e2e.ts | 9 ++ .../mcp/mcp-client/tests/mcp-client.spec.ts | 5 + packages/mcp/mcp-client/tests/probe.spec.ts | 2 + .../mcp/mcp-client/tests/reconnect.spec.ts | 117 +++++++++++++++++- packages/mcp/mcp-client/tests/status.spec.ts | 2 + packages/mcp/mcp-manager/README.i18n.yaml | 4 +- packages/mcp/mcp-manager/README.md | 2 +- packages/mcp/mcp-manager/README.zh.md | 2 +- packages/mcp/mcp-manager/src/index.ts | 4 + .../mcp/mcp-manager/tests/manager.spec.ts | 4 +- 19 files changed, 278 insertions(+), 23 deletions(-) diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 019975c8cf..aa4d351c01 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: c29219370afa91f750ac21e0699f65be8e9606ed -config-catalog.zh.md: 6fb1b18cab9708b41099bbdcaeb23c96a4a4f1dc +config-catalog.md: 6bad24d16ba468d0bb57b2fd2cbe201366704404 +config-catalog.zh.md: 2809bf698094fe4822ac348aa0299cd9356b680b diff --git a/docs/config-catalog.md b/docs/config-catalog.md index c29219370a..6bad24d16b 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1377,6 +1377,14 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Budget for one connection attempt plus its initial tool sync, in milliseconds. */ startupTimeoutMs: number + /** + * Spacing between liveness pings on an established connection, in + * milliseconds; the same value bounds each ping's answer. A failed or + * timed-out ping closes the generation into the reconnect path — the only + * way a silently dead Streamable HTTP server (no close signal between + * requests) is detected. + */ + healthCheckIntervalMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ @@ -1401,6 +1409,14 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Budget for one connection attempt plus its initial tool sync, in milliseconds. */ startupTimeoutMs: number + /** + * Spacing between liveness pings on an established connection, in + * milliseconds; the same value bounds each ping's answer. A failed or + * timed-out ping closes the generation into the reconnect path — the only + * way a silently dead Streamable HTTP server (no close signal between + * requests) is detected. + */ + healthCheckIntervalMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ @@ -1420,7 +1436,7 @@ export interface ReconnectConfig { } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:124`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:143`](../packages/mcp/mcp-client/src/index.ts) @@ -1463,6 +1479,8 @@ export interface McpManagedServerEntry { toolCallTimeoutMs?: number /** Per-attempt startup budget override in milliseconds; omission uses the mcp-client default. */ startupTimeoutMs?: number + /** Liveness-ping spacing override in milliseconds; omission uses the mcp-client default. */ + healthCheckIntervalMs?: number /** Reconnect policy; the schema fills the mcp-client defaults. */ reconnect: ReconnectConfig } @@ -1470,7 +1488,7 @@ export interface McpManagedServerEntry { Depends on: [`ReconnectConfig`](../packages/mcp/mcp-client/src/index.ts) -Source: [`packages/mcp/mcp-manager/src/index.ts:74`](../packages/mcp/mcp-manager/src/index.ts) +Source: [`packages/mcp/mcp-manager/src/index.ts:76`](../packages/mcp/mcp-manager/src/index.ts) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 6fb1b18cab..2809bf6980 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1379,6 +1379,14 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Budget for one connection attempt plus its initial tool sync, in milliseconds. */ startupTimeoutMs: number + /** + * Spacing between liveness pings on an established connection, in + * milliseconds; the same value bounds each ping's answer. A failed or + * timed-out ping closes the generation into the reconnect path — the only + * way a silently dead Streamable HTTP server (no close signal between + * requests) is detected. + */ + healthCheckIntervalMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ @@ -1403,6 +1411,14 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Budget for one connection attempt plus its initial tool sync, in milliseconds. */ startupTimeoutMs: number + /** + * Spacing between liveness pings on an established connection, in + * milliseconds; the same value bounds each ping's answer. A failed or + * timed-out ping closes the generation into the reconnect path — the only + * way a silently dead Streamable HTTP server (no close signal between + * requests) is detected. + */ + healthCheckIntervalMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ @@ -1422,7 +1438,7 @@ export interface ReconnectConfig { } ``` -来源:[`packages/mcp/mcp-client/src/index.ts:124`](../packages/mcp/mcp-client/src/index.ts) +来源:[`packages/mcp/mcp-client/src/index.ts:143`](../packages/mcp/mcp-client/src/index.ts) @@ -1465,6 +1481,8 @@ export interface McpManagedServerEntry { toolCallTimeoutMs?: number /** Per-attempt startup budget override in milliseconds; omission uses the mcp-client default. */ startupTimeoutMs?: number + /** Liveness-ping spacing override in milliseconds; omission uses the mcp-client default. */ + healthCheckIntervalMs?: number /** Reconnect policy; the schema fills the mcp-client defaults. */ reconnect: ReconnectConfig } @@ -1472,7 +1490,7 @@ export interface McpManagedServerEntry { 依赖:[`ReconnectConfig`](../packages/mcp/mcp-client/src/index.ts) -来源:[`packages/mcp/mcp-manager/src/index.ts:74`](../packages/mcp/mcp-manager/src/index.ts) +来源:[`packages/mcp/mcp-manager/src/index.ts:76`](../packages/mcp/mcp-manager/src/index.ts) diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 731a0bd88a..e0c01454f0 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md -README.md: c9a25593caa5cd611122b7a80563c814e5d4be6f -README.zh.md: b8dd3e830d43d731db7f47d0ba7f72f3f5bb1179 +README.md: ba44572b8af20d51f529ccef7e68dc82ade5f923 +README.zh.md: 468084848016c494aeec1846d8905e329ee06a05 diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index c9a25593ca..ba44572b8a 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -60,6 +60,7 @@ Add one entry per server; nothing else is required. After the harness starts, th | `url` / `headers` | — | streamable-http: endpoint URL and extra request headers | | `toolCallTimeoutMs` | `60,000` | Timeout per `tools/call` invocation | | `startupTimeoutMs` | `30,000` | Budget for one connection attempt plus its initial tool sync | +| `healthCheckIntervalMs` | `30,000` | Spacing between liveness pings on an established connection (also each ping's answer budget) | | `failOnStartupError` | `false` | Reject plugin activation when the initial connection or tool synchronization fails | | `reconnect.enabled` | `true` | Reconnect automatically after a lost connection | | `reconnect.initialDelayMs` | `500` | First reconnect delay; doubles per consecutive failed attempt | @@ -87,7 +88,7 @@ Images are supported when the current model accepts image input and the harness ### Startup, updates, and reconnection -The server's tools appear before the harness starts its first turn. Every connection attempt — the first one and each reconnect — is bounded by `startupTimeoutMs` (default 30 seconds) covering the connection plus the initial tool discovery, so an unresponsive server cannot hold startup open. When the server changes its tool list, the model's tool set updates automatically; if the update fails, the previous tool set keeps working. +The server's tools appear before the harness starts its first turn. Every connection attempt — the first one and each reconnect — is bounded by `startupTimeoutMs` (default 30 seconds) covering the connection plus the initial tool discovery, so an unresponsive server cannot hold startup open. An established connection is pinged every `healthCheckIntervalMs` (default 30 seconds): a stdio server's death closes the transport by itself, but a Streamable HTTP server holds no connection between requests, so the failed or timed-out ping is what moves a silently dead server into the reconnect path. When the server changes its tool list, the model's tool set updates automatically; if the update fails, the previous tool set keeps working. When a server connection drops — for example a local server process crashes — the plugin reconnects automatically with delays that double from 500 ms up to 30 s and then refreshes the tool set; reconnect progress is visible in the logs. During an outage the last known tools stay listed but calls to them fail until the server recovers. After ten consecutive failed attempts the server's tools are removed and reconnection stops until you reload the configuration or restart the harness; a server that stays connected for a while resets that counter. Set `reconnect.enabled: false` to disable automatic reconnection — tools then stay listed but fail until you reload. Editing the configuration entry reloads the server connection in place, and unchanged names stay unchanged. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index b8dd3e830d..4680848480 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -60,6 +60,7 @@ kind: "package-reference" | `url` / `headers` | — | streamable-http:端点 URL 与额外请求标头 | | `toolCallTimeoutMs` | `60,000` | 每次 `tools/call` 调用的超时 | | `startupTimeoutMs` | `30,000` | 单次连接尝试加初次工具同步的预算 | +| `healthCheckIntervalMs` | `30,000` | 已建立连接上两次存活 ping 的间隔(同时是每次 ping 的应答预算) | | `failOnStartupError` | `false` | 初始连接或工具同步失败时拒绝插件激活 | | `reconnect.enabled` | `true` | 连接丢失后自动重新连接 | | `reconnect.initialDelayMs` | `500` | 首次重连延迟;每次连续失败尝试翻倍 | @@ -87,7 +88,7 @@ kind: "package-reference" ### 启动、工具更新与重连 -服务器的工具会在 harness 开始首个轮次之前出现。每次连接尝试——首次连接与每次重连——都受 `startupTimeoutMs`(默认 30 秒)约束,覆盖连接加初次工具发现,因此无响应的服务器无法拖住启动。服务器更改工具列表时,模型的工具集会自动更新;更新失败时,上一组工具继续可用。 +服务器的工具会在 harness 开始首个轮次之前出现。每次连接尝试——首次连接与每次重连——都受 `startupTimeoutMs`(默认 30 秒)约束,覆盖连接加初次工具发现,因此无响应的服务器无法拖住启动。已建立的连接每 `healthCheckIntervalMs`(默认 30 秒)发送一次存活 ping:stdio 服务器死亡会自行关闭传输,而 Streamable HTTP 在两次请求之间没有连接,失败或超时的 ping 正是把悄然死亡的服务器送入重连路径的信号。服务器更改工具列表时,模型的工具集会自动更新;更新失败时,上一组工具继续可用。 服务器连接断开时——例如本地服务器进程崩溃——插件会以从 500 ms 起逐次翻倍、上限 30 s 的延迟自动重连,并刷新工具集;重连进度在日志中可见。中断期间最后已知的工具仍会列出,但对它们的调用会失败,直到服务器恢复。连续失败十次后,该服务器的工具会被移除,重连停止,直到你重载配置或重启 harness;服务器持续连接一段时间后,该计数会重置。设置 `reconnect.enabled: false` 可禁用自动重连——此时工具在断开后仍会列出,但调用失败,直到你重载。编辑配置项会在原地重载服务器连接,未变的名称保持不变。 diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 1183f7dac3..a4d17c6ff1 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -183,9 +183,57 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe return run } + /** + * The diagnostic the next scheduleReconnect reports for a lost established + * connection; set by the health ping so its failure is not flattened to the + * generic close text. + */ + let pendingDownReason: string | undefined + + /** Armed while a generation is established: one liveness ping per interval. */ + let healthTimer: NodeJS.Timeout | undefined + + function stopHealthCheck(): void { + if (healthTimer !== undefined) { + clearTimeout(healthTimer) + healthTimer = undefined + } + } + + /** + * Chain the next liveness ping on the established generation. Stdio deaths + * already close the transport, but Streamable HTTP has no connection between + * requests, so only this ping turns a silently dead server into the + * onclose-driven reconnect path. The next ping arms only after the previous + * one settles, and the interval doubles as each ping's answer budget. + */ + function armHealthPing(generation: Client): void { + stopHealthCheck() + healthTimer = setTimeout(() => { + healthTimer = undefined + void withDeadline( + generation.ping(), + config.healthCheckIntervalMs, + `${label}: health ping timed out after ${config.healthCheckIntervalMs}ms`, + ).then( + () => { if (isCurrent(generation)) armHealthPing(generation) }, + (error: unknown) => { + if (!isCurrent(generation)) return + pendingDownReason = String(error) + ctx.logger.warn(`${label}: health ping failed: ${String(error)} — closing the connection to reconnect`) + // A close that completes reaches generationDown through onclose; a + // transport too broken to close still must not strand the supervisor. + generation.close().catch(() => { generationDown(generation) }) + }, + ) + }, config.healthCheckIntervalMs) + healthTimer.unref() + } + /** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */ function generationDown(generation: Client): void { if (!isCurrent(generation)) return + stopHealthCheck() client = undefined clientClosed = undefined scheduleReconnect() @@ -206,8 +254,10 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe function scheduleReconnect(): void { const lostEstablishedConnection = connectedAt !== undefined // A lost established connection carries no thrown error; the close signal - // itself is the most recent failure the status can report. - if (lostEstablishedConnection) lastErrorText = 'connection closed' + // itself — or the health ping failure that forced it — is the most recent + // failure the status can report. + if (lostEstablishedConnection) lastErrorText = pendingDownReason ?? 'connection closed' + pendingDownReason = undefined if (!policy.enabled) { const message = lostEstablishedConnection ? 'connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart' @@ -344,6 +394,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe if (!isCurrent(generation)) return connectedAt = Date.now() status.update({ state: 'connected', toolCount: disposers.size, connectedAt }) + armHealthPing(generation) if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`) } @@ -369,6 +420,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe ready, async dispose(): Promise { disposed = true + stopHealthCheck() if (reconnectTimer !== undefined) { clearTimeout(reconnectTimer) reconnectTimer = undefined diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index a9ec9940a5..c795e2728c 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -42,6 +42,9 @@ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 60_000 /** Default budget for one connection attempt plus its initial tool sync (ms). */ const DEFAULT_STARTUP_TIMEOUT_MS = 30_000 +/** Default spacing between liveness pings on an established connection. */ +const DEFAULT_HEALTH_CHECK_INTERVAL_MS = 30_000 + /** Valid `serverName`, kept below the public tool-name budget. */ export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ @@ -90,6 +93,14 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Budget for one connection attempt plus its initial tool sync, in milliseconds. */ startupTimeoutMs: number + /** + * Spacing between liveness pings on an established connection, in + * milliseconds; the same value bounds each ping's answer. A failed or + * timed-out ping closes the generation into the reconnect path — the only + * way a silently dead Streamable HTTP server (no close signal between + * requests) is detected. + */ + healthCheckIntervalMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ @@ -114,6 +125,14 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Budget for one connection attempt plus its initial tool sync, in milliseconds. */ startupTimeoutMs: number + /** + * Spacing between liveness pings on an established connection, in + * milliseconds; the same value bounds each ping's answer. A failed or + * timed-out ping closes the generation into the reconnect path — the only + * way a silently dead Streamable HTTP server (no close signal between + * requests) is detected. + */ + healthCheckIntervalMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ @@ -123,10 +142,10 @@ export interface StreamableHttpConfig { /** Configuration for one stdio or Streamable HTTP MCP server. */ export type Config = StdioConfig | StreamableHttpConfig -type StdioConfigInput = Omit - & Partial> -type StreamableHttpConfigInput = Omit - & Partial> +type StdioConfigInput = Omit + & Partial> +type StreamableHttpConfigInput = Omit + & Partial> type ConfigInput = StdioConfigInput | StreamableHttpConfigInput /** @@ -141,6 +160,9 @@ export const ReconnectSchema: z = z.object({ maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts), }) +/* jscpd:ignore-start */ +// The catalog generator statically walks this literal and rejects spreads and +// computed keys, so the branches restate their shared rows by construction. export const Config = z.union([ z.object({ transport: z.const('stdio'), @@ -151,6 +173,7 @@ export const Config = z.union([ cwd: z.string().default(''), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), startupTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STARTUP_TIMEOUT_MS), + healthCheckIntervalMs: z.number().min(1_000).max(MAX_TIMER_DELAY_MS).default(DEFAULT_HEALTH_CHECK_INTERVAL_MS), failOnStartupError: z.boolean().default(false), reconnect: ReconnectSchema, }), @@ -161,10 +184,12 @@ export const Config = z.union([ headers: z.dict(String).default({}), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), startupTimeoutMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(DEFAULT_STARTUP_TIMEOUT_MS), + healthCheckIntervalMs: z.number().min(1_000).max(MAX_TIMER_DELAY_MS).default(DEFAULT_HEALTH_CHECK_INTERVAL_MS), failOnStartupError: z.boolean().default(false), reconnect: ReconnectSchema, }), ]) as unknown as z +/* jscpd:ignore-end */ // ---- Plugin apply ---- diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 929d0e58c3..b932c4deca 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -32,6 +32,7 @@ const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotification }) class MockClient { connect = mockConnect + ping = (): Promise => Promise.resolve() close = mockClose listTools = mockListTools callTool = mockCallTool @@ -84,6 +85,7 @@ const stdioConfig: Config = { cwd: '', toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } @@ -405,6 +407,7 @@ describe('apply (plugin lifecycle)', () => { headers: { Authorization: 'Bearer x' }, toolCallTimeoutMs: 30_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index 87dd1c6643..1075af0083 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -106,6 +106,7 @@ describe('fixture server — controlled scenarios', () => { cwd: packageDir, toolCallTimeoutMs: 15_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } @@ -206,6 +207,7 @@ describe('fixture server — duplicate serverName', () => { cwd: packageDir, toolCallTimeoutMs: 15_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } await apply(ctx, config) @@ -229,6 +231,7 @@ describe('fixture server — disposal', () => { cwd: packageDir, toolCallTimeoutMs: 15_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, }) @@ -253,6 +256,7 @@ describe('fixture server — crash recovery', () => { cwd: packageDir, toolCallTimeoutMs: 15_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, reconnect, } @@ -338,6 +342,7 @@ describe('server-everything — official test server', () => { cwd: '', toolCallTimeoutMs: 30_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } @@ -408,6 +413,7 @@ describe('server-filesystem — real filesystem operations', () => { cwd: '', toolCallTimeoutMs: 30_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } await apply(ctx, config) @@ -526,6 +532,7 @@ describe('streamable-http — in-process MCP server', () => { headers: { Authorization: 'Bearer e2e-test-token' }, toolCallTimeoutMs: 15_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } await apply(ctx, config) @@ -582,6 +589,7 @@ describe('fixture server — probe', () => { cwd: packageDir, toolCallTimeoutMs: 15_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, }, { timeoutMs: 20_000 }) expect(result).toMatchObject({ @@ -602,6 +610,7 @@ describe('fixture server — probe', () => { cwd: packageDir, toolCallTimeoutMs: 5_000, startupTimeoutMs: 5_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, }, { timeoutMs: 5_000 }) expect(dead).toMatchObject({ ok: false, stage: 'connect' }) diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index f104ad911f..d18cf10fe9 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -1129,6 +1129,7 @@ describe('createTransport', () => { cwd: '/tmp', toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } const transport = createTransport(config) @@ -1145,6 +1146,7 @@ describe('createTransport', () => { headers: {}, toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } const transport = createTransport(config) @@ -1161,6 +1163,7 @@ describe('createTransport', () => { headers: { Authorization: 'Bearer token' }, toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } const transport = createTransport(config) @@ -1186,6 +1189,7 @@ describe('createTransport', () => { cwd: '', toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } // StdioClientTransport keeps its env private; the observable contract is @@ -1213,6 +1217,7 @@ describe('createTransport', () => { cwd: '', toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } const transport = createTransport(config) diff --git a/packages/mcp/mcp-client/tests/probe.spec.ts b/packages/mcp/mcp-client/tests/probe.spec.ts index ac21a88848..dcc41e5773 100644 --- a/packages/mcp/mcp-client/tests/probe.spec.ts +++ b/packages/mcp/mcp-client/tests/probe.spec.ts @@ -19,6 +19,7 @@ const { mockConnect, mockClose, mockListTools, mockGetServerVersion, MockClient }) class MockClient { connect = mockConnect + ping = (): Promise => Promise.resolve() close = mockClose request = mockRequest getServerVersion = mockGetServerVersion @@ -45,6 +46,7 @@ function httpConfig(): Config { headers: {}, toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, } } diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 3d460725f8..963f573ece 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -15,9 +15,12 @@ import type { Config } from '@deepseek-ai/dsh-mcp-client' // vi.mock factories are hoisted above every import/const, so the mock fns and // class must be created inside vi.hoisted to exist when the factories run. -const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } = vi.hoisted(() => { +const { + mockConnect, mockClose, mockPing, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances, +} = vi.hoisted(() => { const mockConnect = vi.fn<() => Promise>() const mockClose = vi.fn<() => Promise>() + const mockPing = vi.fn<() => Promise>() const mockListTools = vi.fn<(_params?: Record) => Promise>() const mockCallTool = vi.fn<( _params?: Record, _compatibilitySchema?: unknown, _options?: unknown, @@ -36,12 +39,13 @@ const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotification onclose: (() => void) | undefined connect = mockConnect close = mockClose + ping = mockPing request = mockRequest setNotificationHandler = mockSetNotificationHandler constructor() { instances.push(this) } } const instances: MockClient[] = [] - return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } + return { mockConnect, mockClose, mockPing, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } }) vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ @@ -103,6 +107,7 @@ function stdioConfig(reconnect?: Config['reconnect']): Config { cwd: '', toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, ...reconnect === undefined ? {} : { reconnect }, } @@ -123,6 +128,113 @@ function nextCallId(): ToolCallId { // ---- Tests ---- +describe('health ping', () => { + let ctx: Context + + beforeEach(async () => { + vi.clearAllMocks() + instances.length = 0 + mockConnect.mockResolvedValue(undefined) + mockPing.mockResolvedValue(undefined) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.resolve() + }) + mockListTools.mockResolvedValue(listing('remote')) + ctx = await mountRegistry() + }) + + /** Supervisor under an owned lifecycle: every test disposes what it started. */ + function superviseWith(config: Config): { seen: string[]; dispose: () => Promise; ready: Promise } { + const status = new McpStatusCell(() => {}) + const seen: string[] = [] + status.onChange((next) => { + seen.push(next.state === 'reconnecting' ? `reconnecting:${next.lastError}` : next.state) + }) + const handle = startConnection(ctx, config, resolveReconnectPolicy(config.reconnect, 'test'), status) + return { seen, dispose: handle.dispose.bind(handle), ready: handle.ready } + } + + it('re-arms after a healthy ping and turns a failed ping into reconnect with its diagnostic', async () => { + const { warns } = captureLogs(ctx) + mockPing.mockResolvedValueOnce(undefined) + mockPing.mockRejectedValueOnce(new Error('boom')) + const supervisor = superviseWith({ ...stdioConfig({ initialDelayMs: 5, maxDelayMs: 40, maxAttempts: 3 }), healthCheckIntervalMs: 20 }) + try { + await supervisor.ready + // Ping 1 succeeds and re-arms; ping 2 fails and closes the generation. + await vi.waitFor(() => { expect(mockPing).toHaveBeenCalledTimes(2) }) + await vi.waitFor(() => { expect(supervisor.seen.includes('reconnecting:Error: boom')).toBe(true) }) + expect(warns.some(line => line.includes('health ping failed: Error: boom'))).toBe(true) + // The supervisor recovers onto a fresh generation and resumes pinging it. + await vi.waitFor(() => { expect(supervisor.seen.filter(state => state === 'connected').length).toBeGreaterThan(1) }) + expect(instances.length).toBeGreaterThan(1) + } finally { + await supervisor.dispose() + } + }) + + it('a ping that cannot even close the generation still reaches the reconnect path', async () => { + const { warns } = captureLogs(ctx) + mockPing.mockRejectedValueOnce(new Error('gone')) + const supervisor = superviseWith({ ...stdioConfig({ initialDelayMs: 5, maxDelayMs: 40, maxAttempts: 3 }), healthCheckIntervalMs: 15 }) + try { + await supervisor.ready + mockClose.mockImplementation(() => Promise.reject(new Error('cannot close'))) + await vi.waitFor(() => { expect(warns.some(line => line.includes('health ping failed: Error: gone'))).toBe(true) }) + await vi.waitFor(() => { expect(supervisor.seen.some(state => state.startsWith('reconnecting:'))).toBe(true) }) + // Let the recovery generation close normally again so disposal quiesces. + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.resolve() + }) + } finally { + await supervisor.dispose() + } + }) + + it('a ping settling after its generation went down neither re-arms nor reports', async () => { + const gate: PromiseWithResolvers = Promise.withResolvers() + mockPing.mockImplementation(() => gate.promise) + // A wide interval keeps the ping's own deadline (= the interval) from + // firing before the test settles the gated ping by hand. + const supervisor = superviseWith({ ...stdioConfig({ enabled: false }), healthCheckIntervalMs: 1_000 }) + try { + await supervisor.ready + await vi.waitFor(() => { expect(mockPing).toHaveBeenCalledTimes(1) }, { timeout: 3_000 }) + // The server dies while the ping is in flight; reconnect is disabled. + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(supervisor.seen.includes('failed')).toBe(true) }) + gate.resolve() + await sleep(60) + expect(mockPing).toHaveBeenCalledTimes(1) + } finally { + await supervisor.dispose() + } + }) + + it('a ping rejecting after its generation went down is absorbed silently', async () => { + const { warns } = captureLogs(ctx) + const gate: PromiseWithResolvers = Promise.withResolvers() + mockPing.mockImplementation(() => gate.promise) + // A wide interval keeps the ping's own deadline (= the interval) from + // firing before the test settles the gated ping by hand. + const supervisor = superviseWith({ ...stdioConfig({ enabled: false }), healthCheckIntervalMs: 1_000 }) + try { + await supervisor.ready + await vi.waitFor(() => { expect(mockPing).toHaveBeenCalledTimes(1) }, { timeout: 3_000 }) + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(supervisor.seen.includes('failed')).toBe(true) }) + gate.reject(new Error('late')) + await sleep(60) + expect(warns.some(line => line.includes('health ping failed'))).toBe(false) + expect(mockPing).toHaveBeenCalledTimes(1) + } finally { + await supervisor.dispose() + } + }) +}) + describe('reconnect supervisor', () => { let ctx: Context @@ -130,6 +242,7 @@ describe('reconnect supervisor', () => { vi.clearAllMocks() instances.length = 0 mockConnect.mockResolvedValue(undefined) + mockPing.mockResolvedValue(undefined) mockClose.mockImplementation(function (this: { onclose?: () => void }) { this.onclose?.() return Promise.resolve() diff --git a/packages/mcp/mcp-client/tests/status.spec.ts b/packages/mcp/mcp-client/tests/status.spec.ts index ae816e24e8..ccc0d64c1f 100644 --- a/packages/mcp/mcp-client/tests/status.spec.ts +++ b/packages/mcp/mcp-client/tests/status.spec.ts @@ -24,6 +24,7 @@ const { mockConnect, mockClose, mockListTools, mockSetNotificationHandler, MockC class MockClient { onclose: (() => void) | undefined connect = mockConnect + ping = (): Promise => Promise.resolve() close = mockClose request = mockRequest setNotificationHandler = mockSetNotificationHandler @@ -70,6 +71,7 @@ function stdioConfig(overrides?: Partial & { reconnect?: Config['reconne cwd: '', toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, ...overrides, } as Config diff --git a/packages/mcp/mcp-manager/README.i18n.yaml b/packages/mcp/mcp-manager/README.i18n.yaml index e23666ebe2..5bd9ae0b62 100644 --- a/packages/mcp/mcp-manager/README.i18n.yaml +++ b/packages/mcp/mcp-manager/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/mcp/mcp-manager/README.md -README.md: dc25d4a9112592ceb2d776ce59dc7edac9308323 -README.zh.md: 7c9923566c18b6c99054d562996a256ad09b7b1a +README.md: 5f324aa9c5f2a48c61930a29ddbdc4a248b2d7b4 +README.zh.md: ecd0b0c268d788c7d70dc8e86e22039ce2bc4aa9 diff --git a/packages/mcp/mcp-manager/README.md b/packages/mcp/mcp-manager/README.md index dc25d4a911..5f324aa9c5 100644 --- a/packages/mcp/mcp-manager/README.md +++ b/packages/mcp/mcp-manager/README.md @@ -68,7 +68,7 @@ mcp: | `command` / `args` / `env` / `cwd` | — | stdio: executable, arguments, extra child env, working directory | | `url` / `headers` | — | streamable-http: endpoint URL and extra request headers | | `enabled` | `true` | Durable intent: `false` unmounts the instance and keeps the entry | -| `toolCallTimeoutMs` / `startupTimeoutMs` / `reconnect` | mcp-client defaults | Per-server overrides passed through to the instance | +| `toolCallTimeoutMs` / `startupTimeoutMs` / `healthCheckIntervalMs` / `reconnect` | mcp-client defaults | Per-server overrides passed through to the instance | The generated [configuration catalog](../../../docs/config-catalog.md#deepseek-aidsh-mcp-manager) is the exhaustive source for every accepted field. diff --git a/packages/mcp/mcp-manager/README.zh.md b/packages/mcp/mcp-manager/README.zh.md index 7c9923566c..ecd0b0c268 100644 --- a/packages/mcp/mcp-manager/README.zh.md +++ b/packages/mcp/mcp-manager/README.zh.md @@ -68,7 +68,7 @@ mcp: | `command` / `args` / `env` / `cwd` | — | stdio:可执行文件、参数、附加子进程环境、工作目录 | | `url` / `headers` | — | streamable-http:端点 URL 与附加请求头 | | `enabled` | `true` | 持久意图:`false` 卸载实例但保留条目 | -| `toolCallTimeoutMs` / `startupTimeoutMs` / `reconnect` | mcp-client 默认值 | 逐服务器覆盖,原样传给实例 | +| `toolCallTimeoutMs` / `startupTimeoutMs` / `healthCheckIntervalMs` / `reconnect` | mcp-client 默认值 | 逐服务器覆盖,原样传给实例 | 生成的[配置目录](../../../docs/config-catalog.zh.md#deepseek-aidsh-mcp-manager)是每个可接受字段的详尽出处。 diff --git a/packages/mcp/mcp-manager/src/index.ts b/packages/mcp/mcp-manager/src/index.ts index 4d37e1a1f0..4a60f27051 100644 --- a/packages/mcp/mcp-manager/src/index.ts +++ b/packages/mcp/mcp-manager/src/index.ts @@ -66,6 +66,8 @@ export interface McpManagedServerEntry { toolCallTimeoutMs?: number /** Per-attempt startup budget override in milliseconds; omission uses the mcp-client default. */ startupTimeoutMs?: number + /** Liveness-ping spacing override in milliseconds; omission uses the mcp-client default. */ + healthCheckIntervalMs?: number /** Reconnect policy; the schema fills the mcp-client defaults. */ reconnect: ReconnectConfig } @@ -93,6 +95,7 @@ const ServerEntrySchema = z.object({ enabled: z.boolean().default(true), toolCallTimeoutMs: z.number(), startupTimeoutMs: z.number(), + healthCheckIntervalMs: z.number(), reconnect: ReconnectSchema, }) @@ -525,6 +528,7 @@ export default class McpServerManager extends Service { failOnStartupError: false, ...entry.toolCallTimeoutMs === undefined ? {} : { toolCallTimeoutMs: entry.toolCallTimeoutMs }, ...entry.startupTimeoutMs === undefined ? {} : { startupTimeoutMs: entry.startupTimeoutMs }, + ...entry.healthCheckIntervalMs === undefined ? {} : { healthCheckIntervalMs: entry.healthCheckIntervalMs }, reconnect: entry.reconnect, } if (entry.transport === 'stdio') { diff --git a/packages/mcp/mcp-manager/tests/manager.spec.ts b/packages/mcp/mcp-manager/tests/manager.spec.ts index b8449b94d8..d24ac7c40e 100644 --- a/packages/mcp/mcp-manager/tests/manager.spec.ts +++ b/packages/mcp/mcp-manager/tests/manager.spec.ts @@ -28,6 +28,7 @@ const { mockConnect, mockClose, mockListTools, MockClient, instances, transportC class MockClient { onclose: (() => void) | undefined connect = mockConnect + ping = (): Promise => Promise.resolve() close = mockClose request = mockRequest getServerVersion = (): { name: string; version: string } => ({ name: 'fixture', version: '1.0.0' }) @@ -217,7 +218,7 @@ describe('mcp-manager', () => { mockListTools.mockResolvedValue(listing('changed')) await settings!.update('mcp', { servers: { - alpha: { args: ['--fast'], toolCallTimeoutMs: 5_000, startupTimeoutMs: 4_000, reconnect: { maxAttempts: 3 } }, + alpha: { args: ['--fast'], toolCallTimeoutMs: 5_000, startupTimeoutMs: 4_000, healthCheckIntervalMs: 60_000, reconnect: { maxAttempts: 3 } }, }, }) await vi.waitFor(() => { expect(ctx.tools.get('mcp__alpha__changed')).toBeDefined() }) @@ -351,6 +352,7 @@ describe('mcp-manager', () => { cwd: '', toolCallTimeoutMs: 60_000, startupTimeoutMs: 30_000, + healthCheckIntervalMs: 30_000, failOnStartupError: false, }) // The manager learns about "dup" only now, through a live settings attach.