Merge remote-tracking branch 'origin/master' into feature/workspace-picker-composer

This commit is contained in:
NI0317
2026-08-11 11:53:49 +08:00
24 changed files with 1251 additions and 98 deletions

View File

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

View File

@@ -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.

View File

@@ -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` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。
- 重连状态在日志中对用户可见reconnectingwarn含尝试次数和延迟、recoveredinfo、最终失败和 disabled-losserror。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 传输自身的 SSEServer-Sent Events流恢复机制暴露因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn
- **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。
- **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`

View File

@@ -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",

View 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()
},
}
}

View File

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

View File

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

View File

@@ -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', {

View File

@@ -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', () => {

View 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/)
})
})

View File

@@ -26,6 +26,9 @@
},
{
"path": "../../support/invariants"
},
{
"path": "../../util/timeout"
}
]
}