dsh-mcp 1.0.0 → 1.2.0

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.
@@ -0,0 +1,733 @@
1
+ import z from "@deepseek-ai/schemastery";
2
+ import { MAX_TIMER_DELAY_MS } from "@deepseek-ai/dsh-timeout";
3
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
4
+ import { ListToolsResultSchema, ToolListChangedNotificationSchema } from "@modelcontextprotocol/sdk/types.js";
5
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
6
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
7
+ import { scrubbedParentEnv } from "@deepseek-ai/dsh-subprocess";
8
+ import { createHash } from "node:crypto";
9
+ import { z as z$1 } from "zod";
10
+ import { assertSupportedJsonSchema } from "@deepseek-ai/dsh-tools";
11
+ //#region lib/types/transport.js
12
+ /**
13
+ * Transport factory: creates the appropriate MCP transport based on the
14
+ * plugin's resolved config. Stdio spawns a child process (with credential
15
+ * scrubbing); Streamable HTTP connects to a URL.
16
+ *
17
+ * @module
18
+ */
19
+ /**
20
+ * The subprocess seam's scrubbed parent env (credential-shaped and stale
21
+ * `DSH_*` names dropped), plus the spec's explicit env. The MCP SDK owns the
22
+ * actual spawn, so this transport shares the scrub definition rather than the
23
+ * spawn path.
24
+ */
25
+ function buildChildEnv(extra) {
26
+ return {
27
+ ...scrubbedParentEnv(),
28
+ ...extra
29
+ };
30
+ }
31
+ /**
32
+ * Create an MCP transport from the resolved plugin config.
33
+ *
34
+ * @param config - Resolved plugin config discriminated on `transport`.
35
+ * @returns A connected-ready MCP Transport (stdio or Streamable HTTP).
36
+ */
37
+ function createTransport(config) {
38
+ switch (config.transport) {
39
+ case "stdio": return new StdioClientTransport({
40
+ command: config.command,
41
+ args: config.args,
42
+ env: buildChildEnv(config.env),
43
+ cwd: config.cwd
44
+ });
45
+ case "streamable-http": return new StreamableHTTPClientTransport(new URL(config.url), {
46
+ requestInit: { headers: config.headers },
47
+ ...(config.authProvider === void 0 ? {} : { authProvider: config.authProvider })
48
+ });
49
+ }
50
+ }
51
+ //#endregion
52
+ //#region lib/types/tools.js
53
+ /**
54
+ * Tool bridge: discovers MCP tools, registers them on the harness ToolRuntime
55
+ * under deterministic server-qualified public names, and handles re-sync when
56
+ * the server's tool list changes.
57
+ *
58
+ * Naming contract (see the mcp-client Agent Note "Naming invariants"): every MCP tool
59
+ * has the stable identity `(serverName, rawName)`; the model-facing public name
60
+ * is `mcp__<serverName>__<rawName>`, normalized to the DeepSeek function-name
61
+ * constraints. The raw name is only ever sent on the wire (`tools/call`); the
62
+ * public name is never parsed to recover it.
63
+ *
64
+ * @module
65
+ */
66
+ /**
67
+ * DeepSeek function-name contract: at most 64 characters. Wire-protocol
68
+ * constant, not configuration.
69
+ */
70
+ const MAX_PUBLIC_NAME_LENGTH = 64;
71
+ /** DeepSeek function-name contract: only `[A-Za-z0-9_-]` is allowed. */
72
+ const INVALID_NAME_CHARS = /[^A-Za-z0-9_-]/g;
73
+ /** Hex chars of the SHA-256 identity hash appended on lossy normalization. */
74
+ const HASH_LENGTH = 12;
75
+ /** Raw result record: the bridge owns JSON-value validation after transport. */
76
+ const RawCallToolResultSchema = z$1.record(z$1.string(), z$1.unknown());
77
+ /** List without mutating the SDK's per-page output-validator cache. */
78
+ function listToolsUncached(client, cursor) {
79
+ return client.request({
80
+ method: "tools/list",
81
+ ...cursor === void 0 ? {} : { params: { cursor } }
82
+ }, ListToolsResultSchema);
83
+ }
84
+ /** Call without the SDK pre-validating an output schema the bridge may not support. */
85
+ function callToolUncached(client, rawName, args, exec, opts) {
86
+ return client.request({
87
+ method: "tools/call",
88
+ params: {
89
+ name: rawName,
90
+ arguments: args
91
+ }
92
+ }, RawCallToolResultSchema, {
93
+ signal: exec.signal,
94
+ timeout: opts.toolCallTimeoutMs
95
+ });
96
+ }
97
+ /**
98
+ * Derive the model-facing public name for one MCP tool.
99
+ *
100
+ * Deterministic pure function of `(serverName, rawName)`: the clean case is
101
+ * `mcp__<serverName>__<rawName>` verbatim. When character replacement or
102
+ * truncation to the DeepSeek function-name contract (64 chars,
103
+ * `[A-Za-z0-9_-]`) changes the name, a 12-hex-char SHA-256 hash of the
104
+ * identity is appended so distinct MCP identities never collapse into the
105
+ * same public name.
106
+ *
107
+ * @param serverName - Stable local namespace from plugin config.
108
+ * @param rawName - The MCP server's own tool name.
109
+ * @returns The globally unique, model-facing ToolRuntime name.
110
+ */
111
+ function publicToolName(serverName, rawName) {
112
+ const joined = `mcp__${serverName}__${rawName}`;
113
+ const normalized = joined.replace(INVALID_NAME_CHARS, "_");
114
+ if (normalized === joined && normalized.length <= MAX_PUBLIC_NAME_LENGTH) return normalized;
115
+ const hash = createHash("sha256").update(`${serverName}\0${rawName}`).digest("hex").slice(0, HASH_LENGTH);
116
+ return `${normalized.slice(0, MAX_PUBLIC_NAME_LENGTH - HASH_LENGTH - 1)}_${hash}`;
117
+ }
118
+ /**
119
+ * Sync the MCP server's tool list into the harness ToolRuntime.
120
+ *
121
+ * Two phases keep the swap safe:
122
+ *
123
+ * 1. Fetch: drain uncached `tools/list` pagination and build the full next
124
+ * generation of `ToolDefinition`s under public names. Any failure here
125
+ * (network error, duplicate raw name in the server's list) rejects and
126
+ * leaves the previous generation registered untouched.
127
+ * 2. Swap: dispose the previous generation, register the new one. A registry
128
+ * conflict here can only mean a foreign registration squats on this
129
+ * server's `mcp__<serverName>__` namespace — the partial generation is
130
+ * rolled back (zero tools from this server) and logged. Initial strict
131
+ * synchronization may propagate the conflict so its parent transaction
132
+ * rejects; ordinary clients and later re-syncs return an empty map.
133
+ *
134
+ * @param client - Connected MCP Client instance used to list and call tools.
135
+ * @param ctx - Cordis context providing the `tools` service for registration.
136
+ * @param opts - Bridge options: server namespace and per-call timeout.
137
+ * @param previous - Disposer map from the prior sync generation; disposed
138
+ * during the swap phase (only after the fetch phase succeeded).
139
+ * @returns A map of registered public tool names to their unregister
140
+ * disposers — the exact set of live registrations owned by this server.
141
+ */
142
+ async function syncTools(client, ctx, opts, previous) {
143
+ const definitions = /* @__PURE__ */ new Map();
144
+ let cursor;
145
+ do {
146
+ const response = await listToolsUncached(client, cursor);
147
+ for (const tool of response.tools) {
148
+ const publicName = publicToolName(opts.serverName, tool.name);
149
+ if (definitions.has(publicName)) throw new Error(`mcp-client(${opts.serverName}): server listed tool "${tool.name}" more than once — invalid tool list`);
150
+ definitions.set(publicName, {
151
+ name: publicName,
152
+ description: tool.description ?? "",
153
+ parameters: tool.inputSchema,
154
+ output: createOutput(tool.name, supportedOutputSchema(tool.outputSchema)),
155
+ execute: createExecutor(client, tool.name, tool.execution?.taskSupport === "required", opts)
156
+ });
157
+ }
158
+ cursor = response.nextCursor;
159
+ } while (cursor);
160
+ // Tool-stability pass (dsh-mcp vendored extension): during a same-connection
161
+ // re-sync (e.g. a `tools/list_changed` notification) a tool whose public
162
+ // surface (name/description/parameters/output schema) is unchanged keeps its
163
+ // existing registration instead of being disposed and re-registered.
164
+ // Keeping the system-prompt tool list stable preserves prompt-cache hits;
165
+ // execution still calls the live MCP server, so server-side changes need no
166
+ // re-registration. A reconnected generation gets `stable: false` and rebuilds
167
+ // every tool because its executors bind the new client.
168
+ const kept = /* @__PURE__ */ new Set();
169
+ if (opts.stable !== false) {
170
+ for (const [publicName, definition] of definitions) {
171
+ if (!previous.has(publicName)) continue;
172
+ const existing = ctx.tools.get(publicName);
173
+ if (existing !== void 0 && toolPublicEqual(existing, definition)) kept.add(publicName);
174
+ }
175
+ }
176
+ for (const [publicName, dispose] of previous) {
177
+ if (!kept.has(publicName)) dispose();
178
+ }
179
+ const disposers = /* @__PURE__ */ new Map();
180
+ try {
181
+ for (const [publicName, definition] of definitions) {
182
+ if (kept.has(publicName)) {
183
+ disposers.set(publicName, previous.get(publicName));
184
+ continue;
185
+ }
186
+ disposers.set(publicName, ctx.tools.register(definition));
187
+ }
188
+ } catch (error) {
189
+ for (const dispose of disposers.values()) dispose();
190
+ ctx.logger.error(`mcp-client(${opts.serverName}): tool registration failed, no tools registered: ${String(error)}`);
191
+ if (opts.registrationFailure === "throw") throw error;
192
+ return /* @__PURE__ */ new Map();
193
+ }
194
+ return disposers;
195
+ }
196
+ /** Compare the public, cache-relevant surface of two tool definitions (dsh-mcp vendored). */
197
+ function toolPublicEqual(a, b) {
198
+ if (a.name !== b.name || a.description !== b.description) return false;
199
+ if (!jsonEqual(a.parameters, b.parameters)) return false;
200
+ return jsonEqual(a.output && a.output.schema, b.output && b.output.schema);
201
+ }
202
+ /** Deep equality of JSON-serializable values (schema objects). */
203
+ function jsonEqual(a, b) {
204
+ return JSON.stringify(a ?? null) === JSON.stringify(b ?? null);
205
+ }
206
+ /** Keep a supported advertised schema; unsupported MCP vocabulary falls back to JsonValue. */
207
+ function supportedOutputSchema(candidate) {
208
+ if (candidate === void 0) return void 0;
209
+ try {
210
+ assertSupportedJsonSchema(candidate);
211
+ return candidate;
212
+ } catch {
213
+ return;
214
+ }
215
+ }
216
+ /** Build the canonical result schema and existing Native text projection. */
217
+ function createOutput(rawName, structuredSchema) {
218
+ return {
219
+ schema: {
220
+ type: "object",
221
+ properties: {
222
+ content: {
223
+ type: "array",
224
+ items: {}
225
+ },
226
+ structuredContent: structuredSchema ?? {}
227
+ },
228
+ required: structuredSchema === void 0 ? ["content"] : ["content", "structuredContent"],
229
+ additionalProperties: false
230
+ },
231
+ render(_args, value) {
232
+ return [{
233
+ type: "text",
234
+ text: extractText(value.content, rawName)
235
+ }];
236
+ }
237
+ };
238
+ }
239
+ /**
240
+ * Create an execute function for one MCP tool. The executor closes over the
241
+ * raw MCP tool name and sends an uncached `tools/call` request with it (never
242
+ * the public name), with abort signal and timeout, then maps the result to
243
+ * harness ContentBlocks. Owning the raw request prevents the SDK's internal
244
+ * per-page schema cache from pre-validating a different contract.
245
+ *
246
+ * When the MCP server returns `isError: true`, the executor throws so that
247
+ * the ToolRuntime's catch path produces an `isError` result for the model.
248
+ */
249
+ function createExecutor(client, rawName, taskRequired, opts) {
250
+ return async (args, exec) => {
251
+ if (taskRequired) throw new Error(`Tool "${rawName}" requires task-based execution, which this bridge does not support`);
252
+ const result = await callToolUncached(client, rawName, typeof args === "object" && args !== null ? args : {}, exec, opts);
253
+ if (!Array.isArray(result.content)) {
254
+ const rendered = "toolResult" in result ? JSON.stringify(result.toolResult) : "(no output)";
255
+ const text = typeof rendered === "string" ? rendered : "(no output)";
256
+ if (result.isError === true) throw new Error(text);
257
+ return {
258
+ content: [{
259
+ type: "text",
260
+ text
261
+ }],
262
+ ...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
263
+ };
264
+ }
265
+ const content = result.content;
266
+ const text = extractText(content, rawName);
267
+ if (result.isError === true) throw new Error(text);
268
+ return {
269
+ content,
270
+ ...result.structuredContent !== void 0 ? { structuredContent: result.structuredContent } : {}
271
+ };
272
+ };
273
+ }
274
+ /**
275
+ * Extract text from an MCP content array into a single string.
276
+ * - text blocks: join with '\n'
277
+ * - image/audio/resource blocks: replaced with a placeholder
278
+ *
279
+ * Defensive: fields that the MCP spec declares required (mimeType, text) are
280
+ * guarded with fallbacks because this is a network trust boundary.
281
+ */
282
+ function extractText(mcpContent, toolName) {
283
+ const parts = [];
284
+ for (const value of mcpContent) {
285
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
286
+ parts.push("[unsupported content type: unknown]");
287
+ continue;
288
+ }
289
+ const block = value;
290
+ switch (block.type) {
291
+ case "text":
292
+ if (block.text !== void 0) parts.push(block.text);
293
+ break;
294
+ case "image":
295
+ parts.push(`[image: ${block.mimeType ?? "unknown"}, content discarded]`);
296
+ break;
297
+ case "audio":
298
+ parts.push(`[audio: ${block.mimeType ?? "unknown"}, content discarded]`);
299
+ break;
300
+ case "resource":
301
+ case "resource_link":
302
+ parts.push("[resource: content discarded]");
303
+ break;
304
+ default: parts.push(`[unsupported content type: ${block.type}]`);
305
+ }
306
+ }
307
+ return parts.join("\n") || `(${toolName} returned no text content)`;
308
+ }
309
+ //#endregion
310
+ //#region lib/types/connection.js
311
+ /**
312
+ * Connection supervisor: owns the MCP client/transport generations for one
313
+ * plugin instance, keeps the harness tool registry in sync with the live
314
+ * generation, and — when the connection drops — restarts the configured
315
+ * server with bounded exponential backoff.
316
+ *
317
+ * One outage shares one attempt budget (`maxAttempts` consecutive failed
318
+ * attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A
319
+ * connection that stays up past the stability window closes the outage, so
320
+ * the next disconnect starts a fresh budget while a crash-looping server —
321
+ * even one whose connects briefly succeed — still exhausts the cap instead of
322
+ * restarting forever. Exhaustion unregisters the server's tools and stops;
323
+ * disposal (including HMR) is the only way back from that state.
324
+ *
325
+ * @module
326
+ */
327
+ /** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */
328
+ const RECONNECT_DEFAULTS = Object.freeze({
329
+ enabled: true,
330
+ initialDelayMs: 500,
331
+ maxDelayMs: 3e4,
332
+ maxAttempts: 10
333
+ });
334
+ const GENERATION_CLOSE_TIMEOUT_MS = 5e3;
335
+ /**
336
+ * The one explicit resolve step from raw reconnect config to the policy the
337
+ * supervisor runs. Programmatic construction may bypass Schemastery
338
+ * normalization, so every default and bound is re-judged here — misconfiguration
339
+ * fails the plugin instance at load.
340
+ *
341
+ * @param config - Raw `reconnect` config; omission uses the defaults.
342
+ * @param path - Diagnostic prefix naming the config location in thrown messages.
343
+ * @returns The frozen resolved policy.
344
+ */
345
+ function resolveReconnectPolicy(config, path) {
346
+ if (config !== void 0) {
347
+ for (const key of Object.keys(config)) if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`);
348
+ }
349
+ const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled;
350
+ const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs;
351
+ const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs;
352
+ const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts;
353
+ 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}`);
354
+ 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}`);
355
+ if (initialDelayMs > maxDelayMs) throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`);
356
+ if (!Number.isInteger(maxAttempts) || maxAttempts < 1) throw new Error(`${path}.maxAttempts must be a positive integer`);
357
+ return Object.freeze({
358
+ enabled,
359
+ initialDelayMs,
360
+ maxDelayMs,
361
+ maxAttempts
362
+ });
363
+ }
364
+ /**
365
+ * Start the supervised connection for one MCP server and keep it alive per
366
+ * the reconnect policy.
367
+ *
368
+ * @param ctx - Cordis context providing the `tools` registry and logger.
369
+ * @param config - Resolved plugin config selecting the transport and server identity.
370
+ * @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}.
371
+ * @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown.
372
+ */
373
+ function startConnection(ctx, config, policy) {
374
+ const label = `mcp-client(${config.serverName})`;
375
+ const opts = {
376
+ registrationFailure: "contain",
377
+ serverName: config.serverName,
378
+ toolCallTimeoutMs: config.toolCallTimeoutMs
379
+ };
380
+ const startupOpts = config.failOnStartupError ? {
381
+ ...opts,
382
+ registrationFailure: "throw"
383
+ } : opts;
384
+ let disposed = false;
385
+ /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */
386
+ let client;
387
+ /** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */
388
+ let clientClosed;
389
+ /** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */
390
+ let disposers = /* @__PURE__ */ new Map();
391
+ let reconnectTimer;
392
+ /** Consecutive failed connection attempts within the current outage. */
393
+ let failedAttempts = 0;
394
+ /** When the current generation finished connect + initial sync; undefined while down. */
395
+ let connectedAt;
396
+ /** The real error from the first connection attempt, for startup-await diagnostics. */
397
+ let firstAttemptError;
398
+ /** A generation may act only while it is the current one on a live plugin. */
399
+ const isCurrent = (generation) => !disposed && client === generation;
400
+ /**
401
+ * Serializes every syncTools call — initial syncs and notification re-syncs
402
+ * across all generations — so two syncs can never interleave their
403
+ * dispose-previous/register-next swap (which would double-dispose one
404
+ * generation and leak another).
405
+ */
406
+ let syncChain = Promise.resolve();
407
+ /** The client of the most recent sync (dsh-mcp vendored): a same-client re-sync may keep unchanged tools. */
408
+ let lastSyncedClient;
409
+ function enqueueSync(generation, syncOpts = opts) {
410
+ const run = syncChain.then(async () => {
411
+ if (!isCurrent(generation)) return;
412
+ // A same-client re-sync (notification) keeps unchanged tools; a fresh
413
+ // generation (reconnect) must rebuild executors for the new client.
414
+ const stable = generation === lastSyncedClient;
415
+ lastSyncedClient = generation;
416
+ disposers = await syncTools(generation, ctx, { ...syncOpts, stable }, disposers);
417
+ });
418
+ syncChain = run.catch(() => {});
419
+ return run;
420
+ }
421
+ /** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */
422
+ function generationDown(generation) {
423
+ if (!isCurrent(generation)) return;
424
+ client = void 0;
425
+ clientClosed = void 0;
426
+ scheduleReconnect();
427
+ }
428
+ /** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */
429
+ function waitForClose(closed) {
430
+ return new Promise((resolve) => {
431
+ const timeout = setTimeout(() => {
432
+ resolve(false);
433
+ }, GENERATION_CLOSE_TIMEOUT_MS);
434
+ timeout.unref();
435
+ closed.then(() => {
436
+ clearTimeout(timeout);
437
+ resolve(true);
438
+ });
439
+ });
440
+ }
441
+ function scheduleReconnect() {
442
+ const lostEstablishedConnection = connectedAt !== void 0;
443
+ if (!policy.enabled) {
444
+ 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";
445
+ ctx.logger.error(`${label}: ${message}`);
446
+ return;
447
+ }
448
+ if (connectedAt !== void 0 && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0;
449
+ connectedAt = void 0;
450
+ failedAttempts += 1;
451
+ if (failedAttempts > policy.maxAttempts) {
452
+ syncChain = syncChain.then(() => {
453
+ for (const dispose of disposers.values()) dispose();
454
+ disposers = /* @__PURE__ */ new Map();
455
+ });
456
+ ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`);
457
+ return;
458
+ }
459
+ const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1));
460
+ const action = lostEstablishedConnection ? "connection lost; reconnecting" : "connection failed; retrying";
461
+ ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`);
462
+ reconnectTimer = setTimeout(() => {
463
+ reconnectTimer = void 0;
464
+ settling = connectGeneration(false);
465
+ }, delayMs);
466
+ reconnectTimer.unref();
467
+ }
468
+ /**
469
+ * One connection attempt: fresh transport + client (the MCP SDK binds a
470
+ * Protocol to one transport for life), connect, then queue the initial tool
471
+ * sync. The startup flag belongs to the attempt rather than the shared sync
472
+ * queue, so an early notification cannot consume strict startup semantics.
473
+ * Every failure funnels through {@link generationDown}; success arms the
474
+ * onclose-driven disconnect path. Never rejects.
475
+ *
476
+ * @param startup - Whether this is the plugin's activation attempt.
477
+ */
478
+ async function connectGeneration(startup) {
479
+ const generation = new Client({
480
+ name: "dsh-mcp-client",
481
+ version: "0.0.1"
482
+ }, {
483
+ capabilities: {},
484
+ ...(opts.authProvider === void 0 ? {} : { authProvider: opts.authProvider })
485
+ });
486
+ const closed = Promise.withResolvers();
487
+ let attemptSettled = false;
488
+ let closeObserved = false;
489
+ const hasClosed = () => closeObserved;
490
+ client = generation;
491
+ clientClosed = closed.promise;
492
+ generation.onclose = () => {
493
+ closeObserved = true;
494
+ closed.resolve();
495
+ if (attemptSettled) generationDown(generation);
496
+ };
497
+ generation.setNotificationHandler(ToolListChangedNotificationSchema, async () => {
498
+ if (!isCurrent(generation)) return;
499
+ ctx.logger.info(`${label}: tool list changed, re-syncing`);
500
+ try {
501
+ await enqueueSync(generation);
502
+ } catch (error) {
503
+ if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`);
504
+ }
505
+ });
506
+ try {
507
+ await generation.connect(createTransport(config));
508
+ if (hasClosed()) {
509
+ attemptSettled = true;
510
+ generationDown(generation);
511
+ return;
512
+ }
513
+ await enqueueSync(generation, startup ? startupOpts : opts);
514
+ } catch (error) {
515
+ if (firstAttemptError === void 0) firstAttemptError = error;
516
+ if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`);
517
+ try {
518
+ await generation.close();
519
+ } catch {}
520
+ const quiesced = hasClosed() || await waitForClose(closed.promise);
521
+ attemptSettled = true;
522
+ if (!isCurrent(generation)) return;
523
+ if (!quiesced) {
524
+ client = void 0;
525
+ clientClosed = void 0;
526
+ 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`);
527
+ return;
528
+ }
529
+ generationDown(generation);
530
+ return;
531
+ }
532
+ attemptSettled = true;
533
+ if (hasClosed()) {
534
+ generationDown(generation);
535
+ return;
536
+ }
537
+ if (!isCurrent(generation)) return;
538
+ connectedAt = Date.now();
539
+ if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`);
540
+ }
541
+ /** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */
542
+ let settling = connectGeneration(true);
543
+ return {
544
+ ready: settling.then(() => {
545
+ if (client !== void 0) return {};
546
+ /* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */
547
+ return { error: firstAttemptError ?? /* @__PURE__ */ new Error(`${label}: initial connection failed`) };
548
+ }),
549
+ async dispose() {
550
+ disposed = true;
551
+ if (reconnectTimer !== void 0) {
552
+ clearTimeout(reconnectTimer);
553
+ reconnectTimer = void 0;
554
+ }
555
+ const current = client;
556
+ const currentClosed = clientClosed;
557
+ client = void 0;
558
+ clientClosed = void 0;
559
+ if (current !== void 0) {
560
+ try {
561
+ await current.close();
562
+ } catch {}
563
+ 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`);
564
+ }
565
+ await settling;
566
+ await syncChain;
567
+ for (const dispose of disposers.values()) dispose();
568
+ disposers = /* @__PURE__ */ new Map();
569
+ }
570
+ };
571
+ }
572
+ //#endregion
573
+ //#region lib/types/probe.js
574
+ /**
575
+ * One-shot connection probe: connects to an MCP server, lists its tools, and
576
+ * closes the transport. Registers nothing — management surfaces use it to test
577
+ * a server configuration before it is kept or mounted, so a probe must never
578
+ * touch `ctx.tools` or any plugin lifecycle.
579
+ *
580
+ * @module
581
+ */
582
+ /** Default overall deadline for one probe (ms). */
583
+ const DEFAULT_PROBE_TIMEOUT_MS = 15e3;
584
+ /** Keep a thrown value readable for a management UI. */
585
+ function probeErrorMessage(error) {
586
+ return error instanceof Error ? error.message : String(error);
587
+ }
588
+ /**
589
+ * Connect, drain the server's `tools/list` pagination, then close.
590
+ *
591
+ * @param client - Fresh, unconnected MCP Client for this probe.
592
+ * @param config - Resolved plugin config selecting transport and server identity.
593
+ * @returns The discovered tool summary.
594
+ */
595
+ async function runProbe(client, config) {
596
+ await client.connect(createTransport(config));
597
+ const tools = [];
598
+ let cursor;
599
+ do {
600
+ const response = await client.request({
601
+ method: "tools/list",
602
+ ...cursor === void 0 ? {} : { params: { cursor } }
603
+ }, ListToolsResultSchema);
604
+ for (const tool of response.tools) tools.push({
605
+ name: tool.name,
606
+ ...tool.description === void 0 ? {} : { description: tool.description }
607
+ });
608
+ cursor = response.nextCursor;
609
+ } while (cursor);
610
+ return {
611
+ ok: true,
612
+ tools
613
+ };
614
+ }
615
+ /**
616
+ * Probe one MCP server configuration without registering anything.
617
+ *
618
+ * The overall connect + list attempt races a deadline; a timeout returns a
619
+ * failure rather than hanging the caller. The transport is always closed on
620
+ * the way out — for stdio that terminates the probed child process.
621
+ *
622
+ * @param config - Transport and server identity to probe.
623
+ * @param options - Optional probe deadline override.
624
+ * @returns the tool listing, or a readable failure.
625
+ */
626
+ async function probeConnection(config, options = {}) {
627
+ const timeoutMs = options.timeoutMs ?? DEFAULT_PROBE_TIMEOUT_MS;
628
+ if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) throw new Error(`probeConnection: timeoutMs must be a positive finite number, received ${String(timeoutMs)}`);
629
+ const client = new Client({
630
+ name: "dsh-mcp-client",
631
+ version: "0.0.1"
632
+ }, { capabilities: {} });
633
+ const timeout = new Promise((_, reject) => {
634
+ setTimeout(() => reject(/* @__PURE__ */ new Error(`connection probe timed out after ${timeoutMs}ms`)), timeoutMs).unref();
635
+ });
636
+ try {
637
+ return await Promise.race([runProbe(client, config), timeout]);
638
+ } catch (error) {
639
+ return {
640
+ ok: false,
641
+ message: probeErrorMessage(error)
642
+ };
643
+ } finally {
644
+ try {
645
+ await client.close();
646
+ } catch {}
647
+ }
648
+ }
649
+ //#endregion
650
+ //#region lib/types/index.js
651
+ /**
652
+ * MCP client bridge plugin: connects to an external MCP server and registers
653
+ * its tools on `ctx.tools` under server-qualified public names
654
+ * (`mcp__<serverName>__<rawName>`). Each plugin instance connects to one MCP
655
+ * server; load multiple instances in `cordis.yml` for multiple servers.
656
+ *
657
+ * Namespace plugin (named exports, no default export). Lifecycle is
658
+ * effect-scoped: disposal disconnects from the server, unregisters all tools,
659
+ * and releases the `serverName` namespace reservation. HMR hot-swaps by
660
+ * disposing the old instance and creating a new one; identical `serverName`
661
+ * reproduces identical public tool names.
662
+ *
663
+ * @module @deepseek-ai/dsh-mcp-client
664
+ */
665
+ /** Cordis plugin name used by loader diagnostics. */
666
+ const name = "mcp-client";
667
+ /** Services required by this plugin. */
668
+ const inject = ["tools"];
669
+ /** Default timeout for individual MCP tool calls (ms). */
670
+ const DEFAULT_TOOL_CALL_TIMEOUT_MS = 6e4;
671
+ /** Valid `serverName`, kept below the public tool-name budget. */
672
+ const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/;
673
+ /**
674
+ * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps
675
+ * in one process — tests — must not see each other's names). A duplicate
676
+ * namespace is a configuration error surfaced at plugin load, never silent
677
+ * shadowing.
678
+ */
679
+ const activeServerNames = /* @__PURE__ */ new WeakMap();
680
+ const Reconnect = z.object({
681
+ enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled),
682
+ initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs),
683
+ maxDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.maxDelayMs),
684
+ maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts)
685
+ });
686
+ const Config = z.union([z.object({
687
+ transport: z.const("stdio"),
688
+ serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
689
+ command: z.string().required(),
690
+ args: z.array(String).default([]),
691
+ env: z.dict(String).default({}),
692
+ cwd: z.string().default(""),
693
+ toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
694
+ failOnStartupError: z.boolean().default(false),
695
+ reconnect: Reconnect
696
+ }), z.object({
697
+ transport: z.const("streamable-http"),
698
+ serverName: z.string().required().pattern(SERVER_NAME_PATTERN),
699
+ url: z.string().required(),
700
+ headers: z.dict(String).default({}),
701
+ toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS),
702
+ failOnStartupError: z.boolean().default(false),
703
+ reconnect: Reconnect
704
+ })]);
705
+ /**
706
+ * Connect one MCP server and publish its initial tool generation before activation.
707
+ * This entry remains explicitly `async`: Cordis treats a prototype-bearing
708
+ * ordinary function as a constructor, whose returned Promise is not startup work.
709
+ * @param ctx - plugin context carrying the tool registry.
710
+ * @param config - resolved transport and server namespace configuration.
711
+ * @returns startup readiness after connection and initial tool discovery settle.
712
+ */
713
+ async function apply(ctx, config) {
714
+ const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`);
715
+ ctx.effect(() => {
716
+ let names = activeServerNames.get(ctx.root);
717
+ if (!names) {
718
+ names = /* @__PURE__ */ new Set();
719
+ activeServerNames.set(ctx.root, names);
720
+ }
721
+ if (names.has(config.serverName)) throw new Error(`mcp-client: serverName "${config.serverName}" is already in use by another mcp-client instance — pick a unique serverName in cordis.yml`);
722
+ names.add(config.serverName);
723
+ return () => void names.delete(config.serverName);
724
+ }, "mcp-client.serverName");
725
+ const connection = startConnection(ctx, config, reconnect);
726
+ ctx.effect(() => {
727
+ return () => connection.dispose();
728
+ }, "mcp-client.connection");
729
+ const outcome = await connection.ready;
730
+ if (outcome.error !== void 0 && config.failOnStartupError) throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error });
731
+ }
732
+ //#endregion
733
+ export { Config, apply, inject, name, probeConnection };