@deepseek-ai/dsh-mcp-client 0.0.1-rc.1 → 0.0.1-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.i18n.yaml CHANGED
@@ -2,5 +2,5 @@
2
2
  # side as of the last confirmed-consistent state. Both languages carry equal authority;
3
3
  # after editing either side, bring the other along and re-record with:
4
4
  # pnpm run verify-translation-pairing --write packages/mcp/mcp-client/README.md
5
- README.md: 76d1271f6f7a3e9c959bdcf5e969906f25563c56
6
- README.zh.md: b2da1119af3a8d52761a5040059e7a9d922aa567
5
+ README.md: 266c3b7c2b38406800ae5dad1eb065c9dcbf50e6
6
+ README.zh.md: 1b5b5c523e0a477db30f97a748651dbe7e6992ea
package/README.md CHANGED
@@ -45,6 +45,10 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same
45
45
  | `headers` | http | no | Extra headers (e.g. auth tokens) |
46
46
  | `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) |
47
47
  | `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) |
48
+ | `reconnect.enabled` | both | no | Reconnect automatically after a lost connection (default `true`) |
49
+ | `reconnect.initialDelayMs` | both | no | First reconnect delay in ms; doubles per consecutive failed attempt (default 500) |
50
+ | `reconnect.maxDelayMs` | both | no | Backoff ceiling in ms; also the uptime after which the attempt budget resets (default 30000) |
51
+ | `reconnect.maxAttempts` | both | no | Consecutive failed attempts per outage before giving up for good (default 10) |
48
52
 
49
53
  ## Tool naming
50
54
 
@@ -62,7 +66,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
62
66
  - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server.
63
67
  - 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`.
64
68
  - Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders.
65
- - 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.
69
+ - 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.
70
+ - 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.
71
+ - 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.
66
72
 
67
73
  ## Services consumed
68
74
 
@@ -76,7 +82,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call`
76
82
 
77
83
  #### What the model sees
78
84
 
79
- 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.
85
+ 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.
80
86
 
81
87
  #### Token effect
82
88
 
@@ -84,7 +90,7 @@ Data-dependent schema cost is paid on every request while the tools are register
84
90
 
85
91
  #### KV Cache effect
86
92
 
87
- 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.
93
+ 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.
88
94
 
89
95
  ### Tool-call history and results
90
96
 
@@ -102,8 +108,8 @@ Append-only; newly visible content follows the reusable request prefix and does
102
108
 
103
109
  ## Known Limitations and Deferred Work
104
110
 
105
- - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred.
111
+ - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumer and are deferred.
106
112
  - **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.
107
- - **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.
113
+ - **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.
108
114
  - **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.
109
115
  - **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset.
package/README.zh.md CHANGED
@@ -45,6 +45,10 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
45
45
  | `headers` | http | 否 | 额外标头(例如认证 token) |
46
46
  | `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) |
47
47
  | `failOnStartupError` | 两者 | 否 | 初始连接或工具同步失败时拒绝插件激活(默认 `false`) |
48
+ | `reconnect.enabled` | 两者 | 否 | 连接丢失后自动重新连接(默认 `true`) |
49
+ | `reconnect.initialDelayMs` | 两者 | 否 | 首次重连延迟(毫秒);每次连续失败尝试翻倍(默认 500) |
50
+ | `reconnect.maxDelayMs` | 两者 | 否 | 退避上限(毫秒);同时也是重置尝试预算所需的正常运行时长(默认 30000) |
51
+ | `reconnect.maxAttempts` | 两者 | 否 | 每次中断期间连续失败尝试次数上限,超出后彻底放弃(默认 10) |
48
52
 
49
53
  ## 工具命名
50
54
 
@@ -62,7 +66,9 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
62
66
  - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。
63
67
  - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。
64
68
  - Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。
65
- - 断开/崩溃时:不自动重新连接。已注册工具会一直保留到对插件执行 dispose(资源释放)或成功重新同步,针对已关闭传输的调用可能失败;请通过 HMR 重新加载或重启 Host 来重新连接。
69
+ - 断开/崩溃时:supervisor 以指数退避(`reconnect.initialDelayMs` 逐次翻倍,上限 `reconnect.maxDelayMs`)重启原始服务器配置,成功后重新执行发现——恢复的世代会替换前一个,因此工具既不会重复也不会泄漏。中断期间最后一个正常世代保持注册;针对它的调用在恢复前会失败。
70
+ - 重连按中断预算控制:连续失败达到 `reconnect.maxAttempts` 次后,该服务器的工具会被注销,重连停止,直到 HMR 重载或重启 Host。连接存活超过 `maxDelayMs` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。
71
+ - 重连状态在日志中对用户可见:reconnecting(warn,含尝试次数和延迟)、recovered(info)、最终失败和 disabled-loss(error)。dispose(资源释放)会取消任何待执行的重连。设置 `reconnect.enabled: false` 时,连接丢失后工具保持注册但调用失败,直到重载——即手动恢复行为。
66
72
 
67
73
  ## 消费的服务
68
74
 
@@ -76,7 +82,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
76
82
 
77
83
  #### 模型看到的内容
78
84
 
79
- 初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp__<serverName>__<rawName>`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步会替换整个世代;对插件执行 dispose 会移除该世代。
85
+ 初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp__<serverName>__<rawName>`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步——包括自动重连后的同步——会替换整个世代;对插件执行 dispose(资源释放)或重连预算耗尽会移除该世代。
80
86
 
81
87
  #### Token 影响
82
88
 
@@ -84,7 +90,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
84
90
 
85
91
  #### KV Cache 影响
86
92
 
87
- 只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效。
93
+ 只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效;恢复了未变列表的重连会生成完全相同的定义,前缀保持稳定。
88
94
 
89
95
  ### 工具调用历史与结果
90
96
 
@@ -104,6 +110,6 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc
104
110
 
105
111
  - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。
106
112
  - **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。
107
- - **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。
113
+ - **重连在传输关闭时触发**:崩溃的 stdio 子进程会触发重连;Streamable HTTP 失败通过每次请求以及 SDK 传输自身的 SSE(Server-Sent Events)流恢复机制暴露,因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。
108
114
  - **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。
109
115
  - **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import z from "@deepseek-ai/schemastery";
2
+ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
2
3
  import { Client } from "@modelcontextprotocol/sdk/client/index.js";
3
4
  import { ListToolsResultSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
4
5
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -269,6 +270,260 @@ function extractText(mcpContent, toolName) {
269
270
  return parts.join("\n") || `(${toolName} returned no text content)`;
270
271
  }
271
272
  //#endregion
273
+ //#region lib/types/connection.js
274
+ /**
275
+ * Connection supervisor: owns the MCP client/transport generations for one
276
+ * plugin instance, keeps the harness tool registry in sync with the live
277
+ * generation, and — when the connection drops — restarts the configured
278
+ * server with bounded exponential backoff.
279
+ *
280
+ * One outage shares one attempt budget (`maxAttempts` consecutive failed
281
+ * attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
282
+ * connection that stays up past the stability window closes the outage, so
283
+ * the next disconnect starts a fresh budget while a crash-looping server —
284
+ * even one whose connects briefly succeed — still exhausts the cap instead of
285
+ * restarting forever. Exhaustion unregisters the server's tools and stops;
286
+ * disposal (including HMR) is the only way back from that state.
287
+ *
288
+ * @module
289
+ */
290
+ /** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
291
+ const RECONNECT_DEFAULTS = Object.freeze({
292
+ enabled: true,
293
+ initialDelayMs: 500,
294
+ maxDelayMs: 3e4,
295
+ maxAttempts: 10
296
+ });
297
+ const GENERATION_CLOSE_TIMEOUT_MS = 5e3;
298
+ /**
299
+ * The one explicit resolve step from raw reconnect config to the policy the
300
+ * supervisor runs. Programmatic construction may bypass Schemastery
301
+ * normalization, so every default and bound is re-judged here — misconfiguration
302
+ * fails the plugin instance at load.
303
+ *
304
+ * @param config - Raw `reconnect` config; omission uses the defaults.
305
+ * @param path - Diagnostic prefix naming the config location in thrown messages.
306
+ * @returns The frozen resolved policy.
307
+ */
308
+ function resolveReconnectPolicy(config, path) {
309
+ if (config !== void 0) {
310
+ for (const key of Object.keys(config)) if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`);
311
+ }
312
+ const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled;
313
+ const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs;
314
+ const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs;
315
+ const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts;
316
+ 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}`);
317
+ 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}`);
318
+ if (initialDelayMs > maxDelayMs) throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
319
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new Error(`${path}.maxAttempts must be a positive integer`);
320
+ return Object.freeze({
321
+ enabled,
322
+ initialDelayMs,
323
+ maxDelayMs,
324
+ maxAttempts
325
+ });
326
+ }
327
+ /**
328
+ * Start the supervised connection for one MCP server and keep it alive per
329
+ * the reconnect policy.
330
+ *
331
+ * @param ctx - Cordis context providing the `tools` registry and logger.
332
+ * @param config - Resolved plugin config selecting the transport and server identity.
333
+ * @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
334
+ * @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
335
+ */
336
+ function startConnection(ctx, config, policy) {
337
+ const label = `mcp-client(${config.serverName})`;
338
+ const opts = {
339
+ registrationFailure: "contain",
340
+ serverName: config.serverName,
341
+ toolCallTimeoutMs: config.toolCallTimeoutMs
342
+ };
343
+ const startupOpts = config.failOnStartupError ? {
344
+ ...opts,
345
+ registrationFailure: "throw"
346
+ } : opts;
347
+ let disposed = false;
348
+ /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
349
+ let client;
350
+ /** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
351
+ let clientClosed;
352
+ /** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */
353
+ let disposers = /* @__PURE__ */ new Map();
354
+ let reconnectTimer;
355
+ /** Consecutive failed connection attempts within the current outage. */
356
+ let failedAttempts = 0;
357
+ /** When the current generation finished connect + initial sync; undefined while down. */
358
+ let connectedAt;
359
+ /** The real error from the first connection attempt, for startup-await diagnostics. */
360
+ let firstAttemptError;
361
+ /** A generation may act only while it is the current one on a live plugin. */
362
+ const isCurrent = (generation) => !disposed && client === generation;
363
+ /**
364
+ * Serializes every syncTools call — initial syncs and notification re-syncs
365
+ * across all generations — so two syncs can never interleave their
366
+ * dispose-previous/register-next swap (which would double-dispose one
367
+ * generation and leak another).
368
+ */
369
+ let syncChain = Promise.resolve();
370
+ function enqueueSync(generation, syncOpts = opts) {
371
+ const run = syncChain.then(async () => {
372
+ if (!isCurrent(generation)) return;
373
+ disposers = await syncTools(generation, ctx, syncOpts, disposers);
374
+ });
375
+ syncChain = run.catch(() => {});
376
+ return run;
377
+ }
378
+ /** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
379
+ function generationDown(generation) {
380
+ if (!isCurrent(generation)) return;
381
+ client = void 0;
382
+ clientClosed = void 0;
383
+ scheduleReconnect();
384
+ }
385
+ /** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
386
+ function waitForClose(closed) {
387
+ return new Promise((resolve) => {
388
+ const timeout = setTimeout(() => {
389
+ resolve(false);
390
+ }, GENERATION_CLOSE_TIMEOUT_MS);
391
+ timeout.unref();
392
+ closed.then(() => {
393
+ clearTimeout(timeout);
394
+ resolve(true);
395
+ });
396
+ });
397
+ }
398
+ function scheduleReconnect() {
399
+ const lostEstablishedConnection = connectedAt !== void 0;
400
+ if (!policy.enabled) {
401
+ 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";
402
+ ctx.logger.error(`${label}: ${message}`);
403
+ return;
404
+ }
405
+ if (connectedAt !== void 0 && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0;
406
+ connectedAt = void 0;
407
+ failedAttempts += 1;
408
+ if (failedAttempts > policy.maxAttempts) {
409
+ syncChain = syncChain.then(() => {
410
+ for (const dispose of disposers.values()) dispose();
411
+ disposers = /* @__PURE__ */ new Map();
412
+ });
413
+ ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`);
414
+ return;
415
+ }
416
+ const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1));
417
+ const action = lostEstablishedConnection ? "connection lost; reconnecting" : "connection failed; retrying";
418
+ ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`);
419
+ reconnectTimer = setTimeout(() => {
420
+ reconnectTimer = void 0;
421
+ settling = connectGeneration(false);
422
+ }, delayMs);
423
+ reconnectTimer.unref();
424
+ }
425
+ /**
426
+ * One connection attempt: fresh transport + client (the MCP SDK binds a
427
+ * Protocol to one transport for life), connect, then queue the initial tool
428
+ * sync. The startup flag belongs to the attempt rather than the shared sync
429
+ * queue, so an early notification cannot consume strict startup semantics.
430
+ * Every failure funnels through {@link generationDown}; success arms the
431
+ * onclose-driven disconnect path. Never rejects.
432
+ *
433
+ * @param startup - Whether this is the plugin's activation attempt.
434
+ */
435
+ async function connectGeneration(startup) {
436
+ const generation = new Client({
437
+ name: "dsh-mcp-client",
438
+ version: "0.0.1"
439
+ }, { capabilities: {} });
440
+ const closed = Promise.withResolvers();
441
+ let attemptSettled = false;
442
+ let closeObserved = false;
443
+ const hasClosed = () => closeObserved;
444
+ client = generation;
445
+ clientClosed = closed.promise;
446
+ generation.onclose = () => {
447
+ closeObserved = true;
448
+ closed.resolve();
449
+ if (attemptSettled) generationDown(generation);
450
+ };
451
+ generation.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
452
+ if (!isCurrent(generation)) return;
453
+ ctx.logger.info(`${label}: tool list changed, re-syncing`);
454
+ try {
455
+ await enqueueSync(generation);
456
+ } catch (error) {
457
+ if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
458
+ }
459
+ });
460
+ try {
461
+ await generation.connect(createTransport(config));
462
+ if (hasClosed()) {
463
+ attemptSettled = true;
464
+ generationDown(generation);
465
+ return;
466
+ }
467
+ await enqueueSync(generation, startup ? startupOpts : opts);
468
+ } catch (error) {
469
+ if (firstAttemptError === void 0) firstAttemptError = error;
470
+ if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`);
471
+ try {
472
+ await generation.close();
473
+ } catch {}
474
+ const quiesced = hasClosed() || await waitForClose(closed.promise);
475
+ attemptSettled = true;
476
+ if (!isCurrent(generation)) return;
477
+ if (!quiesced) {
478
+ client = void 0;
479
+ clientClosed = void 0;
480
+ 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`);
481
+ return;
482
+ }
483
+ generationDown(generation);
484
+ return;
485
+ }
486
+ attemptSettled = true;
487
+ if (hasClosed()) {
488
+ generationDown(generation);
489
+ return;
490
+ }
491
+ if (!isCurrent(generation)) return;
492
+ connectedAt = Date.now();
493
+ if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
494
+ }
495
+ /** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
496
+ let settling = connectGeneration(true);
497
+ return {
498
+ ready: settling.then(() => {
499
+ if (client !== void 0) return {};
500
+ /* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */
501
+ return { error: firstAttemptError ?? /* @__PURE__ */ new Error(`${label}: initial connection failed`) };
502
+ }),
503
+ async dispose() {
504
+ disposed = true;
505
+ if (reconnectTimer !== void 0) {
506
+ clearTimeout(reconnectTimer);
507
+ reconnectTimer = void 0;
508
+ }
509
+ const current = client;
510
+ const currentClosed = clientClosed;
511
+ client = void 0;
512
+ clientClosed = void 0;
513
+ if (current !== void 0) {
514
+ try {
515
+ await current.close();
516
+ } catch {}
517
+ if (currentClosed !== void 0 && !await waitForClose(currentClosed)) ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`);
518
+ }
519
+ await settling;
520
+ await syncChain;
521
+ for (const dispose of disposers.values()) dispose();
522
+ disposers = /* @__PURE__ */ new Map();
523
+ }
524
+ };
525
+ }
526
+ //#endregion
272
527
  //#region lib/types/index.js
273
528
  /**
274
529
  * MCP client bridge plugin: connects to an external MCP server and registers
@@ -299,6 +554,12 @@ const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
299
554
  * shadowing.
300
555
  */
301
556
  const activeServerNames = /* @__PURE__ */ new WeakMap();
557
+ const Reconnect = z.object({
558
+ enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled),
559
+ initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs),
560
+ maxDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.maxDelayMs),
561
+ maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts)
562
+ });
302
563
  const Config = z.union([z.object({
303
564
  transport: z.const("stdio"),
304
565
  serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
@@ -307,14 +568,16 @@ const Config = z.union([z.object({
307
568
  env: z.dict(String).default({}),
308
569
  cwd: z.string().default(""),
309
570
  toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
310
- failOnStartupError: z.boolean().default(false)
571
+ failOnStartupError: z.boolean().default(false),
572
+ reconnect: Reconnect
311
573
  }), z.object({
312
574
  transport: z.const("streamable-http"),
313
575
  serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
314
576
  url: z.string().required(),
315
577
  headers: z.dict(String).default({}),
316
578
  toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
317
- failOnStartupError: z.boolean().default(false)
579
+ failOnStartupError: z.boolean().default(false),
580
+ reconnect: Reconnect
318
581
  })]);
319
582
  /**
320
583
  * Connect one MCP server and publish its initial tool generation before activation.
@@ -325,6 +588,7 @@ const Config = z.union([z.object({
325
588
  * @returns startup readiness after connection and initial tool discovery settle.
326
589
  */
327
590
  async function apply(ctx, config) {
591
+ const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`);
328
592
  ctx.effect(() => {
329
593
  let names = activeServerNames.get(ctx.root);
330
594
  if (!names) {
@@ -335,47 +599,12 @@ async function apply(ctx, config) {
335
599
  names.add(config.serverName);
336
600
  return () => void names.delete(config.serverName);
337
601
  }, "mcp-client.serverName");
338
- const transport = createTransport(config);
339
- const client = new Client({
340
- name: "dsh-mcp-client",
341
- version: "0.0.1"
342
- }, { capabilities: {} });
343
- const opts = {
344
- registrationFailure: "contain",
345
- serverName: config.serverName,
346
- toolCallTimeoutMs: config.toolCallTimeoutMs
347
- };
348
- const ready = (async () => {
349
- await client.connect(transport);
350
- let disposers = await syncTools(client, ctx, {
351
- ...opts,
352
- registrationFailure: config.failOnStartupError ? "throw" : "contain"
353
- }, /* @__PURE__ */ new Map());
354
- client.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
355
- ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`);
356
- try {
357
- disposers = await syncTools(client, ctx, opts, disposers);
358
- } catch (error) {
359
- ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`);
360
- }
361
- });
362
- return { getDisposers: () => disposers };
363
- })().catch((error) => {
364
- ctx.logger.error(`mcp-client(${config.serverName}): startup failed: ${String(error)}`);
365
- return {
366
- getDisposers: () => /* @__PURE__ */ new Map(),
367
- error
368
- };
369
- });
370
- ctx.effect(() => async () => {
371
- const outcome = await ready;
372
- for (const dispose of outcome.getDisposers().values()) dispose();
373
- try {
374
- await client.close();
375
- } catch {}
602
+ const connection = startConnection(ctx, config, reconnect);
603
+ ctx.effect(() => {
604
+ return () => connection.dispose();
376
605
  }, "mcp-client.connection");
377
- const outcome = await ready;
378
- if ("error" in outcome && config.failOnStartupError) throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error });
606
+ const outcome = await connection.ready;
607
+ if (outcome.error !== void 0 && config.failOnStartupError) throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error });
379
608
  }
380
609
  //#endregion
381
610
  export { Config, apply, inject, name };
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Connection supervisor: owns the MCP client/transport generations for one
3
+ * plugin instance, keeps the harness tool registry in sync with the live
4
+ * generation, and — when the connection drops — restarts the configured
5
+ * server with bounded exponential backoff.
6
+ *
7
+ * One outage shares one attempt budget (`maxAttempts` consecutive failed
8
+ * attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
9
+ * connection that stays up past the stability window closes the outage, so
10
+ * the next disconnect starts a fresh budget while a crash-looping server —
11
+ * even one whose connects briefly succeed — still exhausts the cap instead of
12
+ * restarting forever. Exhaustion unregisters the server's tools and stops;
13
+ * disposal (including HMR) is the only way back from that state.
14
+ *
15
+ * @module
16
+ */
17
+ import type { Context } from '@deepseek-ai/cordis';
18
+ import type { Config } from './index.ts';
19
+ /** Automatic reconnect policy for one MCP server connection. */
20
+ export interface ReconnectConfig {
21
+ /** Reconnect automatically after a lost connection (default true). */
22
+ enabled?: boolean;
23
+ /** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */
24
+ initialDelayMs?: number;
25
+ /** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */
26
+ maxDelayMs?: number;
27
+ /** Consecutive failed attempts per outage before giving up for good (default 10). */
28
+ maxAttempts?: number;
29
+ }
30
+ /** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
31
+ export declare const RECONNECT_DEFAULTS: Required<ReconnectConfig>;
32
+ /** Fully resolved reconnect policy captured at plugin load. */
33
+ export type ResolvedReconnectPolicy = Readonly<Required<ReconnectConfig>>;
34
+ /**
35
+ * The one explicit resolve step from raw reconnect config to the policy the
36
+ * supervisor runs. Programmatic construction may bypass Schemastery
37
+ * normalization, so every default and bound is re-judged here — misconfiguration
38
+ * fails the plugin instance at load.
39
+ *
40
+ * @param config - Raw `reconnect` config; omission uses the defaults.
41
+ * @param path - Diagnostic prefix naming the config location in thrown messages.
42
+ * @returns The frozen resolved policy.
43
+ */
44
+ export declare function resolveReconnectPolicy(config: ReconnectConfig | undefined, path: string): ResolvedReconnectPolicy;
45
+ /** Result from the initial connection attempt, for startup-await semantics. */
46
+ export interface ConnectionOutcome {
47
+ /** If the initial connection or tool sync failed, the error; otherwise absent. */
48
+ error?: unknown;
49
+ }
50
+ /** Handle for one plugin instance's supervised connection. */
51
+ export interface ConnectionHandle {
52
+ /**
53
+ * Settles when the first connection attempt completes (success or failure).
54
+ * The supervisor enters its reconnect loop regardless; the caller decides
55
+ * whether a failed startup is fatal via `failOnStartupError`.
56
+ */
57
+ ready: Promise<ConnectionOutcome>;
58
+ /**
59
+ * Stop reconnection, close the live client, wait for the in-flight attempt
60
+ * and queued tool syncs to quiesce, then unregister every tool this server
61
+ * still owns.
62
+ */
63
+ dispose(): Promise<void>;
64
+ }
65
+ /**
66
+ * Start the supervised connection for one MCP server and keep it alive per
67
+ * the reconnect policy.
68
+ *
69
+ * @param ctx - Cordis context providing the `tools` registry and logger.
70
+ * @param config - Resolved plugin config selecting the transport and server identity.
71
+ * @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
72
+ * @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
73
+ */
74
+ export declare function startConnection(ctx: Context, config: Config, policy: ResolvedReconnectPolicy): ConnectionHandle;
75
+ //# sourceMappingURL=connection.d.ts.map
@@ -14,7 +14,9 @@
14
14
  */
15
15
  import type { Context } from '@deepseek-ai/cordis';
16
16
  import z from '@deepseek-ai/schemastery';
17
+ import type { ReconnectConfig } from './connection.ts';
17
18
  export type { McpResult } from './tools.ts';
19
+ export type { ReconnectConfig, ResolvedReconnectPolicy } from './connection.ts';
18
20
  /** Cordis plugin name used by loader diagnostics. */
19
21
  export declare const name = "mcp-client";
20
22
  /** Services required by this plugin. */
@@ -41,6 +43,8 @@ export interface StdioConfig {
41
43
  toolCallTimeoutMs: number;
42
44
  /** Fail plugin activation when the initial connection or tool synchronization fails. */
43
45
  failOnStartupError: boolean;
46
+ /** Automatic reconnect policy after a lost connection; omission uses the defaults. */
47
+ reconnect?: ReconnectConfig;
44
48
  }
45
49
  /** Config for connecting to an MCP server over Streamable HTTP (SSE). */
46
50
  export interface StreamableHttpConfig {
@@ -60,6 +64,8 @@ export interface StreamableHttpConfig {
60
64
  toolCallTimeoutMs: number;
61
65
  /** Fail plugin activation when the initial connection or tool synchronization fails. */
62
66
  failOnStartupError: boolean;
67
+ /** Automatic reconnect policy after a lost connection; omission uses the defaults. */
68
+ reconnect?: ReconnectConfig;
63
69
  }
64
70
  /** Configuration for one stdio or Streamable HTTP MCP server. */
65
71
  export type Config = StdioConfig | StreamableHttpConfig;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@deepseek-ai/dsh-mcp-client",
3
3
  "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools",
4
- "version": "0.0.1-rc.1",
4
+ "version": "0.0.1-rc.2",
5
5
  "publishConfig": {
6
6
  "access": "restricted"
7
7
  },
@@ -32,11 +32,12 @@
32
32
  ],
33
33
  "license": "BSD-3-Clause",
34
34
  "peerDependencies": {
35
- "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
36
- "@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
37
- "@deepseek-ai/dsh-subprocess": "^0.0.1-rc.1",
38
- "@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
39
- "@deepseek-ai/cordis": "^4.0.1-rc.1"
35
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.2",
36
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.2",
37
+ "@deepseek-ai/dsh-subprocess": "^0.0.1-rc.2",
38
+ "@deepseek-ai/dsh-timeout": "^0.0.1-rc.2",
39
+ "@deepseek-ai/cordis": "^4.0.1-rc.1",
40
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.2"
40
41
  },
41
42
  "dependencies": {
42
43
  "@modelcontextprotocol/sdk": "^1.12.0",
@@ -46,10 +47,11 @@
46
47
  "devDependencies": {
47
48
  "@modelcontextprotocol/server-everything": "^2026.7.4",
48
49
  "@modelcontextprotocol/server-filesystem": "^2026.7.4",
49
- "@deepseek-ai/dsh-invariants": "^0.0.1-rc.1",
50
- "@deepseek-ai/dsh-llm": "^0.0.1-rc.1",
50
+ "@deepseek-ai/dsh-invariants": "^0.0.1-rc.2",
51
+ "@deepseek-ai/dsh-subprocess": "^0.0.1-rc.2",
52
+ "@deepseek-ai/dsh-tools": "^0.0.1-rc.2",
51
53
  "@deepseek-ai/cordis": "^4.0.1-rc.1",
52
- "@deepseek-ai/dsh-tools": "^0.0.1-rc.1",
53
- "@deepseek-ai/dsh-subprocess": "^0.0.1-rc.1"
54
+ "@deepseek-ai/dsh-timeout": "^0.0.1-rc.2",
55
+ "@deepseek-ai/dsh-llm": "^0.0.1-rc.2"
54
56
  }
55
57
  }