Merge remote-tracking branch 'origin/master' into worktree/web-background-tasks-display-258f7e
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 .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md
|
||||
2026-07-07-mcp-client-plugin.md: 756a5c4dc9f1152ecb1955d93b8dc47fcf07c661
|
||||
2026-07-07-mcp-client-plugin.zh.md: 9f45203c479379087c5a19eccce3ab7a339d4ed0
|
||||
2026-07-07-mcp-client-plugin.md: 077d978d8815d759574f89ed524ab3bc8c24a267
|
||||
2026-07-07-mcp-client-plugin.zh.md: 8f58c9359ca8447717cc353f97f1333370fa6fda
|
||||
@@ -153,13 +153,7 @@ Build the child environment from the subprocess seam's shared `scrubbedParentEnv
|
||||
|
||||
### Disconnection / crash
|
||||
|
||||
No auto-reconnect. If the MCP server process exits or the transport closes:
|
||||
|
||||
1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers).
|
||||
2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`.
|
||||
3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness.
|
||||
|
||||
This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry."
|
||||
A per-instance connection supervisor reconnects automatically after a lost connection with bounded exponential backoff and a per-outage attempt budget, re-running discovery on success; exhaustion unregisters the server's tools and stops until reload. The [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) owns that decision, including the `reconnect` config block and the `reconnect.enabled: false` opt-out that restores manual HMR/restart recovery.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
@@ -173,7 +167,7 @@ Rejected. There is no foreseeable alternative MCP client implementation — MCP
|
||||
|
||||
### Auto-reconnect with exponential backoff
|
||||
|
||||
Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed.
|
||||
Rejected for v1: it added a partial-availability state (tools registered but temporarily non-functional), and stdio crashes often indicate configuration problems retrying cannot fix; HMR was the recovery path. Operational feedback reversed the deferral — the [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) implements it with a bounded per-outage budget and an opt-out.
|
||||
|
||||
### Bridge Resources and Prompts
|
||||
|
||||
@@ -211,4 +205,4 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex
|
||||
- **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent.
|
||||
- **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's.
|
||||
- **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level.
|
||||
- Crash recovery is manual (HMR edit or restart) — accepted for v1; a `reconnect` config remains open as future work.
|
||||
- Crash recovery is automatic within the [reconnect budget](2026-08-06-mcp-client-auto-reconnect.md); manual reload remains the path after exhaustion or with `reconnect.enabled: false`.
|
||||
@@ -153,13 +153,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
|
||||
|
||||
### 断连 / 崩溃
|
||||
|
||||
不自动重连。如果 MCP 服务器进程退出或传输层关闭:
|
||||
|
||||
1. effect dispose → 所有已注册工具被注销(fiber 作用域的 disposer)。
|
||||
2. 后续模型对这些工具的调用 → `ToolNotFoundError` → `isError: true`。
|
||||
3. 恢复:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。
|
||||
|
||||
这与 ACP subagent 模式一致:「崩溃即终态,报告错误,清理资源,不重试。」
|
||||
每个实例的连接监督器在连接丢失后以有界指数退避和单次故障尝试预算自动重连,成功后重新执行发现流程;尝试耗尽则注销该服务器的工具并停止,直到重新加载。[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.md) 拥有该决策,包括 `reconnect` 配置块和恢复手动 HMR/重启恢复的 `reconnect.enabled: false` opt-out。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
@@ -173,7 +167,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp
|
||||
|
||||
### 指数退避自动重连
|
||||
|
||||
v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,可在未来作为 `reconnect: boolean` 配置项添加。
|
||||
v1 否决:引入了部分可用状态(工具已注册但暂时不可用),且 stdio 崩溃往往表明配置问题,重试无法修复;HMR 曾是恢复路径。运营反馈扭转了该延期决定——[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.md) 以有界的单次故障预算和 opt-out 实现了自动重连。
|
||||
|
||||
### 桥接 Resources 和 Prompts
|
||||
|
||||
@@ -211,4 +205,4 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha
|
||||
- **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。
|
||||
- **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。
|
||||
- **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。
|
||||
- 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置作为未来工作保持开放。
|
||||
- 崩溃恢复在[重连预算](2026-08-06-mcp-client-auto-reconnect.md)内自动进行;耗尽后或配置 `reconnect.enabled: false` 时回退为手动重新加载。
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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 .agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md
|
||||
2026-08-06-mcp-client-auto-reconnect.md: 99a8aec1abe3713822f8f17c17d8efaca5d61a4d
|
||||
2026-08-06-mcp-client-auto-reconnect.zh.md: 8d4dc935e6edee9a05f556774e743e48234ca709
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent Note: MCP client auto-reconnect with bounded backoff
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-06-mcp-client-auto-reconnect.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. When a stdio server crashed or was killed, its registered tools stayed visible but every call failed with `Not connected` until a human edited the config (HMR) or restarted the Host — v1 explicitly deferred reconnection. Long-running hosts (ACP automation, web) cannot be bounced because a child process died, and for stdio the harness composition is the only party that can respawn it. External feedback escalated this as a real operational gap (issue #1746).
|
||||
|
||||
## Decision
|
||||
|
||||
`packages/mcp/mcp-client/src/connection.ts` owns a per-instance connection supervisor; `apply()` shrinks to config resolution plus two effects (the `serverName` reservation and the supervisor's lifecycle). The supervisor owns the client/transport generations, the live tool registrations, and the reconnect loop.
|
||||
|
||||
**Trigger.** The supervisor arms `client.onclose` per generation. The SDK fires it when the stdio child exits, so a crash is observed without polling. `StreamableHTTPClientTransport` fires `onclose` only for deliberate closes — it owns its internal SSE-stream recovery and surfaces request failures per call — so HTTP servers are effectively outside supervisor restarts; the package README records that limitation.
|
||||
|
||||
**Generations without interleaving.** Each attempt builds a fresh transport and `Client` (the SDK binds a Protocol to one transport for life). One per-supervisor queue serializes every `syncTools` call — initial syncs and `list_changed` re-syncs across all generations — and an `isCurrent` fence makes stale generations inert, so no two syncs can interleave the dispose-previous/register-next swap (which would double-dispose one generation and leak another). The queue also closes a pre-existing race where two rapid `list_changed` notifications re-synced concurrently. The activation attempt, rather than the first queue entrant, explicitly owns strict startup registration: an early `list_changed` notification uses contained re-sync semantics and cannot consume `failOnStartupError`. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. A failed attempt cannot enter backoff until both `Client.close()` settles and the transport reports `onclose`, which for stdio proves the child exited; a missing close signal stops reconnection after the SDK's bounded termination window instead of allowing two server processes to overlap. Disposal uses the same bounded close-signal barrier and reports an incomplete shutdown without ever restarting.
|
||||
|
||||
**Bounded backoff with an outage budget.** Delays double from `initialDelayMs` up to `maxDelayMs`. One outage shares `maxAttempts` consecutive failed attempts; exhaustion unregisters the server's tools, logs at error level, and stops until disposal or reload. A connection that survives past the stability window — `maxDelayMs`, derived rather than a fifth tunable, as the longest configured backoff spacing — resets the budget, so an occasionally-crashing server recovers indefinitely while a crash loop whose connects briefly succeed cannot launder its budget into a restart storm.
|
||||
|
||||
**Config and resolution.** Both transports accept `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` with schemastery defaults (on, 500ms, 30s, 10). `resolveReconnectPolicy()` is the explicit resolve step: it re-judges every bound and cross-field constraint because programmatic construction may bypass Schemastery, and misconfiguration fails the plugin instance at load.
|
||||
|
||||
**Observable states.** An initial or retry-attempt failure says `connection failed`; an established generation ending says `connection lost`. Retrying logs at warn with attempt count and delay, recovery at info, final failure and disabled recovery at error. During an outage the last good generation stays registered and calls against it fail — deterministic public names mean a recovered unchanged tool list reproduces identical definitions, keeping the model-visible schema prefix stable instead of flapping. With `reconnect.enabled: false` a lost connection keeps the v1 manual-recovery behavior.
|
||||
|
||||
**Disposal.** Dispose flips the fence, cancels any pending timer, closes the current client, then awaits the in-flight attempt and the sync queue before unregistering — quiescence, not just a request to stop. The reconnect timer is unref'd so a waiting backoff never holds a finishing process open.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**Consecutive-failure counter that resets on every successful connect.** Rejected: a crash-looping server whose connects briefly succeed would reset the budget each cycle and restart forever — exactly the restart storm the failure cap exists to prevent. The uptime-gated reset distinguishes a recovered server from a looping one without new configuration.
|
||||
|
||||
**Reuse one SDK `Client` across reconnects.** The Protocol clears its transport on close and can technically connect again, but the SDK's own guidance is one connection per Protocol instance, and reuse carries notification handlers and negotiated capability state across server incarnations. A fresh `Client` per generation plus the `isCurrent` fence is unambiguous.
|
||||
|
||||
**Unregister tools immediately on disconnect, re-register on recovery.** Rejected: a transient outage would flap the model-visible tool list (two schema-prefix invalidations per crash) for no information gain; failing calls already signal the outage, and the swap on recovery is atomic per generation. Tools are unregistered at final failure so a permanently dead server does not leak permanently broken tools.
|
||||
|
||||
**Route Streamable HTTP request failures into the supervisor.** Rejected for now: the HTTP transport already reconnects its SSE stream with its own backoff, per-request errors do not imply a dead server, and there is no child process the harness could respawn. Transport close stays the single trigger.
|
||||
|
||||
**Restart through Loader/HMR machinery instead of an in-plugin supervisor.** Rejected: the Loader owns config-driven recomposition, not runtime health. A plugin restarting itself through the Loader would conflate config generations with connection generations and lose the per-outage budget.
|
||||
|
||||
## Testing
|
||||
|
||||
Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, diagnostics distinguish initial or retry failure from established connection loss, strict startup registration survives a pre-connect `list_changed` notification, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A crashed stdio MCP server recovers without human intervention: bounded backoff, re-discovery, atomic generation swap. Default policy retries an outage for roughly 2.5 minutes before giving up.
|
||||
- Connection state is genuinely more intricate than connect-once — the partial-availability window v1 avoided now exists (registered tools failing during an outage), concentrated in one module with the invariants named.
|
||||
- `reconnect` is new config surface on both transports, and the stability window is deliberately derived from `maxDelayMs`; making it independently tunable is a compatible future change.
|
||||
- After final failure or with reconnect disabled, the plugin stays loaded with no (or failing) tools until reload — deliberate and logged, so a chronically broken server cannot restart forever.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Agent Note: MCP client auto-reconnect with bounded backoff
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-06-mcp-client-auto-reconnect.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[MCP 客户端](2026-07-07-mcp-client-plugin.md)在插件加载时仅连接一次。stdio 服务器崩溃或被终止后,其已注册的工具仍然可见,但每次调用均以 `Not connected` 失败,直到人工编辑配置触发 HMR(热模块替换)重载,或重启 Host——v1 明确推迟了重连机制。长时间运行的 Host(ACP 自动化、Web)不能因为子进程死亡就被重启;而对于 stdio 传输,harness 组合层是唯一能重新拉起子进程的一方。外部反馈将此升级为真实的运维缺口(issue #1746)。
|
||||
|
||||
## 决策
|
||||
|
||||
`packages/mcp/mcp-client/src/connection.ts` 拥有一个逐实例的连接监督器;`apply()` 收缩为配置解析加两个副作用(`serverName` 预留和监督器的生命周期)。监督器负责管理 client/transport 代、活跃的工具注册以及重连循环。
|
||||
|
||||
**触发条件。** 监督器在每一代上挂载 `client.onclose`。SDK 在 stdio 子进程退出时触发该回调,因此崩溃无需轮询即可感知。`StreamableHTTPClientTransport` 仅在主动关闭时触发 `onclose`——它内部拥有自己的 SSE(Server-Sent Events)流恢复机制,并将请求失败以逐调用方式暴露——因此 HTTP 服务器实际上不在监督器的重启范围内;包 README 记录了该限制。
|
||||
|
||||
**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。严格启动注册由激活尝试本身显式拥有,而非由首个入队者拥有;提前到达的 `list_changed` 采用故障隔离的再同步语义,不能消费 `failOnStartupError`。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。失败尝试只有在 `Client.close()` 结算且 transport 报告 `onclose` 后才能进入退避;对 stdio 而言,`onclose` 证明子进程已退出;若关闭信号始终未到,则在 SDK 的有界终止窗口结束后停止重连,而不是允许两个服务器进程重叠运行。dispose 使用同一个有界关闭信号屏障;若关停未完成则予以报告,且绝不重启。
|
||||
|
||||
**有界退避与故障预算。** 延迟从 `initialDelayMs` 起逐次翻倍,上限为 `maxDelayMs`。一次故障期间共享 `maxAttempts` 次连续失败尝试的预算;耗尽后注销该服务器的工具、以 error 级别记录日志并停止,直到 dispose 或重新加载。连接在存活超过稳定窗口——即 `maxDelayMs`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。
|
||||
|
||||
**配置与解析。** 两种传输均接受 `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` 配置,Schemastery 默认值为(启用、500ms、30s、10)。`resolveReconnectPolicy()` 是显式的解析步骤:它重新校验每个边界值和跨字段约束,因为程序化构造可能绕过 Schemastery,配置错误在加载时即令插件实例失败。
|
||||
|
||||
**可观测状态。** 初始尝试或重试尝试失败时记录 `connection failed`,已建立的代结束时记录 `connection lost`;重试的 warn 日志包含尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用重连时的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。
|
||||
|
||||
**资源释放。** dispose 翻转栅栏、取消待执行的定时器、关闭当前 client,然后等待正在进行的尝试和同步队列完成后再注销工具——完全停稳,而非仅发出停止请求。重连定时器使用 unref,因此等待中的退避不会阻止进程正常退出。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**连续失败计数器,每次成功连接即重置。** 否决:连接短暂成功后立即崩溃的循环服务器会在每个周期重置预算并永远重启——恰恰是失败上限旨在防止的重启风暴。基于运行时间的重置能区分已恢复的服务器与循环崩溃的服务器,无需新增配置。
|
||||
|
||||
**跨重连复用同一个 SDK `Client`。** Protocol 在关闭时清除其 transport,技术上可以再次连接,但 SDK 自身的指导方针是每个 Protocol 实例对应一次连接,且复用会将通知处理器和已协商的能力状态带入新的服务器实例。每代创建全新 `Client` 加 `isCurrent` 栅栏的方式无歧义。
|
||||
|
||||
**断连时立即注销工具,恢复时重新注册。** 否决:短暂故障会使模型可见工具列表抖动(每次崩溃触发两次 schema 前缀失效),而无任何信息增益;失败的调用已足以标示故障,恢复时的切换按代原子执行。工具仅在最终失败时注销,确保永久死亡的服务器不会泄漏永久失效的工具。
|
||||
|
||||
**将 Streamable HTTP 请求失败路由到监督器。** 暂不采纳:HTTP 传输已使用自己的退避机制重连其 SSE 流,逐请求错误并不意味着服务器已死,且 harness 没有可重新拉起的子进程。transport 关闭仍是唯一触发条件。
|
||||
|
||||
**通过 Loader/HMR 机制重启,而非使用插件内监督器。** 否决:Loader 负责配置驱动的重组合,而非运行时健康管理。插件通过 Loader 重启自身会混淆配置代与连接代,并丢失逐故障预算。
|
||||
|
||||
## 测试
|
||||
|
||||
单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、诊断区分初始或重试尝试失败与已建立连接丢失、严格启动注册在连接前收到 `list_changed` 通知后仍然生效、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。
|
||||
|
||||
## 后果
|
||||
|
||||
- 崩溃的 stdio MCP 服务器无需人工干预即可恢复:有界退避、重新发现、原子代切换。默认策略对一次故障大约重试 2.5 分钟后放弃。
|
||||
- 连接状态确实比一次性连接更复杂——v1 刻意回避的部分可用窗口现已存在(故障期间已注册工具返回失败),集中在一个模块中并命名了所有不变式。
|
||||
- `reconnect` 是两种传输上的新配置表面,稳定窗口刻意从 `maxDelayMs` 推导;将其设为独立可调参数是兼容的未来变更。
|
||||
- 最终失败后或禁用重连时,插件保持加载状态但无(或失败的)工具,直到重新加载——行为是刻意的且有日志记录,确保长期故障的服务器不能永远重启。
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/config-catalog.md
|
||||
config-catalog.md: 9673a1596d13565de4f1e23a3e68d94a2f778f29
|
||||
config-catalog.zh.md: 7149bb054ab92a272aac3469ff5778e41f6fa932
|
||||
config-catalog.md: 0940b966d7738eb98606df06fd8f39866e1871d2
|
||||
config-catalog.zh.md: 15ebafe204f28380555bbb10731b6bd9d799d7ae
|
||||
+17
-1
@@ -1086,6 +1086,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). */
|
||||
@@ -1106,10 +1108,24 @@ 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
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
```
|
||||
|
||||
Source: [`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts)
|
||||
Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts)
|
||||
|
||||
## `@deepseek-ai/dsh-permission`
|
||||
|
||||
|
||||
@@ -1088,6 +1088,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). */
|
||||
@@ -1108,6 +1110,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
|
||||
}
|
||||
|
||||
/** 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
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write docs/module-graph.md
|
||||
module-graph.md: ce9909fb5b73eb9e25eaae26aceedd9d287a68f9
|
||||
module-graph.zh.md: f49542835a614819f8432e775ef3dfa21614dbad
|
||||
module-graph.md: 95b3a7376b64dc2ff5ad3d491fa99ddfb470408f
|
||||
module-graph.zh.md: c407d9490b83802bd1babe071c04ba66fd7a0482
|
||||
@@ -932,6 +932,7 @@ flowchart TD
|
||||
pkg_mcp_client --> pkg_invariants
|
||||
pkg_mcp_client --> pkg_llm
|
||||
pkg_mcp_client --> pkg_subprocess
|
||||
pkg_mcp_client --> pkg_timeout
|
||||
pkg_mcp_client --> pkg_tools
|
||||
pkg_tool_bash_persistent --> pkg_agent
|
||||
pkg_tool_bash_persistent --> pkg_invariants
|
||||
@@ -1417,7 +1418,7 @@ flowchart TD
|
||||
| [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) |
|
||||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -934,6 +934,7 @@ flowchart TD
|
||||
pkg_mcp_client --> pkg_invariants
|
||||
pkg_mcp_client --> pkg_llm
|
||||
pkg_mcp_client --> pkg_subprocess
|
||||
pkg_mcp_client --> pkg_timeout
|
||||
pkg_mcp_client --> pkg_tools
|
||||
pkg_tool_bash_persistent --> pkg_agent
|
||||
pkg_tool_bash_persistent --> pkg_invariants
|
||||
@@ -1419,7 +1420,7 @@ flowchart TD
|
||||
| [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) |
|
||||
| [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) |
|
||||
| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) |
|
||||
| [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) |
|
||||
| [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
Generated
+3
@@ -5040,6 +5040,9 @@ importers:
|
||||
'@deepseek-ai/dsh-subprocess':
|
||||
specifier: workspace:^
|
||||
version: link:../../subprocess/subprocess
|
||||
'@deepseek-ai/dsh-timeout':
|
||||
specifier: workspace:^
|
||||
version: link:../../util/timeout
|
||||
'@deepseek-ai/dsh-tools':
|
||||
specifier: workspace:^
|
||||
version: link:../../core/tools
|
||||
|
||||
Reference in New Issue
Block a user