Merge remote-tracking branch 'origin/master' into feature/workspace-picker-composer
This commit is contained in:
@@ -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: 76d1271f6f7a3e9c959bdcf5e969906f25563c56
|
||||
README.zh.md: b2da1119af3a8d52761a5040059e7a9d922aa567
|
||||
README.md: 97ac9c173fc2b848f524e8c0fdd93eca072567af
|
||||
README.zh.md: 1b5b5c523e0a477db30f97a748651dbe7e6992ea
|
||||
|
||||
@@ -45,6 +45,10 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same
|
||||
| `headers` | http | no | Extra headers (e.g. auth tokens) |
|
||||
| `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
|
||||
| `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) |
|
||||
| `reconnect.enabled` | both | no | Reconnect automatically after a lost connection (default `true`) |
|
||||
| `reconnect.initialDelayMs` | both | no | First reconnect delay in ms; doubles per consecutive failed attempt (default 500) |
|
||||
| `reconnect.maxDelayMs` | both | no | Backoff ceiling in ms; also the uptime after which the attempt budget resets (default 30000) |
|
||||
| `reconnect.maxAttempts` | both | no | Consecutive failed attempts per outage before giving up for good (default 10) |
|
||||
|
||||
## Tool naming
|
||||
|
||||
@@ -62,7 +66,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
|
||||
- Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
|
||||
- Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`.
|
||||
- Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
|
||||
- On disconnect/crash: no auto-reconnect. Registered tools remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport; reload with HMR or restart the Host to reconnect.
|
||||
- On disconnect/crash: the supervisor restarts the original server config with exponential backoff (`reconnect.initialDelayMs` doubling up to `reconnect.maxDelayMs`) and re-runs discovery on success — the recovered generation replaces the previous one, so tools neither duplicate nor leak. During the outage the last good generation stays registered; calls against it fail until recovery.
|
||||
- Reconnection is budgeted per outage: after `reconnect.maxAttempts` consecutive failures the server's tools are unregistered and reconnection stops until an HMR reload or Host restart. A connection that survives past `maxDelayMs` resets the budget, so an occasionally-crashing server recovers indefinitely while a crash-looping one — even with briefly successful connects — still exhausts the cap instead of restarting forever.
|
||||
- Reconnect states are user-visible in logs: reconnecting (warn, with attempt count and delay), recovered (info), final failure and disabled-loss (error). Disposal cancels any pending reconnect. With `reconnect.enabled: false`, a lost connection keeps tools registered but failing until a reload — the manual-recovery behavior.
|
||||
|
||||
## Services consumed
|
||||
|
||||
@@ -76,7 +82,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
|
||||
|
||||
#### What the model sees
|
||||
|
||||
After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it.
|
||||
After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp__<serverName>__<rawName>` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync — including the one after an automatic reconnect — replaces the generation; plugin disposal or an exhausted reconnect budget removes it.
|
||||
|
||||
#### Token effect
|
||||
|
||||
@@ -84,7 +90,7 @@ Data-dependent schema cost is paid on every request while the tools are register
|
||||
|
||||
#### KV Cache effect
|
||||
|
||||
Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token.
|
||||
Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token; a reconnect that recovers an unchanged list reproduces identical definitions and stays prefix-stable.
|
||||
|
||||
### Tool-call history and results
|
||||
|
||||
@@ -104,6 +110,6 @@ Append-only; newly visible content follows the reusable request prefix and does
|
||||
|
||||
- **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred.
|
||||
- **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles.
|
||||
- **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart.
|
||||
- **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request and through the SDK transport's own SSE-stream recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor.
|
||||
- **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred.
|
||||
- **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.
|
||||
|
||||
@@ -45,6 +45,10 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
| `headers` | http | 否 | 额外标头(例如认证 token) |
|
||||
| `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) |
|
||||
| `failOnStartupError` | 两者 | 否 | 初始连接或工具同步失败时拒绝插件激活(默认 `false`) |
|
||||
| `reconnect.enabled` | 两者 | 否 | 连接丢失后自动重新连接(默认 `true`) |
|
||||
| `reconnect.initialDelayMs` | 两者 | 否 | 首次重连延迟(毫秒);每次连续失败尝试翻倍(默认 500) |
|
||||
| `reconnect.maxDelayMs` | 两者 | 否 | 退避上限(毫秒);同时也是重置尝试预算所需的正常运行时长(默认 30000) |
|
||||
| `reconnect.maxAttempts` | 两者 | 否 | 每次中断期间连续失败尝试次数上限,超出后彻底放弃(默认 10) |
|
||||
|
||||
## 工具命名
|
||||
|
||||
@@ -62,7 +66,9 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
- 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。
|
||||
- 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。
|
||||
- Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。
|
||||
- 断开/崩溃时:不自动重新连接。已注册工具会一直保留到对插件执行 dispose(资源释放)或成功重新同步,针对已关闭传输的调用可能失败;请通过 HMR 重新加载或重启 Host 来重新连接。
|
||||
- 断开/崩溃时:supervisor 以指数退避(`reconnect.initialDelayMs` 逐次翻倍,上限 `reconnect.maxDelayMs`)重启原始服务器配置,成功后重新执行发现——恢复的世代会替换前一个,因此工具既不会重复也不会泄漏。中断期间最后一个正常世代保持注册;针对它的调用在恢复前会失败。
|
||||
- 重连按中断预算控制:连续失败达到 `reconnect.maxAttempts` 次后,该服务器的工具会被注销,重连停止,直到 HMR 重载或重启 Host。连接存活超过 `maxDelayMs` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。
|
||||
- 重连状态在日志中对用户可见:reconnecting(warn,含尝试次数和延迟)、recovered(info)、最终失败和 disabled-loss(error)。dispose(资源释放)会取消任何待执行的重连。设置 `reconnect.enabled: false` 时,连接丢失后工具保持注册但调用失败,直到重载——即手动恢复行为。
|
||||
|
||||
## 消费的服务
|
||||
|
||||
@@ -76,7 +82,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
|
||||
#### 模型看到的内容
|
||||
|
||||
初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp__<serverName>__<rawName>`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步会替换整个世代;对插件执行 dispose 会移除该世代。
|
||||
初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp__<serverName>__<rawName>`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步——包括自动重连后的同步——会替换整个世代;对插件执行 dispose(资源释放)或重连预算耗尽会移除该世代。
|
||||
|
||||
#### Token 影响
|
||||
|
||||
@@ -84,7 +90,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
|
||||
#### KV Cache 影响
|
||||
|
||||
只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效。
|
||||
只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效;恢复了未变列表的重连会生成完全相同的定义,前缀保持稳定。
|
||||
|
||||
### 工具调用历史与结果
|
||||
|
||||
@@ -104,6 +110,6 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
|
||||
|
||||
- **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。
|
||||
- **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。
|
||||
- **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。
|
||||
- **重连在传输关闭时触发**:崩溃的 stdio 子进程会触发重连;Streamable HTTP 失败通过每次请求以及 SDK 传输自身的 SSE(Server-Sent Events)流恢复机制暴露,因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。
|
||||
- **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。
|
||||
- **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@deepseek-ai/cordis": "workspace:^"
|
||||
},
|
||||
@@ -47,6 +48,7 @@
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
"@deepseek-ai/dsh-llm": "workspace:^",
|
||||
"@deepseek-ai/dsh-subprocess": "workspace:^",
|
||||
"@deepseek-ai/dsh-timeout": "workspace:^",
|
||||
"@deepseek-ai/dsh-tools": "workspace:^",
|
||||
"@modelcontextprotocol/server-everything": "^2026.7.4",
|
||||
"@modelcontextprotocol/server-filesystem": "^2026.7.4",
|
||||
|
||||
351
packages/mcp/mcp-client/src/connection.ts
Normal file
351
packages/mcp/mcp-client/src/connection.ts
Normal file
@@ -0,0 +1,351 @@
|
||||
/**
|
||||
* Connection supervisor: owns the MCP client/transport generations for one
|
||||
* plugin instance, keeps the harness tool registry in sync with the live
|
||||
* generation, and — when the connection drops — restarts the configured
|
||||
* server with bounded exponential backoff.
|
||||
*
|
||||
* One outage shares one attempt budget (`maxAttempts` consecutive failed
|
||||
* attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
|
||||
* connection that stays up past the stability window closes the outage, so
|
||||
* the next disconnect starts a fresh budget while a crash-looping server —
|
||||
* even one whose connects briefly succeed — still exhausts the cap instead of
|
||||
* restarting forever. Exhaustion unregisters the server's tools and stops;
|
||||
* disposal (including HMR) is the only way back from that state.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { createTransport } from './transport.ts'
|
||||
import { syncTools } from './tools.ts'
|
||||
import type { ToolBridgeOptions, ToolDisposers } from './tools.ts'
|
||||
import type { Config } from './index.ts'
|
||||
|
||||
/** Automatic reconnect policy for one MCP server connection. */
|
||||
export interface ReconnectConfig {
|
||||
/** Reconnect automatically after a lost connection (default true). */
|
||||
enabled?: boolean
|
||||
/** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */
|
||||
initialDelayMs?: number
|
||||
/** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */
|
||||
maxDelayMs?: number
|
||||
/** Consecutive failed attempts per outage before giving up for good (default 10). */
|
||||
maxAttempts?: number
|
||||
}
|
||||
|
||||
/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
|
||||
export const RECONNECT_DEFAULTS: Required<ReconnectConfig> = Object.freeze({
|
||||
enabled: true,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 30_000,
|
||||
maxAttempts: 10,
|
||||
})
|
||||
|
||||
// The SDK's stdio transport owns two two-second termination grace periods.
|
||||
// Keep one additional second for the process-close event that proves the old
|
||||
// generation is gone; timing out fails closed instead of overlapping children.
|
||||
const GENERATION_CLOSE_TIMEOUT_MS = 5_000
|
||||
|
||||
/** Fully resolved reconnect policy captured at plugin load. */
|
||||
export type ResolvedReconnectPolicy = Readonly<Required<ReconnectConfig>>
|
||||
|
||||
/**
|
||||
* The one explicit resolve step from raw reconnect config to the policy the
|
||||
* supervisor runs. Programmatic construction may bypass Schemastery
|
||||
* normalization, so every default and bound is re-judged here — misconfiguration
|
||||
* fails the plugin instance at load.
|
||||
*
|
||||
* @param config - Raw `reconnect` config; omission uses the defaults.
|
||||
* @param path - Diagnostic prefix naming the config location in thrown messages.
|
||||
* @returns The frozen resolved policy.
|
||||
*/
|
||||
export function resolveReconnectPolicy(config: ReconnectConfig | undefined, path: string): ResolvedReconnectPolicy {
|
||||
if (config !== undefined) {
|
||||
for (const key of Object.keys(config)) {
|
||||
if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`)
|
||||
}
|
||||
}
|
||||
const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled
|
||||
const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs
|
||||
const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs
|
||||
const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts
|
||||
/* jscpd:ignore-start — domain-specific delay validation parallels llm retry-policy; not extractable */
|
||||
if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) {
|
||||
throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`)
|
||||
}
|
||||
if (initialDelayMs > maxDelayMs) {
|
||||
throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`)
|
||||
}
|
||||
if (!Number.isInteger(maxAttempts) || maxAttempts < 1) {
|
||||
throw new Error(`${path}.maxAttempts must be a positive integer`)
|
||||
}
|
||||
/* jscpd:ignore-end */
|
||||
return Object.freeze({ enabled, initialDelayMs, maxDelayMs, maxAttempts })
|
||||
}
|
||||
|
||||
/** Result from the initial connection attempt, for startup-await semantics. */
|
||||
export interface ConnectionOutcome {
|
||||
/** If the initial connection or tool sync failed, the error; otherwise absent. */
|
||||
error?: unknown
|
||||
}
|
||||
|
||||
/** Handle for one plugin instance's supervised connection. */
|
||||
export interface ConnectionHandle {
|
||||
/**
|
||||
* Settles when the first connection attempt completes (success or failure).
|
||||
* The supervisor enters its reconnect loop regardless; the caller decides
|
||||
* whether a failed startup is fatal via `failOnStartupError`.
|
||||
*/
|
||||
ready: Promise<ConnectionOutcome>
|
||||
/**
|
||||
* Stop reconnection, close the live client, wait for the in-flight attempt
|
||||
* and queued tool syncs to quiesce, then unregister every tool this server
|
||||
* still owns.
|
||||
*/
|
||||
dispose(): Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the supervised connection for one MCP server and keep it alive per
|
||||
* the reconnect policy.
|
||||
*
|
||||
* @param ctx - Cordis context providing the `tools` registry and logger.
|
||||
* @param config - Resolved plugin config selecting the transport and server identity.
|
||||
* @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
|
||||
* @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
|
||||
*/
|
||||
export function startConnection(ctx: Context, config: Config, policy: ResolvedReconnectPolicy): ConnectionHandle {
|
||||
const label = `mcp-client(${config.serverName})`
|
||||
const opts: ToolBridgeOptions = {
|
||||
registrationFailure: 'contain',
|
||||
serverName: config.serverName,
|
||||
toolCallTimeoutMs: config.toolCallTimeoutMs,
|
||||
}
|
||||
// The initial sync uses 'throw' when failOnStartupError is configured, so
|
||||
// a registration conflict propagates to the startup-await path. Re-syncs
|
||||
// and reconnect syncs always contain conflicts.
|
||||
const startupOpts: ToolBridgeOptions = config.failOnStartupError
|
||||
? { ...opts, registrationFailure: 'throw' }
|
||||
: opts
|
||||
|
||||
let disposed = false
|
||||
/** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
|
||||
let client: Client | undefined
|
||||
/** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
|
||||
let clientClosed: Promise<void> | undefined
|
||||
/** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */
|
||||
let disposers: ToolDisposers = new Map()
|
||||
let reconnectTimer: NodeJS.Timeout | undefined
|
||||
/** Consecutive failed connection attempts within the current outage. */
|
||||
let failedAttempts = 0
|
||||
/** When the current generation finished connect + initial sync; undefined while down. */
|
||||
let connectedAt: number | undefined
|
||||
/** The real error from the first connection attempt, for startup-await diagnostics. */
|
||||
let firstAttemptError: unknown
|
||||
|
||||
/** A generation may act only while it is the current one on a live plugin. */
|
||||
const isCurrent = (generation: Client): boolean => !disposed && client === generation
|
||||
|
||||
/**
|
||||
* Serializes every syncTools call — initial syncs and notification re-syncs
|
||||
* across all generations — so two syncs can never interleave their
|
||||
* dispose-previous/register-next swap (which would double-dispose one
|
||||
* generation and leak another).
|
||||
*/
|
||||
let syncChain: Promise<void> = Promise.resolve()
|
||||
function enqueueSync(generation: Client, syncOpts: ToolBridgeOptions = opts): Promise<void> {
|
||||
const run = syncChain.then(async () => {
|
||||
if (!isCurrent(generation)) return
|
||||
disposers = await syncTools(generation, ctx, syncOpts, disposers)
|
||||
})
|
||||
// The chain tail must survive a failed sync; the enqueuing caller owns reporting.
|
||||
syncChain = run.catch(() => {})
|
||||
return run
|
||||
}
|
||||
|
||||
/** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
|
||||
function generationDown(generation: Client): void {
|
||||
if (!isCurrent(generation)) return
|
||||
client = undefined
|
||||
clientClosed = undefined
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
/** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
|
||||
function waitForClose(closed: Promise<void>): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => { resolve(false) }, GENERATION_CLOSE_TIMEOUT_MS)
|
||||
timeout.unref()
|
||||
void closed.then(() => {
|
||||
clearTimeout(timeout)
|
||||
resolve(true)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function scheduleReconnect(): void {
|
||||
const lostEstablishedConnection = connectedAt !== undefined
|
||||
if (!policy.enabled) {
|
||||
const message = lostEstablishedConnection
|
||||
? 'connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart'
|
||||
: 'connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect'
|
||||
ctx.logger.error(`${label}: ${message}`)
|
||||
return
|
||||
}
|
||||
// A connection that stayed up past the stability window (= maxDelayMs, the
|
||||
// longest backoff spacing) ended the previous outage: start a fresh budget.
|
||||
if (connectedAt !== undefined && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0
|
||||
connectedAt = undefined
|
||||
failedAttempts += 1
|
||||
if (failedAttempts > policy.maxAttempts) {
|
||||
// Enqueue the give-up disposal so it cannot race an in-flight sync's
|
||||
// phase-2 swap (which checks isCurrent inside the queue).
|
||||
syncChain = syncChain.then(() => {
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
disposers = new Map()
|
||||
})
|
||||
ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`)
|
||||
return
|
||||
}
|
||||
const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1))
|
||||
const action = lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying'
|
||||
ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`)
|
||||
reconnectTimer = setTimeout(() => {
|
||||
reconnectTimer = undefined
|
||||
settling = connectGeneration(false)
|
||||
}, delayMs)
|
||||
// An armed reconnect timer must never hold the process open on its own.
|
||||
reconnectTimer.unref()
|
||||
}
|
||||
|
||||
/**
|
||||
* One connection attempt: fresh transport + client (the MCP SDK binds a
|
||||
* Protocol to one transport for life), connect, then queue the initial tool
|
||||
* sync. The startup flag belongs to the attempt rather than the shared sync
|
||||
* queue, so an early notification cannot consume strict startup semantics.
|
||||
* Every failure funnels through {@link generationDown}; success arms the
|
||||
* onclose-driven disconnect path. Never rejects.
|
||||
*
|
||||
* @param startup - Whether this is the plugin's activation attempt.
|
||||
*/
|
||||
async function connectGeneration(startup: boolean): Promise<void> {
|
||||
const generation = new Client(
|
||||
{ name: 'dsh-mcp-client', version: '0.0.1' },
|
||||
{ capabilities: {} },
|
||||
)
|
||||
const closed: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
let attemptSettled = false
|
||||
let closeObserved = false
|
||||
const hasClosed = (): boolean => closeObserved
|
||||
client = generation
|
||||
clientClosed = closed.promise
|
||||
generation.onclose = () => {
|
||||
closeObserved = true
|
||||
closed.resolve()
|
||||
// A failed connect owns its close barrier in the catch path below. An
|
||||
// established generation can transition down directly from this signal.
|
||||
if (attemptSettled) generationDown(generation)
|
||||
}
|
||||
// Registered before connect so a list change during the initial sync is
|
||||
// queued behind it rather than dropped.
|
||||
generation.setNotificationHandler(
|
||||
ToolListChangedNotificationSchema,
|
||||
async () => {
|
||||
if (!isCurrent(generation)) return
|
||||
ctx.logger.info(`${label}: tool list changed, re-syncing`)
|
||||
try {
|
||||
await enqueueSync(generation)
|
||||
} catch (error) {
|
||||
// Fetch-phase failure: the previous generation is still registered
|
||||
// and `disposers` still owns it — keep serving the last good list.
|
||||
if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
try {
|
||||
await generation.connect(createTransport(config))
|
||||
if (hasClosed()) {
|
||||
attemptSettled = true
|
||||
generationDown(generation)
|
||||
return
|
||||
}
|
||||
await enqueueSync(generation, startup ? startupOpts : opts)
|
||||
} catch (error) {
|
||||
if (firstAttemptError === undefined) firstAttemptError = error
|
||||
// Disposal clears current ownership before it closes the generation, so
|
||||
// only a live supervisor reports an attempt failure.
|
||||
if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`)
|
||||
try { await generation.close() } catch { /* transport already gone */ }
|
||||
const quiesced = hasClosed() || await waitForClose(closed.promise)
|
||||
attemptSettled = true
|
||||
if (!isCurrent(generation)) return
|
||||
if (!quiesced) {
|
||||
client = undefined
|
||||
clientClosed = undefined
|
||||
ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`)
|
||||
return
|
||||
}
|
||||
generationDown(generation)
|
||||
return
|
||||
}
|
||||
attemptSettled = true
|
||||
if (hasClosed()) {
|
||||
generationDown(generation)
|
||||
return
|
||||
}
|
||||
if (!isCurrent(generation)) return
|
||||
connectedAt = Date.now()
|
||||
if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`)
|
||||
}
|
||||
|
||||
/** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
|
||||
let settling = connectGeneration(true)
|
||||
|
||||
// The ready promise settles when the first attempt finishes (regardless of
|
||||
// success). If the first attempt fails and reconnect is enabled, the
|
||||
// supervisor is already scheduling a retry — ready just reports the outcome.
|
||||
const ready: Promise<ConnectionOutcome> = settling.then(() => {
|
||||
// After settling: if client is set the initial connect+sync succeeded.
|
||||
// If not, the supervisor either scheduled a retry (error logged) or gave
|
||||
// up (error logged). Either way the outcome is reported with the real error.
|
||||
// Note: settling.then() is a microtask; stdio onclose is a macrotask — so
|
||||
// a server that crashes AFTER a successful initial sync cannot flip client
|
||||
// to undefined before this continuation runs.
|
||||
if (client !== undefined) return {}
|
||||
/* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */
|
||||
return { error: firstAttemptError ?? new Error(`${label}: initial connection failed`) }
|
||||
})
|
||||
|
||||
return {
|
||||
ready,
|
||||
async dispose(): Promise<void> {
|
||||
disposed = true
|
||||
if (reconnectTimer !== undefined) {
|
||||
clearTimeout(reconnectTimer)
|
||||
reconnectTimer = undefined
|
||||
}
|
||||
const current = client
|
||||
const currentClosed = clientClosed
|
||||
client = undefined
|
||||
clientClosed = undefined
|
||||
if (current !== undefined) {
|
||||
try { await current.close() } catch { /* transport already gone */ }
|
||||
if (currentClosed !== undefined && !await waitForClose(currentClosed)) {
|
||||
ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`)
|
||||
}
|
||||
}
|
||||
// Quiesce, don't just request it: the in-flight attempt enqueues its
|
||||
// sync before settling, so awaiting both leaves `disposers` final.
|
||||
await settling
|
||||
await syncChain
|
||||
for (const dispose of disposers.values()) dispose()
|
||||
disposers = new Map()
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -15,14 +15,14 @@
|
||||
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import z from '@deepseek-ai/schemastery'
|
||||
import { Client } from '@modelcontextprotocol/sdk/client/index.js'
|
||||
import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js'
|
||||
import { createTransport } from './transport.ts'
|
||||
import { syncTools } from './tools.ts'
|
||||
import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout'
|
||||
import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from './connection.ts'
|
||||
import type { ReconnectConfig } from './connection.ts'
|
||||
// Side-effect type import: declaration-merges `ctx.tools` onto Context.
|
||||
import type {} from '@deepseek-ai/dsh-tools'
|
||||
|
||||
export type { McpResult } from './tools.ts'
|
||||
export type { ReconnectConfig, ResolvedReconnectPolicy } from './connection.ts'
|
||||
|
||||
/** Cordis plugin name used by loader diagnostics. */
|
||||
export const name = 'mcp-client'
|
||||
@@ -68,6 +68,8 @@ export interface StdioConfig {
|
||||
toolCallTimeoutMs: 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. */
|
||||
reconnect?: ReconnectConfig
|
||||
}
|
||||
|
||||
/** Config for connecting to an MCP server over Streamable HTTP (SSE). */
|
||||
@@ -88,11 +90,20 @@ export interface StreamableHttpConfig {
|
||||
toolCallTimeoutMs: 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. */
|
||||
reconnect?: ReconnectConfig
|
||||
}
|
||||
|
||||
/** Configuration for one stdio or Streamable HTTP MCP server. */
|
||||
export type Config = StdioConfig | StreamableHttpConfig
|
||||
|
||||
const Reconnect: z<ReconnectConfig> = z.object({
|
||||
enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled),
|
||||
initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs),
|
||||
maxDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.maxDelayMs),
|
||||
maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts),
|
||||
})
|
||||
|
||||
export const Config = z.union([
|
||||
z.object({
|
||||
transport: z.const('stdio'),
|
||||
@@ -103,6 +114,7 @@ export const Config = z.union([
|
||||
cwd: z.string().default(''),
|
||||
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
||||
failOnStartupError: z.boolean().default(false),
|
||||
reconnect: Reconnect,
|
||||
}),
|
||||
z.object({
|
||||
transport: z.const('streamable-http'),
|
||||
@@ -111,6 +123,7 @@ export const Config = z.union([
|
||||
headers: z.dict(String).default({}),
|
||||
toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
|
||||
failOnStartupError: z.boolean().default(false),
|
||||
reconnect: Reconnect,
|
||||
}),
|
||||
]) as unknown as z<Config>
|
||||
|
||||
@@ -125,7 +138,12 @@ export const Config = z.union([
|
||||
* @returns startup readiness after connection and initial tool discovery settle.
|
||||
*/
|
||||
export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
// Reserve the namespace first: a duplicate `serverName` fails THIS instance
|
||||
// Fail loud at load: reconnect misconfiguration (including programmatic
|
||||
// construction that bypassed Schemastery) rejects THIS instance before any
|
||||
// effect registers.
|
||||
const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`)
|
||||
|
||||
// Reserve the namespace next: a duplicate `serverName` fails THIS instance
|
||||
// at load with an actionable error and leaves the earlier instance intact.
|
||||
ctx.effect(() => {
|
||||
let names = activeServerNames.get(ctx.root)
|
||||
@@ -142,58 +160,22 @@ export async function apply(ctx: Context, config: Config): Promise<void> {
|
||||
return () => void names.delete(config.serverName)
|
||||
}, 'mcp-client.serverName')
|
||||
|
||||
const transport = createTransport(config)
|
||||
const client = new Client(
|
||||
{ name: 'dsh-mcp-client', version: '0.0.1' },
|
||||
{ capabilities: {} },
|
||||
)
|
||||
// The supervisor owns the client/transport generations, the reconnect
|
||||
// loop, and the live tool registrations; disposal stops reconnection,
|
||||
// quiesces in-flight work, and unregisters the current generation.
|
||||
const connection = startConnection(ctx, config, reconnect)
|
||||
|
||||
const opts = {
|
||||
registrationFailure: 'contain' as const,
|
||||
serverName: config.serverName,
|
||||
toolCallTimeoutMs: config.toolCallTimeoutMs,
|
||||
}
|
||||
|
||||
// Connect and set up tools. `ready` always settles to an outcome so rollback
|
||||
// can close a partially opened client even when strict startup later rejects.
|
||||
// Its accessor returns the CURRENT disposer generation, so disposal always
|
||||
// unregisters the live set, not the first one.
|
||||
const ready = (async () => {
|
||||
await client.connect(transport)
|
||||
|
||||
let disposers = await syncTools(client, ctx, {
|
||||
...opts,
|
||||
registrationFailure: config.failOnStartupError ? 'throw' : 'contain',
|
||||
}, new Map())
|
||||
|
||||
client.setNotificationHandler(
|
||||
ToolListChangedNotificationSchema,
|
||||
async () => {
|
||||
ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`)
|
||||
try {
|
||||
disposers = await syncTools(client, ctx, opts, disposers)
|
||||
} catch (error) {
|
||||
// Fetch-phase failure: the previous generation is still registered
|
||||
// and `disposers` still owns it — keep serving the last good list.
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`)
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
return { getDisposers: () => disposers }
|
||||
})().catch((error: unknown) => {
|
||||
ctx.logger.error(`mcp-client(${config.serverName}): startup failed: ${String(error)}`)
|
||||
return { getDisposers: () => new Map<string, () => void>(), error }
|
||||
})
|
||||
|
||||
ctx.effect(() => async () => {
|
||||
const outcome = await ready
|
||||
for (const dispose of outcome.getDisposers().values()) dispose()
|
||||
try { await client.close() } catch { /* transport already gone */ }
|
||||
ctx.effect(() => {
|
||||
return () => connection.dispose()
|
||||
}, 'mcp-client.connection')
|
||||
|
||||
const outcome = await ready
|
||||
if ('error' in outcome && config.failOnStartupError) {
|
||||
// Block plugin activation on the initial connection + tool discovery so
|
||||
// Cordis consumers observe the tools immediately after the fiber activates.
|
||||
// When failOnStartupError is true, a failed initial attempt rejects the
|
||||
// fiber (Cordis rolls it back); otherwise the error is logged and the
|
||||
// supervisor enters its reconnect loop.
|
||||
const outcome = await connection.ready
|
||||
if (outcome.error !== undefined && config.failOnStartupError) {
|
||||
throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error })
|
||||
}
|
||||
}
|
||||
|
||||
@@ -124,6 +124,33 @@ describe('mcp-client plugin module exports', () => {
|
||||
} as never)
|
||||
expect(resolved.serverName).toBe('github-prod_1')
|
||||
})
|
||||
|
||||
it('Config schema materializes reconnect defaults and merges partial overrides', () => {
|
||||
const omitted = ConfigSchema({
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'echo',
|
||||
} as never)
|
||||
expect(omitted.reconnect).toEqual({ enabled: true, initialDelayMs: 500, maxDelayMs: 30_000, maxAttempts: 10 })
|
||||
|
||||
const partial = ConfigSchema({
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'echo',
|
||||
reconnect: { initialDelayMs: 100 },
|
||||
} as never)
|
||||
expect(partial.reconnect).toEqual({ enabled: true, initialDelayMs: 100, maxDelayMs: 30_000, maxAttempts: 10 })
|
||||
})
|
||||
|
||||
it('Config schema rejects an invalid reconnect block', () => {
|
||||
// schemastery unions wrap branch errors, so assert the throw only.
|
||||
expect(() => ConfigSchema({
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'echo',
|
||||
reconnect: { maxAttempts: 0 },
|
||||
} as never)).toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('apply (plugin lifecycle)', () => {
|
||||
@@ -132,7 +159,10 @@ describe('apply (plugin lifecycle)', () => {
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
mockConnect.mockResolvedValue(undefined)
|
||||
mockClose.mockResolvedValue(undefined)
|
||||
mockClose.mockImplementation(function (this: { onclose?: () => void }) {
|
||||
this.onclose?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
mockListTools.mockResolvedValue({
|
||||
tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }],
|
||||
nextCursor: undefined,
|
||||
@@ -216,19 +246,23 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
|
||||
// Disposal exercises the empty fallback accessor: nothing to unregister,
|
||||
// close still attempted, no throw.
|
||||
// Disposal cancels the scheduled reconnect attempt: nothing to
|
||||
// unregister, close already attempted by the failed attempt, no throw.
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(50)
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects activation and still closes the client when startup failure is configured as fatal', async () => {
|
||||
mockConnect.mockRejectedValue(new Error('connection refused'))
|
||||
const cause = new Error('connection refused')
|
||||
mockConnect.mockRejectedValue(cause)
|
||||
await expect(apply(ctx, {
|
||||
...stdioConfig,
|
||||
failOnStartupError: true,
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
})).rejects.toMatchObject({
|
||||
message: 'mcp-client(srv): initial connection or tool synchronization failed',
|
||||
cause,
|
||||
})
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
@@ -258,6 +292,32 @@ describe('apply (plugin lifecycle)', () => {
|
||||
expect(mockClose).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('preserves strict startup registration when list_changed arrives before connect resolves', async () => {
|
||||
ctx.tools.register({
|
||||
name: 'mcp__srv__remote',
|
||||
description: 'Foreign squatter',
|
||||
parameters: { type: 'object' },
|
||||
output: {
|
||||
schema: { type: 'string' },
|
||||
render: (_args, value) => [{ type: 'text', text: value as string }],
|
||||
},
|
||||
execute: async () => 'foreign',
|
||||
})
|
||||
mockConnect.mockImplementation(async () => {
|
||||
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
await handler()
|
||||
})
|
||||
|
||||
await expect(apply(ctx, {
|
||||
...stdioConfig,
|
||||
failOnStartupError: true,
|
||||
})).rejects.toThrow('initial connection or tool synchronization failed')
|
||||
|
||||
expect(mockListTools).toHaveBeenCalledTimes(2)
|
||||
expect(ctx.tools.get('mcp__srv__remote')?.description).toBe('Foreign squatter')
|
||||
await ctx.fiber.dispose()
|
||||
})
|
||||
|
||||
it('re-syncs tools on ToolListChanged notification', async () => {
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
@@ -311,7 +371,10 @@ describe('apply (plugin lifecycle)', () => {
|
||||
})
|
||||
|
||||
it('effect disposer handles client.close failure gracefully', async () => {
|
||||
mockClose.mockRejectedValue(new Error('already closed'))
|
||||
mockClose.mockImplementation(function (this: { onclose?: () => void }) {
|
||||
this.onclose?.()
|
||||
return Promise.reject(new Error('already closed'))
|
||||
})
|
||||
|
||||
await apply(ctx, stdioConfig)
|
||||
|
||||
|
||||
@@ -51,6 +51,17 @@ server.registerTool('image', {
|
||||
],
|
||||
}))
|
||||
|
||||
server.registerTool('crash', {
|
||||
title: 'Crash Tool',
|
||||
description: 'Replies, then exits the server process (crash-recovery test).',
|
||||
inputSchema: {},
|
||||
}, async () => {
|
||||
// Exit AFTER the response flushes so the caller observes a clean result
|
||||
// followed by a transport close, like a real post-reply crash.
|
||||
setTimeout(() => process.exit(7), 25)
|
||||
return { content: [{ type: 'text', text: 'crashing' }] }
|
||||
})
|
||||
|
||||
// Dotted name: legal in MCP, illegal in the DeepSeek function-name contract.
|
||||
// Exercises the bridge's normalize-and-hash public-name path end to end.
|
||||
server.registerTool('admin.reset', {
|
||||
|
||||
@@ -13,7 +13,7 @@ import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
|
||||
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
|
||||
@@ -200,6 +200,87 @@ describe('fixture server — disposal', () => {
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
describe('fixture server — crash recovery', () => {
|
||||
function crashConfig(serverName: string, reconnect: NonNullable<Config['reconnect']>): Config {
|
||||
return {
|
||||
transport: 'stdio',
|
||||
serverName,
|
||||
command: process.execPath,
|
||||
args: [fixtureServerPath],
|
||||
env: {},
|
||||
cwd: packageDir,
|
||||
toolCallTimeoutMs: 15_000,
|
||||
failOnStartupError: false,
|
||||
reconnect,
|
||||
}
|
||||
}
|
||||
|
||||
it('auto-reconnects after a stdio crash and serves tool calls again', async () => {
|
||||
const ctx = await mountRegistry()
|
||||
await apply(ctx, crashConfig('crashy', { initialDelayMs: 50, maxDelayMs: 500, maxAttempts: 40 }))
|
||||
|
||||
const before = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 2, b: 3 },
|
||||
})
|
||||
expect(textOf(before.content[0])).toBe('5')
|
||||
|
||||
// The crash tool replies, then kills the real child process.
|
||||
const crash = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: nextCallId(), name: 'mcp__crashy__crash', arguments: {},
|
||||
})
|
||||
expect(crash.isError).toBe(false)
|
||||
|
||||
// Recovery is proven by the world: a post-crash call round-trips through
|
||||
// the respawned server process.
|
||||
await vi.waitFor(async () => {
|
||||
const after = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 20, b: 22 },
|
||||
})
|
||||
expect(after.isError).toBe(false)
|
||||
expect(textOf(after.content[0])).toBe('42')
|
||||
}, { timeout: 15_000, interval: 250 })
|
||||
|
||||
// The recovered generation replaced the dead one: no duplicates, no leak.
|
||||
const addEntries = ctx.tools.schemas().map(s => s.name).filter(name => name === 'mcp__crashy__add')
|
||||
expect(addEntries).toHaveLength(1)
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(200)
|
||||
}, 30_000)
|
||||
|
||||
it('plugin unload during an outage stops reconnection and unregisters tools', async () => {
|
||||
const ctx = await mountRegistry()
|
||||
const fiber = ctx.plugin(
|
||||
{ name: 'mcp-client', inject: ['tools'], apply },
|
||||
crashConfig('ephemeral', { initialDelayMs: 8_000, maxDelayMs: 8_000, maxAttempts: 5 }),
|
||||
)
|
||||
// Cordis awaits async apply() as startup work; wait for it.
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__ephemeral__add')).toBeDefined() }, { timeout: 20_000 })
|
||||
|
||||
const crash = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: nextCallId(), name: 'mcp__ephemeral__crash', arguments: {},
|
||||
})
|
||||
expect(crash.isError).toBe(false)
|
||||
|
||||
// Give the transport close a moment to land the supervisor in its 8s
|
||||
// backoff wait, then unload: disposal must not sit out the backoff.
|
||||
await sleep(300)
|
||||
const started = Date.now()
|
||||
await fiber.dispose()
|
||||
expect(Date.now() - started).toBeLessThan(4_000)
|
||||
|
||||
expect(ctx.tools.get('mcp__ephemeral__add')).toBeUndefined()
|
||||
await sleep(200)
|
||||
expect(ctx.tools.get('mcp__ephemeral__add')).toBeUndefined()
|
||||
|
||||
await ctx.fiber.dispose()
|
||||
}, 30_000)
|
||||
})
|
||||
|
||||
// ---- @modelcontextprotocol/server-everything ----
|
||||
|
||||
describe('server-everything — official test server', () => {
|
||||
|
||||
521
packages/mcp/mcp-client/tests/reconnect.spec.ts
Normal file
521
packages/mcp/mcp-client/tests/reconnect.spec.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
/**
|
||||
* Tests for the mcp-client connection supervisor: crash-driven reconnection
|
||||
* with bounded backoff, generation-safe tool re-registration, the failure
|
||||
* cap, the stability-window budget reset, and disposal stopping reconnection.
|
||||
* Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites.
|
||||
*/
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { Context } from '@deepseek-ai/cordis'
|
||||
import SystemPrompt from '@deepseek-ai/dsh-system-prompt'
|
||||
import ToolRegistry from '@deepseek-ai/dsh-tools'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import type { Config } from '@deepseek-ai/dsh-mcp-client'
|
||||
|
||||
// ---- Mock MCP SDK ----
|
||||
|
||||
// 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 = vi.fn<() => Promise<void>>()
|
||||
const mockClose = vi.fn<() => Promise<void>>()
|
||||
const mockListTools = vi.fn<(_params?: Record<string, unknown>) => Promise<unknown>>()
|
||||
const mockCallTool = vi.fn<(
|
||||
_params?: Record<string, unknown>, _compatibilitySchema?: unknown, _options?: unknown,
|
||||
) => Promise<unknown>>()
|
||||
const mockSetNotificationHandler = vi.fn()
|
||||
const mockRequest = vi.fn(async (
|
||||
request: { method: string; params?: Record<string, unknown> },
|
||||
_schema: unknown,
|
||||
options?: unknown,
|
||||
): Promise<unknown> => {
|
||||
if (request.method === 'tools/list') return await mockListTools(request.params)
|
||||
if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options)
|
||||
throw new Error(`unexpected MCP request: ${request.method}`)
|
||||
})
|
||||
class MockClient {
|
||||
onclose: (() => void) | undefined
|
||||
connect = mockConnect
|
||||
close = mockClose
|
||||
request = mockRequest
|
||||
setNotificationHandler = mockSetNotificationHandler
|
||||
constructor() { instances.push(this) }
|
||||
}
|
||||
const instances: MockClient[] = []
|
||||
return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances }
|
||||
})
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({
|
||||
Client: MockClient,
|
||||
}))
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({
|
||||
StdioClientTransport: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({
|
||||
StreamableHTTPClientTransport: vi.fn(),
|
||||
}))
|
||||
|
||||
// vi.mock is hoisted above static imports, so the modules under test see the
|
||||
// mocked SDK even through a static import.
|
||||
import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts'
|
||||
import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from '@deepseek-ai/dsh-mcp-client/src/connection.ts'
|
||||
|
||||
// ---- Helpers ----
|
||||
|
||||
const testToolSignal = new AbortController().signal
|
||||
|
||||
async function mountRegistry(): Promise<Context> {
|
||||
const ctx = new Context()
|
||||
await ctx.plugin(SystemPrompt)
|
||||
await ctx.plugin(ToolRegistry)
|
||||
return ctx
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
// Annotated binding (not withResolvers<void>()): the tests lint layer runs
|
||||
// no-invalid-void-type with default options, which rejects the explicit
|
||||
// type argument in call position but accepts the inferred form.
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
setTimeout(gate.resolve, ms)
|
||||
return gate.promise
|
||||
}
|
||||
|
||||
/** Capture the supervisor's logger lines by level on one context. */
|
||||
function captureLogs(ctx: Context): { warns: string[]; errors: string[]; infos: string[] } {
|
||||
const warns: string[] = []
|
||||
const errors: string[] = []
|
||||
const infos: string[] = []
|
||||
ctx.logger.warn = ((message: unknown) => { warns.push(String(message)) }) as typeof ctx.logger.warn
|
||||
ctx.logger.error = ((message: unknown) => { errors.push(String(message)) }) as typeof ctx.logger.error
|
||||
ctx.logger.info = ((message: unknown) => { infos.push(String(message)) }) as typeof ctx.logger.info
|
||||
return { warns, errors, infos }
|
||||
}
|
||||
|
||||
function stdioConfig(reconnect?: Config['reconnect']): Config {
|
||||
return {
|
||||
transport: 'stdio',
|
||||
serverName: 'srv',
|
||||
command: 'echo',
|
||||
args: [],
|
||||
env: {},
|
||||
cwd: '',
|
||||
toolCallTimeoutMs: 60_000,
|
||||
failOnStartupError: false,
|
||||
...reconnect === undefined ? {} : { reconnect },
|
||||
}
|
||||
}
|
||||
|
||||
/** The tool list the mock server advertises after a successful (re)connect. */
|
||||
function listing(...names: string[]): { tools: { name: string; inputSchema: { type: string } }[]; nextCursor: undefined } {
|
||||
return {
|
||||
tools: names.map(name => ({ name, inputSchema: { type: 'object' } })),
|
||||
nextCursor: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let callSeq = 0
|
||||
function nextCallId(): CallId {
|
||||
return CallId(`reconnect-${++callSeq}`)
|
||||
}
|
||||
|
||||
// ---- Tests ----
|
||||
|
||||
describe('reconnect supervisor', () => {
|
||||
let ctx: Context
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks()
|
||||
instances.length = 0
|
||||
mockConnect.mockResolvedValue(undefined)
|
||||
mockClose.mockImplementation(function (this: { onclose?: () => void }) {
|
||||
this.onclose?.()
|
||||
return Promise.resolve()
|
||||
})
|
||||
mockListTools.mockResolvedValue(listing('remote'))
|
||||
mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] })
|
||||
ctx = await mountRegistry()
|
||||
})
|
||||
|
||||
it('reconnects after a transport close, re-syncs tools through the new generation, and serves calls', async () => {
|
||||
const { warns, infos } = captureLogs(ctx)
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 5, maxDelayMs: 40, maxAttempts: 5 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
expect(instances).toHaveLength(1)
|
||||
|
||||
// The recovered server advertises a different list: the swap must neither
|
||||
// duplicate nor leak the pre-crash generation.
|
||||
mockListTools.mockResolvedValue(listing('revived'))
|
||||
instances[0]!.onclose?.()
|
||||
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__revived')).toBeDefined() })
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(mockConnect).toHaveBeenCalledTimes(2)
|
||||
|
||||
// Post-recovery calls execute through the re-registered definition.
|
||||
const result = await ctx.tools.execute({
|
||||
signal: testToolSignal,
|
||||
callId: nextCallId(), name: 'mcp__srv__revived', arguments: {},
|
||||
})
|
||||
expect(result.isError).toBe(false)
|
||||
|
||||
// User-visible state: reconnecting and recovered are distinct lines.
|
||||
expect(warns.some(line => line.includes('reconnecting in 5ms (attempt 1/5)'))).toBe(true)
|
||||
expect(infos.some(line => line.includes('reconnected and re-synced tools'))).toBe(true)
|
||||
|
||||
// A late close signal from the replaced generation is ignored.
|
||||
instances[0]!.onclose?.()
|
||||
await sleep(30)
|
||||
expect(instances).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('stops at the failure cap, unregisters the tools, and reports final failure', async () => {
|
||||
const { warns, errors } = captureLogs(ctx)
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
mockConnect.mockRejectedValue(new Error('server gone'))
|
||||
// A failing close on the failed attempt's cleanup must not break the loop.
|
||||
mockClose.mockImplementation(function (this: { onclose?: () => void }) {
|
||||
this.onclose?.()
|
||||
return Promise.reject(new Error('already closed'))
|
||||
})
|
||||
instances[0]!.onclose?.()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(errors.some(line => line.includes('giving up after 2 consecutive failed reconnect attempts'))).toBe(true)
|
||||
})
|
||||
// Stale tools do not leak past final failure.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
// Initial connect + exactly maxAttempts reconnect attempts.
|
||||
expect(mockConnect).toHaveBeenCalledTimes(3)
|
||||
expect(warns.some(line => line.includes('connection attempt failed: Error: server gone'))).toBe(true)
|
||||
expect(warns.some(line => line.includes('connection failed; retrying in 4ms (attempt 2/2)'))).toBe(true)
|
||||
await sleep(30)
|
||||
expect(mockConnect).toHaveBeenCalledTimes(3)
|
||||
})
|
||||
|
||||
it('gives up behind an in-flight re-sync and removes the generation it publishes', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 1 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
|
||||
mockListTools.mockImplementation(() => gate.promise)
|
||||
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
const resync = handler()
|
||||
await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
|
||||
|
||||
mockConnect.mockRejectedValue(new Error('server gone'))
|
||||
instances[0]!.onclose?.()
|
||||
await vi.waitFor(() => {
|
||||
expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true)
|
||||
})
|
||||
|
||||
gate.resolve(listing('late'))
|
||||
await resync
|
||||
await vi.waitFor(() => {
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
expect(ctx.tools.get('mcp__srv__late')).toBeUndefined()
|
||||
})
|
||||
expect(mockConnect).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('does not start a replacement until a failed generation reports that it closed', async () => {
|
||||
const { warns } = captureLogs(ctx)
|
||||
mockConnect.mockRejectedValueOnce(new Error('initialize failed'))
|
||||
// Model the SDK's fire-and-forget close after initialize fails: the
|
||||
// harness's second close call returns, but the child has not exited yet.
|
||||
mockClose.mockResolvedValue(undefined)
|
||||
|
||||
const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
||||
await vi.waitFor(() => { expect(mockClose).toHaveBeenCalled() })
|
||||
await sleep(30)
|
||||
expect(instances).toHaveLength(1)
|
||||
|
||||
instances[0]!.onclose?.()
|
||||
await applying
|
||||
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
||||
expect(warns.some(line => line.includes('connection failed; retrying in 2ms (attempt 1/2)'))).toBe(true)
|
||||
})
|
||||
|
||||
it('stops reconnecting when a failed generation never reports that it closed', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { errors } = captureLogs(ctx)
|
||||
mockConnect.mockRejectedValue(new Error('initialize failed'))
|
||||
mockClose.mockResolvedValue(undefined)
|
||||
|
||||
const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
await applying
|
||||
|
||||
expect(instances).toHaveLength(1)
|
||||
expect(errors.some(line => line.includes('reconnect stopped to avoid overlapping server processes'))).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('suppresses retry reporting when disposal owns a pending connect rejection', async () => {
|
||||
const { warns } = captureLogs(ctx)
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
mockConnect.mockImplementation(() => gate.promise)
|
||||
const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
|
||||
await vi.waitFor(() => { expect(instances).toHaveLength(1) })
|
||||
|
||||
const disposing = handle.dispose()
|
||||
gate.reject(new Error('disposed connect'))
|
||||
await disposing
|
||||
await handle.ready
|
||||
|
||||
expect(warns.some(line => line.includes('connection attempt failed'))).toBe(false)
|
||||
expect(instances).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('bounds disposal while a resolving generation never reports that it closed', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const { errors } = captureLogs(ctx)
|
||||
const gate: PromiseWithResolvers<void> = Promise.withResolvers()
|
||||
mockConnect.mockImplementation(() => gate.promise)
|
||||
mockClose.mockResolvedValue(undefined)
|
||||
const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect'))
|
||||
await vi.advanceTimersByTimeAsync(0)
|
||||
|
||||
const disposing = handle.dispose()
|
||||
await vi.advanceTimersByTimeAsync(5_000)
|
||||
gate.resolve()
|
||||
await disposing
|
||||
|
||||
expect(mockListTools).not.toHaveBeenCalled()
|
||||
expect(errors.some(line => line.includes('server shutdown may be incomplete'))).toBe(true)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('dispose during the backoff wait cancels the pending reconnect', async () => {
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 60_000, maxDelayMs: 60_000, maxAttempts: 5 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
instances[0]!.onclose?.()
|
||||
// Now waiting out a 60s backoff; disposal must return promptly anyway.
|
||||
await ctx.fiber.dispose()
|
||||
await sleep(30)
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1)
|
||||
expect(instances).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('a transport close after dispose schedules nothing', async () => {
|
||||
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig())
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
await fiber.dispose()
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
|
||||
// The disposer's client.close() fires onclose in the real SDK.
|
||||
instances[0]!.onclose?.()
|
||||
await sleep(30)
|
||||
expect(instances).toHaveLength(1)
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('reconnect disabled keeps the registered tools and reports manual recovery', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
await apply(ctx, stdioConfig({ enabled: false }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
instances[0]!.onclose?.()
|
||||
await sleep(30)
|
||||
expect(mockConnect).toHaveBeenCalledTimes(1)
|
||||
// Pre-reconnect contract: the generation stays registered until disposal.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeDefined()
|
||||
expect(errors.some(line => line.includes('connection lost and reconnect is disabled'))).toBe(true)
|
||||
})
|
||||
it('reconnect disabled after a failed initial connect reports no registered tools', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
mockConnect.mockRejectedValue(new Error('refused'))
|
||||
await apply(ctx, stdioConfig({ enabled: false }))
|
||||
await sleep(30)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
expect(errors.some(line => line.includes('connection failed and reconnect is disabled'))).toBe(true)
|
||||
expect(errors.some(line => line.includes('no tools were registered'))).toBe(true)
|
||||
})
|
||||
|
||||
it('an uptime past the stability window resets the attempt budget', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 30, maxAttempts: 1 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
instances[0]!.onclose?.()
|
||||
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
// Outlive the stability window (= maxDelayMs), then crash again: the
|
||||
// budget restarts at attempt 1 instead of exceeding maxAttempts.
|
||||
await sleep(40)
|
||||
instances[1]!.onclose?.()
|
||||
await vi.waitFor(() => { expect(instances).toHaveLength(3) })
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
expect(errors).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('a crash loop with briefly successful connects still exhausts the cap', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 10_000, maxAttempts: 1 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
// Crash, recover (attempt 1 of 1), crash again well inside the stability
|
||||
// window: the successful connect must not launder the budget.
|
||||
instances[0]!.onclose?.()
|
||||
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
instances[1]!.onclose?.()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true)
|
||||
})
|
||||
expect(instances).toHaveLength(2)
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a connect rejection racing its own transport close schedules exactly one retry per attempt', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 3 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
// Each reconnect attempt sees the stdio transport die (onclose) AND its
|
||||
// connect() reject — the real SDK emits both for a spawn failure.
|
||||
mockConnect.mockImplementation(async () => {
|
||||
instances.at(-1)!.onclose?.()
|
||||
throw new Error('spawn failed')
|
||||
})
|
||||
instances[0]!.onclose?.()
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(errors.some(line => line.includes('giving up after 3 consecutive failed reconnect attempts'))).toBe(true)
|
||||
})
|
||||
// Initial generation + exactly one generation per budgeted attempt: a
|
||||
// double-scheduled retry would create more.
|
||||
expect(instances).toHaveLength(4)
|
||||
expect(errors.filter(line => line.includes('giving up')).length).toBe(1)
|
||||
})
|
||||
|
||||
it('a transport that closes during a resolving connect registers nothing from the dead generation', async () => {
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
expect(mockListTools).toHaveBeenCalledTimes(1)
|
||||
|
||||
mockConnect.mockImplementation(async () => {
|
||||
instances.at(-1)!.onclose?.()
|
||||
})
|
||||
instances[0]!.onclose?.()
|
||||
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() })
|
||||
// The dead generations never reached tool discovery.
|
||||
expect(mockListTools).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('dispose during an in-flight initial sync quiesces without leaking tools', async () => {
|
||||
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
// Block the reconnect attempt's tool discovery until after dispose starts.
|
||||
const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
|
||||
mockListTools.mockImplementation(() => gate.promise)
|
||||
instances[0]!.onclose?.()
|
||||
await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const disposing = fiber.dispose()
|
||||
await sleep(10)
|
||||
gate.resolve(listing('late'))
|
||||
await disposing
|
||||
|
||||
// The late sync's swap ran, then disposal unregistered its result: no
|
||||
// generation survives the plugin.
|
||||
expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined()
|
||||
expect(ctx.tools.get('mcp__srv__late')).toBeUndefined()
|
||||
})
|
||||
|
||||
it('a re-sync failing because dispose closed the transport stays silent', async () => {
|
||||
const { errors } = captureLogs(ctx)
|
||||
const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig())
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
const gate: PromiseWithResolvers<unknown> = Promise.withResolvers()
|
||||
mockListTools.mockImplementation(() => gate.promise)
|
||||
const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
const resync = handler()
|
||||
await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) })
|
||||
|
||||
const disposing = fiber.dispose()
|
||||
await sleep(10)
|
||||
gate.reject(new Error('Connection closed'))
|
||||
await disposing
|
||||
await resync
|
||||
|
||||
expect(errors.some(line => line.includes('tool re-sync failed'))).toBe(false)
|
||||
})
|
||||
|
||||
it('a stale notification handler from a replaced generation is ignored', async () => {
|
||||
await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 }))
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
|
||||
instances[0]!.onclose?.()
|
||||
await vi.waitFor(() => { expect(instances).toHaveLength(2) })
|
||||
await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() })
|
||||
const listCalls = mockListTools.mock.calls.length
|
||||
|
||||
const staleHandler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise<void>
|
||||
await staleHandler()
|
||||
expect(mockListTools).toHaveBeenCalledTimes(listCalls)
|
||||
})
|
||||
})
|
||||
|
||||
// ---- Policy resolution ----
|
||||
|
||||
describe('resolveReconnectPolicy', () => {
|
||||
const path = 'mcp-client(srv): reconnect'
|
||||
|
||||
it('resolves omission to the defaults, frozen', () => {
|
||||
const policy = resolveReconnectPolicy(undefined, path)
|
||||
expect(policy).toEqual(RECONNECT_DEFAULTS)
|
||||
expect(Object.isFrozen(policy)).toBe(true)
|
||||
})
|
||||
|
||||
it('keeps explicit values', () => {
|
||||
expect(resolveReconnectPolicy(
|
||||
{ enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 },
|
||||
path,
|
||||
)).toEqual({ enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 })
|
||||
})
|
||||
|
||||
it('rejects unknown keys', () => {
|
||||
expect(() => resolveReconnectPolicy({ jitterRatio: 0.5 } as never, path))
|
||||
.toThrow(/reconnect\.jitterRatio is not a reconnect option/)
|
||||
})
|
||||
|
||||
it('rejects out-of-range delays', () => {
|
||||
expect(() => resolveReconnectPolicy({ initialDelayMs: 0 }, path)).toThrow(/initialDelayMs must be a positive finite number/)
|
||||
expect(() => resolveReconnectPolicy({ initialDelayMs: Number.POSITIVE_INFINITY }, path)).toThrow(/initialDelayMs/)
|
||||
expect(() => resolveReconnectPolicy({ maxDelayMs: -1 }, path)).toThrow(/maxDelayMs must be a positive finite number/)
|
||||
})
|
||||
|
||||
it('rejects an initial delay above the ceiling', () => {
|
||||
expect(() => resolveReconnectPolicy({ initialDelayMs: 100, maxDelayMs: 5 }, path))
|
||||
.toThrow(/initialDelayMs must be less than or equal to maxDelayMs/)
|
||||
})
|
||||
|
||||
it('rejects non-positive-integer attempt caps', () => {
|
||||
expect(() => resolveReconnectPolicy({ maxAttempts: 0 }, path)).toThrow(/maxAttempts must be a positive integer/)
|
||||
expect(() => resolveReconnectPolicy({ maxAttempts: 1.5 }, path)).toThrow(/maxAttempts must be a positive integer/)
|
||||
})
|
||||
|
||||
it('apply fails loud at load on a misconfigured reconnect', async () => {
|
||||
const ctx = await mountRegistry()
|
||||
await expect(apply(ctx, stdioConfig({ initialDelayMs: 100, maxDelayMs: 5 })))
|
||||
.rejects.toThrow(/initialDelayMs must be less than or equal to maxDelayMs/)
|
||||
})
|
||||
})
|
||||
@@ -26,6 +26,9 @@
|
||||
},
|
||||
{
|
||||
"path": "../../support/invariants"
|
||||
},
|
||||
{
|
||||
"path": "../../util/timeout"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user