toolfunnel 0.7.0 → 0.8.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.
Files changed (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
@@ -1,1070 +1,1174 @@
1
- 'use strict';
2
-
3
- /**
4
- * mcp-client.js - the MCP CLIENT: the aggregator's per-upstream connection.
5
- *
6
- * This is the REVERSE of src/mcp/server.js. Where server.js is a JSON-RPC 2.0
7
- * server that the CLI connects TO, this is a JSON-RPC 2.0 client that spawns an
8
- * UPSTREAM MCP server as a child process and talks to it over stdio. The
9
- * aggregator owns one McpClient per upstream MCP server, lists their tools, and
10
- * routes curated calls through them.
11
- *
12
- * Transport contract (mirrors server.js's framing helpers but in the other direction):
13
- * WRITE: newline-delimited JSON - one compact message per line, which is what real
14
- * SDK-based MCP servers read on stdin (Content-Length framing silently broke
15
- * every real upstream - see frameRequest()).
16
- * READ: tolerate BOTH `Content-Length:` framing AND newline-delimited JSON, so the
17
- * same client works against a real MCP server AND against our own server.js
18
- * (which WRITES newline-delimited JSON). This is server.js's dual-drain logic
19
- * re-used in the client.
20
- *
21
- * JSON-RPC requests are matched to responses by `id`. Every request carries a
22
- * timeout (requestTimeoutMs) - a slow/dead upstream rejects the in-flight promise
23
- * instead of leaking it. Notifications (no `id`) from the server are ignored.
24
- *
25
- * SAFETY / defensiveness:
26
- * - close() is idempotent and NEVER leaves a zombie child: it ends stdin, then if the
27
- * child is still alive tree-kills it (win32: taskkill /T /F; else child.kill()).
28
- * - connect() that fails (spawn error or initialize timeout) calls close() so no child
29
- * is left running, then rejects with a clear error.
30
- * - A child that exits while requests are in flight rejects every waiter (no hang).
31
- * - spawnImpl is injectable so a unit test can fake the child, but the real round-trip
32
- * against the fixture is the load-bearing test.
33
- *
34
- * CommonJS only. Node built-ins only. No @modelcontextprotocol/sdk.
35
- */
36
-
37
- const childProcess = require('node:child_process');
38
- const path = require('node:path');
39
-
40
- const JSONRPC = '2.0';
41
- const PROTOCOL_VERSION = '2024-11-05';
42
- // The 2026-07-28 ("modern") protocol version this client can SPEAK to a modern upstream. Over
43
- // stdio the modern era needs only per-request `_meta` - the Mcp-Method/Mcp-Name/MCP-Protocol-Version
44
- // headers are HTTP-transport-only, so a stdio client is modern by adding `_meta` + skipping the
45
- // initialize handshake. Built to the RC spec (locked 2026-05-21); reconcile minor final deltas 28 July.
46
- const MODERN_PROTOCOL_VERSION = '2026-07-28';
47
- const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
48
-
49
- /**
50
- * Cross-platform launch resolution for the upstream command.
51
- *
52
- * POSIX: pass through unchanged - execvp resolves `npx` / `uvx` / `node` / ... on PATH.
53
- *
54
- * Windows: child_process with shell:false CANNOT run a `.cmd` / `.bat` shim - a bare name ENOENTs,
55
- * and an explicit `.cmd` throws EINVAL (the CVE-2024-27980 mitigation). Most MCP servers launch via
56
- * exactly such shims (`npx`, `npm`, `uvx`, `pnpm`, ...), so on Windows route anything that isn't already
57
- * a concrete `.exe`/`.com` through cmd.exe, which resolves PATHEXT shims. The args stay DISCRETE
58
- * (never shell-concatenated into one string) and there is none of the `shell:true` DEP0190 hazard.
59
- * HONEST CAVEAT: because the spawned binary here is cmd.exe itself (an .exe), Node applies its
60
- * STANDARD argv quoting - not cmd.exe ^-metacharacter escaping (that special-casing only fires when
61
- * the command IS the .cmd/.bat). An arg containing cmd metacharacters (& | > < ") outside quotes can
62
- * therefore still be reinterpreted by cmd.exe. These args come from the operator's own expose.json,
63
- * so this is a correctness caveat on operator-authored config, not a privilege boundary; the fully
64
- * escaped alternative (resolve the real interpreter à la auth/install.js's `node + npm-cli.js` and
65
- * spawn it directly) is the upgrade path if it ever bites. close()'s taskkill /T still reaps the
66
- * whole cmd -> shim -> server process tree.
67
- *
68
- * @param {string} command the configured upstream command (e.g. "npx", "node", an abs path)
69
- * @param {string[]} args the configured argv
70
- * @returns {{ command: string, args: string[] }} what to actually hand to spawn()
71
- */
72
- function winLaunch(command, args) {
73
- const argv = Array.isArray(args) ? args : [];
74
- if (process.platform !== 'win32') return { command, args: argv };
75
- // A concrete executable can be spawned directly - no shim resolution needed.
76
- if (path.isAbsolute(command) && /\.(exe|com)$/i.test(command)) return { command, args: argv };
77
- // Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT.
78
- const comspec = process.env.ComSpec || 'cmd.exe';
79
- return { command: comspec, args: ['/c', command, ...argv] };
80
- }
81
-
82
- /**
83
- * McpClient - a hand-rolled JSON-RPC 2.0 client over a child process's stdio.
84
- *
85
- * Lifecycle: `new McpClient(opts)` -> `await connect()` -> `listTools()` / `callTool()`
86
- * -> `close()` (idempotent).
87
- */
88
- // Payload-bearing methods get the LONG window (toolTimeoutMs): a tool call legitimately runs as
89
- // long as the tool runs. Everything else (handshake, lists, discover, ping) keeps the SHORT
90
- // requestTimeoutMs - that is the dead-upstream detector.
91
- const PAYLOAD_METHODS = new Set(['tools/call', 'prompts/get', 'resources/read']);
92
-
93
- // Hard ceiling on one buffered stdout message (matches http-transport's MAX_BODY_BYTES). A
94
- // declared Content-Length over the cap is refused and its bytes discarded as they arrive, so
95
- // the pipe stays in frame; a buffer past the cap with NO complete frame has no resync point,
96
- // so the connection is dropped (the owner's reconnect machinery takes over). Without the cap
97
- // a single giant unframed line from a broken upstream grew this buffer until the whole gateway
98
- // process died.
99
- const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
100
-
101
- class McpClient {
102
- /**
103
- * @param {object} opts
104
- * @param {string} [opts.id] caller-chosen id for this upstream (diagnostics only)
105
- * @param {string} opts.command executable to spawn (e.g. process.execPath)
106
- * @param {string[]} [opts.args] argv for the child (e.g. [serverPath])
107
- * @param {object} [opts.env] extra env merged over process.env for the child
108
- * @param {string} [opts.cwd] child working directory
109
- * @param {Function} [opts.spawnImpl] injectable spawn (default node:child_process.spawn)
110
- * @param {number} [opts.requestTimeoutMs] control-plane timeout in ms (default 10000) -
111
- * handshake/list/discover/ping; the dead-upstream detector
112
- * @param {number} [opts.toolTimeoutMs] PAYLOAD timeout in ms (default 120000) for
113
- * tools/call | prompts/get | resources/read; a
114
- * token-matched progress beat re-arms it
115
- * @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
116
- * while not deliberately close()d). Lets the owner
117
- * (the aggregator) schedule a background reconnect.
118
- * @param {'legacy'|'modern'} [opts.eraHint] the owner's memo of the era negotiated LAST connect.
119
- * 'legacy' skips the server/discover probe (a reconnect
120
- * goes straight to initialize); 'modern' is a no-op (the
121
- * probe IS the modern handshake). An observation, not a
122
- * policy: the owner clears its memo when a hinted
123
- * connect fails. modernOnly always wins over the hint.
124
- */
125
- constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs, toolTimeoutMs, onClose, forceLegacy = false, modernOnly = false, eraHint, onNotification, clientInfo } = {}) {
126
- if (typeof command !== 'string' || command.length === 0) {
127
- throw new Error('McpClient: "command" (non-empty string) is required');
128
- }
129
- this.id = typeof id === 'string' && id.length ? id : 'mcp-client';
130
- // clientInfo: the identity this client PRESENTS to the upstream (legacy initialize and the
131
- // modern _meta trio). Defaults to the built-in - absent the opt, wire behaviour is byte-identical
132
- // to before. The wrap's identity mirroring hands the REAL downstream client's info through here.
133
- // The FULL object is carried (title etc.), not a name/version whitelist - the wrap's serverInfo
134
- // direction proved a whitelist silently eats spec fields.
135
- this._clientInfo =
136
- clientInfo && typeof clientInfo === 'object' &&
137
- typeof clientInfo.name === 'string' && clientInfo.name.length
138
- ? Object.freeze(Object.assign({}, clientInfo, {
139
- version: typeof clientInfo.version === 'string' ? clientInfo.version : '0.0.0',
140
- }))
141
- : CLIENT_INFO;
142
- this._command = command;
143
- this._args = Array.isArray(args) ? args.slice() : [];
144
- this._env = env && typeof env === 'object' ? env : {};
145
- this._cwd = cwd;
146
- this._spawn = typeof spawnImpl === 'function' ? spawnImpl : childProcess.spawn;
147
- this._requestTimeoutMs =
148
- Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 10000;
149
- // An EXPLICITLY configured control window also governs the era probe in connect(): the
150
- // operator opted into slower connects for a slow-boot upstream, and the probe must not
151
- // quietly undo that (a slow-boot modern-only server could otherwise never attach).
152
- this._explicitRequestTimeout = Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0;
153
- // The PAYLOAD window (tools/call | prompts/get | resources/read): a tool legitimately runs
154
- // as long as the tool runs - the fixed 10 s window hard-killed every wrapped tool slower
155
- // than that and silently dropped the upstream's eventual result. Per-upstream
156
- // `timeoutMs` config lands here; a token-matched progress beat re-arms the window.
157
- this._toolTimeoutMs =
158
- Number.isFinite(toolTimeoutMs) && toolTimeoutMs > 0 ? toolTimeoutMs : 120000;
159
- this._onClose = typeof onClose === 'function' ? onClose : null;
160
- // forceLegacy (legacyPin): skip the modern server/discover probe and speak legacy unconditionally.
161
- this._forceLegacy = forceLegacy === true;
162
- // modernOnly: the MIRROR of legacyPin - refuse the legacy fallback entirely. A modernOnly
163
- // upstream that does not answer server/discover with the modern protocol FAILS the connect
164
- // with a clear error instead of silently negotiating down (era-policy switches, 2026-07-18).
165
- this._modernOnly = modernOnly === true;
166
- // eraHint ('legacy' is the only meaningful value): the OWNER's memo of the era this upstream
167
- // negotiated LAST time - an observation, not a policy. It skips the server/discover probe the
168
- // way forceLegacy does, but the caller is expected to clear its memo when a hinted connect
169
- // FAILS (the upstream may have changed underneath) - which forceLegacy, being policy, never
170
- // does. 'modern' is accepted-and-ignored: for a modern upstream the probe IS the handshake,
171
- // so there is nothing to skip. modernOnly always wins over the hint - a policy that demands
172
- // the probe cannot be undone by a stale observation.
173
- this._eraHint = eraHint === 'legacy' ? 'legacy' : null;
174
- // Era-observation confidence (set per connect()): true only when the server/discover probe was
175
- // ANSWERED - a valid modern DiscoverResult, a non-modern reply, or a JSON-RPC rejection like
176
- // -32601. A probe that TIMED OUT (or a skipped probe) observed nothing about the upstream's
177
- // eras, and the owner must not memoise the legacy fallback it forced.
178
- this._eraDefinitive = false;
179
- // onNotification: called with each server-initiated NOTIFICATION (a message with a method and
180
- // NO id) - e.g. notifications/resources/updated, .../resources/list_changed, .../prompts/
181
- // list_changed, .../tools/list_changed. Lets the aggregator bridge upstream change-notifications
182
- // to a modern client's subscriptions/listen stream. Settable post-construction too.
183
- this.onNotification = typeof onNotification === 'function' ? onNotification : null;
184
- // onServerRequest: called with each SERVER-INITIATED REQUEST (a message with a method AND an
185
- // id - elicitation/create, sampling/createMessage, roots/list). The owner answers it via
186
- // respondToServer/respondToServerError. Unset -> every such request is answered -32601, so a
187
- // conformant upstream is never left holding an unanswered request (elicitation-bridge infrastructure).
188
- this.onServerRequest = null;
189
- this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
190
- this._everConnected = false; // true once the handshake completed - onClose fires ONLY for the
191
- // death of an ESTABLISHED connection, never a connect-time failure
192
-
193
- // Transport state.
194
- this._child = null;
195
- this._connected = false;
196
- this._closed = false;
197
- this._buf = Buffer.alloc(0);
198
- this._skipRemaining = 0; // bytes of a refused over-cap message still to discard
199
- this._stderr = '';
200
- this._nextId = 1;
201
- /** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
202
- this._waiters = new Map();
203
-
204
- // The initialize result, captured for connect()'s return value.
205
- this._initializeResult = null;
206
- }
207
-
208
- /** @returns {boolean} true once the initialize handshake has completed and not yet closed. */
209
- get connected() {
210
- return this._connected === true && this._closed === false;
211
- }
212
-
213
- /** @returns {object|null} the upstream's raw `initialize` result ({ protocolVersion, serverInfo,
214
- * capabilities, instructions }) captured at connect. Used by the passthrough WRAP to present the
215
- * wrapped server's OWN identity (a wrapped MCP must be indistinguishable from the real thing). */
216
- get initializeResult() {
217
- return this._initializeResult;
218
- }
219
-
220
- /**
221
- * Raw JSON-RPC passthrough for the WRAP: forward a method to the upstream verbatim and return its
222
- * result. Used to relay non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
223
- * server behaves exactly as it would connected directly. Tool calls do NOT use this - they go
224
- * through the gated forward path. Rejects on timeout / transport failure (same as callTool).
225
- * @param {string} method
226
- * @param {any} [params]
227
- * @returns {Promise<any>} the upstream's raw result
228
- */
229
- async request(method, params, out) {
230
- if (typeof method !== 'string' || method.length === 0) {
231
- throw new Error('McpClient.request: "method" (non-empty string) is required');
232
- }
233
- let p = params == null ? {} : params;
234
- p = this._translateLogLevel(p); // era-keyed logLevel (1.4): modern keeps it, legacy gets setLevel
235
- // Modern upstream: ensure the per-request `_meta` protocol trio is present. MERGE with any
236
- // caller-supplied _meta (progressToken, trace keys survive a wrap forward) - the trio wins on
237
- // collision; a partial caller _meta must never reach a strict modern upstream trio-less.
238
- if (this._modern && p && typeof p === 'object' && !Array.isArray(p)) {
239
- const callerMeta = (p._meta && typeof p._meta === 'object' && !Array.isArray(p._meta)) ? p._meta : null;
240
- p = Object.assign({}, p, { _meta: Object.assign({}, callerMeta, this._modernMeta()) });
241
- }
242
- return await this._request(method, p, out);
243
- }
244
-
245
- /**
246
- * Send a NOTIFICATION to the upstream - no id, no waiter, no reply. Used by a wrap to relay
247
- * client notifications (notifications/cancelled, notifications/progress ...) that a
248
- * directly-connected server would receive. Fire-and-forget; NEVER throws.
249
- * @param {string} method
250
- * @param {any} params
251
- */
252
- notify(method, params) {
253
- try {
254
- if (this._closed || !this._child || !this._child.stdin) return;
255
- if (typeof method !== 'string' || method.length === 0) return;
256
- const payload = { jsonrpc: JSONRPC, method };
257
- if (params !== undefined) payload.params = params;
258
- this._child.stdin.write(frameRequest(payload));
259
- } catch (_e) { /* fire-and-forget */ }
260
- }
261
-
262
- /**
263
- * Answer a SERVER-INITIATED request (the elicitation bridge): write a JSON-RPC RESPONSE carrying the
264
- * upstream's own request id. Fire-and-forget; NEVER throws.
265
- * @param {number|string} id the id the UPSTREAM used on its request
266
- * @param {any} result the result payload (e.g. an ElicitResult { action, content })
267
- */
268
- respondToServer(id, result) {
269
- try {
270
- if (this._closed || !this._child || !this._child.stdin) return;
271
- if (id === undefined || id === null) return;
272
- this._child.stdin.write(frameRequest({ jsonrpc: JSONRPC, id, result: result === undefined ? {} : result }));
273
- } catch (_e) { /* fire-and-forget */ }
274
- }
275
-
276
- /**
277
- * Extend the timeout of an in-flight request (the elicitation bridge): a mid-call elicitation proves the
278
- * upstream is alive and deliberately holding the call open while a HUMAN answers - the normal
279
- * dead-upstream timeout must not kill the suspension. Re-arms the waiter's timer; returns
280
- * false if the request already settled. NEVER throws.
281
- * @param {number|string} id the rpc id of the in-flight request
282
- * @param {number} ms the new timeout window from now
283
- */
284
- extendRequestTimeout(id, ms) {
285
- try {
286
- const w = this._waiters.get(id);
287
- if (!w) return false;
288
- clearTimeout(w.timer);
289
- // The extension becomes the waiter's WINDOW, not just its current timer: a token-matched
290
- // progress beat re-arms to w.win, so leaving the old (short) win in place let a single
291
- // beat during a Bridge-B suspension collapse the human-answer hold back to toolTimeoutMs
292
- //Progress during a hold now re-arms to the hold.
293
- const win = Number.isFinite(ms) && ms > 0 ? ms : this._requestTimeoutMs;
294
- w.win = win;
295
- w.timer = setTimeout(() => {
296
- this._waiters.delete(id);
297
- w.reject(new Error(`McpClient: request("${w.method}") timed out (suspended-call window elapsed)`));
298
- }, win);
299
- if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
300
- return true;
301
- } catch (_e) {
302
- return false;
303
- }
304
- }
305
-
306
- /**
307
- * Re-arm the timeout of the in-flight request matching this progressToken:
308
- * a progress beat is proof of life for THAT request. Token-matched only - a dead upstream
309
- * still times out on its full window. NEVER throws.
310
- * @param {number|string} token the progressToken from a notifications/progress params
311
- */
312
- _bumpProgressWaiter(token) {
313
- try {
314
- for (const [id, w] of this._waiters) {
315
- if (w.progressToken === undefined || w.progressToken !== token) continue;
316
- clearTimeout(w.timer);
317
- const win = Number.isFinite(w.win) && w.win > 0 ? w.win : this._toolTimeoutMs;
318
- w.timer = setTimeout(() => {
319
- this._waiters.delete(id);
320
- w.reject(new Error(
321
- `McpClient: timeout after ${win}ms without progress waiting for "${w.method}". stderr so far:\n${this._stderr}`
322
- ));
323
- }, win);
324
- if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
325
- return;
326
- }
327
- } catch (_e) { /* re-arm is best-effort; the original timer stands */ }
328
- }
329
-
330
- /** Answer a server-initiated request with a JSON-RPC ERROR. Fire-and-forget; NEVER throws. */
331
- respondToServerError(id, code, message) {
332
- try {
333
- if (this._closed || !this._child || !this._child.stdin) return;
334
- if (id === undefined || id === null) return;
335
- this._child.stdin.write(frameRequest({
336
- jsonrpc: JSONRPC, id,
337
- error: { code: typeof code === 'number' ? code : -32603, message: String(message || 'error') },
338
- }));
339
- } catch (_e) { /* fire-and-forget */ }
340
- }
341
-
342
- // ── Diagnostics -> never throw. ───────────────────────────────────────────────────────────────
343
- _logErr(...parts) {
344
- try {
345
- process.stderr.write(`[mcp-client:${this.id}] ${parts.join(' ')}\n`);
346
- } catch (_e) {
347
- /* never let logging throw */
348
- }
349
- }
350
-
351
- // ─────────────────────────────────────────────────────────────────────────────────────────────
352
- // connect(): spawn the child, perform the MCP initialize handshake, send the
353
- // notifications/initialized notification, set connected. On ANY failure (spawn
354
- // error, initialize timeout/error) close() the child and reject - never leave a zombie.
355
- // ─────────────────────────────────────────────────────────────────────────────────────────────
356
- async connect() {
357
- if (this._closed) throw new Error('McpClient: connect() called after close()');
358
- if (this._connected) return this._initializeResult;
359
-
360
- // 1) Spawn the child (shared with probeDiscover - a probe may already have spawned it).
361
- if (!this._child) this._spawnChild();
362
-
363
- // 2) DUAL-ERA negotiation (the spec's dual-era CLIENT rule: try MODERN first). Probe
364
- // `server/discover`: a modern or dual-era upstream answers with a DiscoverResult that lists
365
- // the modern version; a legacy-only upstream rejects it (method-not-found) or drops. On a
366
- // valid modern answer we speak MODERN (no initialize; per-request `_meta` on every call).
367
- // Otherwise (or when legacyPin FORCES legacy) we fall back to the LEGACY initialize handshake
368
- // (byte-for-byte the prior behaviour).
369
- let discovered = null;
370
- // The probe is skipped by POLICY (legacyPin) or by the owner's era MEMO (eraHint 'legacy' -
371
- // this upstream negotiated legacy last time, so a reconnect goes straight to initialize
372
- // instead of re-paying the probe window). modernOnly overrides the hint: that policy's whole
373
- // point is the probe, and a stale memo must never silently downgrade it.
374
- const skipProbe = this._forceLegacy || (this._eraHint === 'legacy' && !this._modernOnly);
375
- this._eraDefinitive = false; // no observation yet this connect (stays false when the probe is skipped)
376
- if (!skipProbe) {
377
- let probe = null;
378
- // The probe gets a SHORT timeout of its own: a legacy server that silently DROPS unknown
379
- // methods (instead of answering -32601) would otherwise stall every connect by the full
380
- // request timeout - serialised across upstreams at startup. An EXPLICIT per-upstream
381
- // requestTimeoutMs overrides the clamp - a slow-boot modern-only server needs the raised
382
- // window HERE, and the bin-side era probe already honours it (keep the two symmetric).
383
- const fullTimeout = this._requestTimeoutMs;
384
- this._requestTimeoutMs = this._explicitRequestTimeout
385
- ? fullTimeout : Math.min(3000, fullTimeout || 3000);
386
- try {
387
- probe = await this._request('server/discover', { _meta: this._modernMeta() });
388
- this._eraDefinitive = true; // the probe was answered - whatever we conclude is observed
389
- } catch (probeErr) {
390
- probe = null; // legacy upstream (method-not-found) - fall through to initialize
391
- // An ANSWERED rejection (rpcError present, e.g. -32601) is a definitive legacy
392
- // observation; a timeout/transport drop is SILENCE and proves nothing.
393
- this._eraDefinitive = !!(probeErr && probeErr.rpcError);
394
- if (this._closed) throw new Error('McpClient: connect() aborted (closed during discover probe)');
395
- if (!this._child) this._spawnChild(); // a fragile legacy server may have died on the probe
396
- } finally {
397
- this._requestTimeoutMs = fullTimeout;
398
- }
399
- // Only treat the reply as MODERN if it is a real DiscoverResult advertising the modern
400
- // version - a permissive legacy server that answers unknown methods with `{}`/an empty object
401
- // must NOT be misdetected as modern.
402
- if (probe && typeof probe === 'object' && Array.isArray(probe.supportedVersions) &&
403
- probe.supportedVersions.includes(MODERN_PROTOCOL_VERSION)) {
404
- discovered = probe;
405
- } else if (probe && this._child) {
406
- // Answered, but not a modern DiscoverResult -> legacy. The child is alive (it replied), so the
407
- // legacy initialize below runs on the same child.
408
- discovered = null;
409
- }
410
- }
411
-
412
- // modernOnly refuses the legacy fallback: no valid modern DiscoverResult means no connection,
413
- // with an error that names the policy - never a silent downgrade.
414
- if (!discovered && this._modernOnly) {
415
- this.close();
416
- throw new Error(`McpClient: upstream "${this.id}" is modernOnly but did not answer server/discover with the 2026-07-28 protocol - refusing the legacy fallback`);
417
- }
418
-
419
- if (discovered) {
420
- // MODERN upstream - identity from discover; NO initialize / notifications/initialized.
421
- this._modern = true;
422
- this._initializeResult = this._identityFromDiscover(discovered);
423
- } else {
424
- // LEGACY upstream - the initialize handshake. A spawn 'error' rejects via the waiter path; a
425
- // silent server hits the request timeout. Either way close() + a clear error.
426
- try {
427
- const result = await this._request('initialize', {
428
- protocolVersion: PROTOCOL_VERSION,
429
- clientInfo: this._clientInfo,
430
- // elicitation: real SDK-based servers GATE ctx.elicit() on the client declaring this
431
- // capability - without it they answer "Method not found" and never elicit at all
432
- // (found testing against real published elicitation servers, 2026-07-17). We honestly handle every elicitation now: the
433
- // elicitation bridge's MRTR path for modern callers, a clean decline otherwise.
434
- // BOTH modes declared (0.7.0): the 2025-11-25 back-compat rule reads a bare
435
- // `{}` as FORM ONLY, and "Servers MUST NOT send elicitation requests with modes that
436
- // are not supported by the client" - so without `url` here a conformant upstream is
437
- // FORBIDDEN from url mode and the relay is structurally dead. Every elicit of either
438
- // mode gets an answer: relayed to a caller that declared it, declined otherwise.
439
- capabilities: { elicitation: { form: {}, url: {} } },
440
- });
441
- this._initializeResult = result;
442
- } catch (err) {
443
- this.close();
444
- throw new Error(`McpClient: initialize failed: ${(err && err.message) || String(err)}`);
445
- }
446
- // Tell the server the handshake is complete (notification - no id, no reply).
447
- this._notify('notifications/initialized', {});
448
- this._modern = false;
449
- }
450
-
451
- this._connected = true;
452
- this._everConnected = true; // from here on, an exit/error is an ESTABLISHED-connection death
453
- return this._initializeResult;
454
- }
455
-
456
- /** @returns {'modern'|'legacy'} the era negotiated at connect (default 'legacy' before connect). */
457
- get era() {
458
- return this._modern === true ? 'modern' : 'legacy';
459
- }
460
-
461
- /** @returns {boolean} true when the LAST connect's era came from an ANSWERED probe; false when
462
- * the probe was skipped (policy/hint) or went unanswered (timeout/drop). The owner memoises
463
- * the era only on a definitive observation - silence must never pin an era. */
464
- get eraDefinitive() {
465
- return this._eraDefinitive === true;
466
- }
467
-
468
- /** The per-request `_meta` a modern client MUST send on every request (stdio: no headers). */
469
- _modernMeta() {
470
- return {
471
- 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
472
- 'io.modelcontextprotocol/clientCapabilities': {},
473
- 'io.modelcontextprotocol/clientInfo': this._clientInfo,
474
- };
475
- }
476
-
477
- /** @returns {{name:string, version:string}} the identity this client presents upstream. */
478
- get clientInfo() {
479
- return this._clientInfo;
480
- }
481
-
482
- /**
483
- * Era-keyed logLevel handling (0.7.0). A caller's per-request
484
- * `io.modelcontextprotocol/logLevel` must reach the upstream in the upstream's own vocabulary:
485
- * MODERN upstream -> left in `_meta` (the trio merge in request()/callTool() preserves it - the
486
- * trio does not define logLevel), because the modern severity clause obliges the upstream to
487
- * WITHHOLD notifications/message when the field is absent. LEGACY upstream -> the key is
488
- * REMOVED (no legacy schema defines it; leaking a modern reserved key is also a wrap tell)
489
- * and, when the upstream declared the `logging` capability, translated to a `logging/setLevel`
490
- * request ISSUED BEFORE the main request (stdio writes are ordered - the server sees the level
491
- * first), deduped per distinct level. Best-effort: a setLevel failure never fails the main
492
- * call. Deprecation-sized (SEP-2577): one key, one translation, no wider _meta policy.
493
- * @param {any} params the outgoing request params
494
- * @returns {any} params, possibly cloned with the logLevel key removed
495
- */
496
- _translateLogLevel(params) {
497
- const meta = params && typeof params === 'object' && !Array.isArray(params) ? params._meta : null;
498
- if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return params;
499
- const level = meta['io.modelcontextprotocol/logLevel'];
500
- if (level === undefined) return params;
501
- if (this._modern) return params; // modern upstream: rides the forwarded _meta verbatim
502
- const rest = { ...meta };
503
- delete rest['io.modelcontextprotocol/logLevel'];
504
- const out = { ...params };
505
- if (Object.keys(rest).length) out._meta = rest; else delete out._meta;
506
- const caps = this._initializeResult && this._initializeResult.capabilities;
507
- if (typeof level === 'string' && caps && caps.logging && level !== this._lastSetLogLevel) {
508
- this._lastSetLogLevel = level;
509
- this._request('logging/setLevel', { level }).catch(() => {}); // fire-and-forget, ordered by the pipe
510
- }
511
- return out;
512
- }
513
-
514
- /**
515
- * Normalise a `server/discover` result into the common identity shape wrappedIdentity + connect()
516
- * consumers expect ({ protocolVersion, serverInfo, capabilities, instructions }) - so a MODERN
517
- * upstream's identity reads the same as a legacy `initialize` result. serverInfo lives in the
518
- * discover result's `_meta['io.modelcontextprotocol/serverInfo']`.
519
- */
520
- _identityFromDiscover(disc) {
521
- const d = disc && typeof disc === 'object' ? disc : {};
522
- const meta = d._meta && typeof d._meta === 'object' ? d._meta : {};
523
- const si = meta['io.modelcontextprotocol/serverInfo'];
524
- const versions = Array.isArray(d.supportedVersions) ? d.supportedVersions : [];
525
- return {
526
- // We negotiated MODERN (this path is only reached for a modern DiscoverResult), so report the
527
- // modern version - not `versions[0]`, whose ordering the spec does not guarantee (a dual-era
528
- // upstream listing legacy first would otherwise yield a contradictory identity).
529
- protocolVersion: versions.includes(MODERN_PROTOCOL_VERSION) ? MODERN_PROTOCOL_VERSION : (versions[0] || MODERN_PROTOCOL_VERSION),
530
- serverInfo: si && typeof si === 'object' ? si : undefined,
531
- capabilities: d.capabilities && typeof d.capabilities === 'object' ? d.capabilities : undefined,
532
- instructions: typeof d.instructions === 'string' ? d.instructions : undefined,
533
- supportedVersions: versions,
534
- };
535
- }
536
-
537
- /**
538
- * Spawn the child + wire its pipes (no handshake). A synchronous spawn throw (bad command) is
539
- * caught and rethrown clean. Extracted from connect() so probeDiscover() can share it.
540
- */
541
- _spawnChild() {
542
- try {
543
- // Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
544
- const launch = winLaunch(this._command, this._args);
545
- this._child = this._spawn(launch.command, launch.args, {
546
- stdio: ['pipe', 'pipe', 'pipe'],
547
- cwd: this._cwd,
548
- env: Object.assign({}, process.env, this._env),
549
- windowsHide: true,
550
- });
551
- } catch (err) {
552
- this._child = null;
553
- throw new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
554
- }
555
-
556
- if (!this._child || !this._child.stdin || !this._child.stdout) {
557
- // A fake/odd spawnImpl that didn't give us pipes - bail cleanly.
558
- this.close();
559
- throw new Error('McpClient: spawned child is missing stdio pipes');
560
- }
561
-
562
- this._wireChild();
563
- }
564
-
565
- /**
566
- * Era probe - the 2026-07-28 spec's dual-era client rule for stdio: try `server/discover`
567
- * BEFORE any legacy `initialize`. A modern or dual-era server answers with its
568
- * supportedVersions; a legacy server rejects it (-32601 Method not found) or times out.
569
- * Resolves the DiscoverResult, rejects otherwise. Sends the request with modern per-request
570
- * `_meta` (required in the modern era). Does NOT set connected - this is a probe, not a
571
- * handshake; call connect() for the legacy handshake (fresh client recommended: a badly
572
- * behaved legacy server may react badly to an unknown method).
573
- * @returns {Promise<object>} the server/discover result
574
- */
575
- async probeDiscover() {
576
- if (this._closed) throw new Error('McpClient: probeDiscover() called after close()');
577
- if (!this._child) this._spawnChild();
578
- try {
579
- return await this._request('server/discover', {
580
- _meta: {
581
- 'io.modelcontextprotocol/protocolVersion': '2026-07-28',
582
- 'io.modelcontextprotocol/clientCapabilities': {},
583
- 'io.modelcontextprotocol/clientInfo': this._clientInfo,
584
- },
585
- });
586
- } catch (err) {
587
- // Mirror connect()'s close-on-failure contract: a probe that times out or errors must not
588
- // leave a zombie child (the caller may not have a finally). close() is idempotent.
589
- this.close();
590
- throw err;
591
- }
592
- }
593
-
594
- /**
595
- * tools/list -> the upstream's COMPLETE tool definitions, following `nextCursor` pagination
596
- * (0.7.0 - the single-shot version silently truncated a paginating upstream to page 1,
597
- * leaving its later tools undiscoverable AND unrunnable). Returns the raw concatenated array
598
- * [{name, description, inputSchema, ...}] from every page (empty array if the server returned
599
- * no tools). Rejects on timeout / transport failure of ANY page.
600
- *
601
- * Defensive bounds (a misbehaving upstream must not hang discovery):
602
- * - a cursor already seen ends the loop (a repeating cursor would spin forever);
603
- * - MAX_LIST_PAGES caps a pathological endless-unique-cursor upstream. Both end the loop
604
- * with the pages already fetched - partial truth beats a wedged aggregator.
605
- * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
606
- */
607
- async listTools() {
608
- const MAX_LIST_PAGES = 64;
609
- const tools = [];
610
- const seen = new Set();
611
- let cursor;
612
- for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
613
- // Modern upstream: every request carries per-request `_meta` (stdio needs no headers).
614
- const params = this._modern ? { _meta: this._modernMeta() } : {};
615
- if (cursor !== undefined) params.cursor = cursor;
616
- const result = await this._request('tools/list', params);
617
- if (result && Array.isArray(result.tools)) tools.push(...result.tools);
618
- const next = result && typeof result.nextCursor === 'string' && result.nextCursor.length
619
- ? result.nextCursor : undefined;
620
- if (next === undefined || seen.has(next)) return tools;
621
- seen.add(next);
622
- cursor = next;
623
- }
624
- return tools;
625
- }
626
-
627
- /**
628
- * tools/call -> invoke one upstream tool. Returns the MCP tools/call result
629
- * envelope { content, isError }. Rejects on timeout / transport failure (a tool
630
- * that ran but returned an error sets isError:true in the resolved value - that
631
- * is NOT a rejection).
632
- * @param {string} name the upstream tool name
633
- * @param {any} args structured arguments (forwarded as the `arguments` field)
634
- * @param {object} [meta] the caller's request-scoped _meta (progressToken et al., protocol keys
635
- * already stripped by the server). Rides the forward: without it the upstream
636
- * never emits progress for the call, so a long funnel tool cannot keep its
637
- * own call alive.
638
- * @returns {Promise<{content:any[], isError:boolean}>}
639
- */
640
- async callTool(name, args, meta) {
641
- if (typeof name !== 'string' || name.length === 0) {
642
- throw new Error('McpClient.callTool: "name" (non-empty string) is required');
643
- }
644
- let params = { name, arguments: args == null ? {} : args };
645
- if (meta && typeof meta === 'object' && !Array.isArray(meta) && Object.keys(meta).length) {
646
- params._meta = { ...meta };
647
- }
648
- params = this._translateLogLevel(params); // era-keyed logLevel (1.4)
649
- // modern upstream: the per-request protocol trio always wins over caller keys
650
- if (this._modern) params._meta = { ...(params._meta || {}), ...this._modernMeta() };
651
- const result = await this._request('tools/call', params);
652
- const r = result && typeof result === 'object' ? result : {};
653
- const out = {
654
- content: Array.isArray(r.content) ? r.content : [],
655
- isError: r.isError === true,
656
- };
657
- // structuredContent rides along verbatim - dropping it silently stripped modern upstreams'
658
- // structured results on the curated-direct path while the raw wrap path kept them
659
- if (r.structuredContent !== undefined) out.structuredContent = r.structuredContent;
660
- // result _meta rides along too (3.10, result side): the wrap relays it verbatim (the
661
- // upstream's identity IS the presented identity there); the funnel shaping sites strip the
662
- // protocol-identity keys before forwarding (they own their own identity).
663
- if (r._meta !== undefined) out._meta = r._meta;
664
- return out;
665
- }
666
-
667
- // ─────────────────────────────────────────────────────────────────────────────────────────────
668
- // close(): idempotent teardown. End child stdin so a well-behaved server exits on
669
- // EOF; if it is still alive, tree-kill it (win32 taskkill /T /F to reap the whole
670
- // process tree; else child.kill()). Reject every in-flight request so nothing leaks.
671
- // NEVER throws - it is called from error paths.
672
- // ─────────────────────────────────────────────────────────────────────────────────────────────
673
- close() {
674
- if (this._closed) return;
675
- this._closed = true;
676
- this._connected = false;
677
-
678
- const child = this._child;
679
-
680
- // Reject any in-flight requests first (so they don't fire their timers after teardown).
681
- this._rejectAllWaiters(new Error('McpClient: connection closed'));
682
-
683
- if (!child) return;
684
-
685
- // Best-effort: end stdin to let the server exit on EOF.
686
- try {
687
- if (child.stdin && !child.stdin.destroyed) child.stdin.end();
688
- } catch (_e) {
689
- /* ignore */
690
- }
691
-
692
- // If still alive, tree-kill so no descendant is orphaned (zombie guard).
693
- this._treeKill(child);
694
-
695
- this._child = null;
696
- }
697
-
698
- /**
699
- * Tree-kill a child so no descendant is orphaned (win32 taskkill /T /F reaps the whole
700
- * process tree; else child.kill()). NEVER throws - called from close() and from the
701
- * read-buffer overflow path, both of which must not raise out of an event handler.
702
- * @param {import('node:child_process').ChildProcess|null} child
703
- */
704
- _treeKill(child) {
705
- try {
706
- if (!child) return;
707
- const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
708
- if (alive && typeof child.pid === 'number') {
709
- if (process.platform === 'win32') {
710
- // /T = whole tree, /F = force. spawnSync so it completes synchronously here.
711
- try {
712
- childProcess.spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
713
- windowsHide: true,
714
- });
715
- } catch (_e) {
716
- // Fall back to a plain kill if taskkill is unavailable.
717
- try { child.kill(); } catch (_e2) { /* ignore */ }
718
- }
719
- } else {
720
- child.kill();
721
- }
722
- } else if (typeof child.kill === 'function') {
723
- // Already exited or a fake child - a no-op kill is harmless.
724
- try { child.kill(); } catch (_e) { /* ignore */ }
725
- }
726
- } catch (_e) {
727
- /* never throws - see contract above */
728
- }
729
- }
730
-
731
- // ─────────────────────────────────────────────────────────────────────────────────────────────
732
- // Internals.
733
- // ─────────────────────────────────────────────────────────────────────────────────────────────
734
-
735
- /** Attach the stdout reader, stderr capture, and exit/error handlers to the child. */
736
- _wireChild() {
737
- const child = this._child;
738
-
739
- child.stdout.on('data', (chunk) => this._onData(chunk));
740
- child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
741
-
742
- if (child.stderr) {
743
- child.stderr.on('data', (d) => {
744
- this._stderr += d.toString('utf8');
745
- // Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
746
- if (this._stderr.length > 64 * 1024) {
747
- this._stderr = this._stderr.slice(-32 * 1024);
748
- }
749
- });
750
- child.stderr.on('error', () => { /* ignore */ });
751
- }
752
-
753
- // Either of these means the child is gone - fail every in-flight request so nothing hangs, and
754
- // NULL this._child so a later connect() on the same instance respawns instead of writing to a
755
- // dead stdin (connect() reuses `this._child` when set - a probeDiscover() against a legacy
756
- // server that dies on the unknown method would otherwise corrupt a follow-up connect()). Only
757
- // null it if it is still THIS child (a fresh respawn may have replaced it).
758
- child.on('exit', (code, signal) => {
759
- this._connected = false;
760
- if (this._child === child) this._child = null;
761
- this._rejectAllWaiters(
762
- new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
763
- );
764
- this._fireOnClose(`exited (code=${code}, signal=${signal})`);
765
- });
766
- child.on('error', (err) => {
767
- // Spawn-level failure (e.g. ENOENT) - surfaces here, not as a throw from spawn().
768
- this._connected = false;
769
- if (this._child === child) this._child = null;
770
- this._rejectAllWaiters(
771
- new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`)
772
- );
773
- this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
774
- });
775
- }
776
-
777
- /**
778
- * Fire the onClose callback ONCE, and ONLY for an UNEXPECTED death - never when close() was
779
- * called deliberately (close() sets _closed=true before tree-killing the child, so the resulting
780
- * 'exit' is suppressed here). This is the signal the aggregator uses to schedule a background
781
- * reconnect. Wrapped - a throwing callback must not escape an event handler.
782
- * @param {string} reason
783
- */
784
- _fireOnClose(reason) {
785
- // ONLY an established connection dying counts. A connect-time failure (child exits during the
786
- // handshake, ENOENT, etc.) must NOT fire onClose: it surfaces as connect()'s thrown error, and
787
- // the aggregator's reconnect-retry path owns the backoff. Firing here would let a death inside a
788
- // reconnect attempt reset the backoff counter to 0 -> an unbounded ~1s respawn storm.
789
- if (!this._everConnected) return;
790
- if (this._closed) return; // deliberate close() - not an unexpected death
791
- if (this._onCloseFired) return; // exit AND error can both fire; notify once
792
- this._onCloseFired = true;
793
- if (typeof this._onClose === 'function') {
794
- try { this._onClose(reason); } catch (_e) { this._logErr('onClose callback threw (ignored)'); }
795
- }
796
- }
797
-
798
- /** Reject + clear every pending waiter (used on close / exit / error). */
799
- _rejectAllWaiters(err) {
800
- if (this._waiters.size === 0) return;
801
- for (const [, w] of this._waiters) {
802
- try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
803
- try { w.reject(err); } catch (_e) { /* ignore */ }
804
- }
805
- this._waiters.clear();
806
- }
807
-
808
- /**
809
- * Send a JSON-RPC request with an id and await its matching response. Rejects on
810
- * timeout (requestTimeoutMs) after deleting the waiter so it can't leak. Resolves
811
- * with the response's `result` (or rejects with its `error`).
812
- */
813
- _request(method, params, out) {
814
- if (this._closed) {
815
- return Promise.reject(new Error(`McpClient: request("${method}") after close()`));
816
- }
817
- if (!this._child || !this._child.stdin) {
818
- return Promise.reject(new Error(`McpClient: request("${method}") with no live child`));
819
- }
820
-
821
- const id = this._nextId++;
822
- // Report the issued rpc id synchronously (before any I/O) - a wrap needs the client-id ->
823
- // upstream-id mapping LIVE to translate notifications/cancelled for in-flight forwards.
824
- if (out && typeof out === 'object') out.rpcId = id;
825
- const payload = { jsonrpc: JSONRPC, id, method };
826
- if (params !== undefined) payload.params = params;
827
-
828
- // Method-class window: payload methods wait toolTimeoutMs, control-plane keeps the short
829
- // dead-upstream window. The caller's progressToken (rides params._meta on
830
- // a wrap forward) is remembered so a matching progress beat can re-arm the window.
831
- const win = PAYLOAD_METHODS.has(method) ? this._toolTimeoutMs : this._requestTimeoutMs;
832
- const ptok = params && params._meta && typeof params._meta === 'object' &&
833
- params._meta.progressToken !== undefined ? params._meta.progressToken : undefined;
834
-
835
- return new Promise((resolve, reject) => {
836
- const timer = setTimeout(() => {
837
- this._waiters.delete(id);
838
- reject(new Error(
839
- `McpClient: timeout after ${win}ms waiting for "${method}". stderr so far:\n${this._stderr}`
840
- ));
841
- }, win);
842
- // Don't let a pending timer keep the event loop (and the test process) alive.
843
- if (timer && typeof timer.unref === 'function') timer.unref();
844
-
845
- this._waiters.set(id, { resolve, reject, timer, method, win, progressToken: ptok });
846
-
847
- let framed;
848
- try {
849
- framed = frameRequest(payload);
850
- } catch (err) {
851
- this._waiters.delete(id);
852
- clearTimeout(timer);
853
- reject(new Error(`McpClient: failed to serialise "${method}": ${(err && err.message) || String(err)}`));
854
- return;
855
- }
856
-
857
- try {
858
- this._child.stdin.write(framed, (err) => {
859
- if (err) {
860
- this._waiters.delete(id);
861
- clearTimeout(timer);
862
- reject(new Error(`McpClient: stdin write failed for "${method}": ${(err && err.message) || String(err)}`));
863
- }
864
- });
865
- } catch (err) {
866
- this._waiters.delete(id);
867
- clearTimeout(timer);
868
- reject(new Error(`McpClient: stdin write threw for "${method}": ${(err && err.message) || String(err)}`));
869
- }
870
- });
871
- }
872
-
873
- /** Fire-and-forget JSON-RPC notification (no id, no reply expected). Never throws. */
874
- _notify(method, params) {
875
- if (this._closed || !this._child || !this._child.stdin) return;
876
- const payload = { jsonrpc: JSONRPC, method };
877
- if (params !== undefined) payload.params = params;
878
- let framed;
879
- try {
880
- framed = frameRequest(payload);
881
- } catch (err) {
882
- this._logErr('failed to serialise notification', method + ':', (err && err.message) || String(err));
883
- return;
884
- }
885
- try {
886
- this._child.stdin.write(framed);
887
- } catch (err) {
888
- this._logErr('notification write failed', method + ':', (err && err.message) || String(err));
889
- }
890
- }
891
-
892
- /** Buffer incoming stdout bytes and drain every complete message (dual framing). */
893
- _onData(chunk) {
894
- this._buf = Buffer.concat([this._buf, chunk]);
895
- this._drain();
896
- if (this._buf.length > MAX_MESSAGE_BYTES) {
897
- // Past the cap with no complete frame there is no resync point - drop the connection.
898
- // The tree-kill surfaces as an unexpected exit: waiters reject and onClose lets the
899
- // owner schedule a reconnect (a deliberate close() would suppress that signal and
900
- // permanently retire this client).
901
- this._logErr(`buffered ${this._buf.length} bytes with no complete message (cap ${MAX_MESSAGE_BYTES}); dropping the connection`);
902
- this._buf = Buffer.alloc(0);
903
- this._treeKill(this._child);
904
- }
905
- }
906
-
907
- /**
908
- * Drain as many complete messages as are buffered. Header (Content-Length) framing
909
- * is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
910
- * JSON. Mirrors server.js's dual-drain so the client reads either wire format.
911
- * A header frame that is only PARTLY here ('wait') stops the drain - handing the
912
- * half-arrived header to line framing would eat it and orphan the body (the frame
913
- * was lost and the caller saw only a timeout; chunk boundaries are the transport's
914
- * choice, so any CL-framing upstream could hit it under load).
915
- */
916
- _drain() {
917
- for (;;) {
918
- if (this._skipRemaining > 0) {
919
- // A refused over-cap message: discard exactly its declared bytes as they arrive so the
920
- // pipe stays in frame for everything after it.
921
- const eat = Math.min(this._skipRemaining, this._buf.length);
922
- this._buf = this._buf.slice(eat);
923
- this._skipRemaining -= eat;
924
- if (this._skipRemaining > 0) return; // the rest of the refused body is still in flight
925
- }
926
- const h = this._tryHeaderFramed();
927
- if (h === true) continue;
928
- if (h === 'wait') break; // an incomplete header frame - wait for more bytes
929
- if (this._tryLineFramed()) continue;
930
- break;
931
- }
932
- }
933
-
934
- /** True when the buffer BEGINS with a framing header whose block terminator hasn't arrived. */
935
- _headerInFlight() {
936
- return /^content-(length|type):/i.test(this._buf.slice(0, 16).toString('utf8'));
937
- }
938
-
939
- /**
940
- * Pull one Content-Length-framed message off the front of the buffer.
941
- * Returns true (consumed one), false (not header framing - try line framing), or
942
- * 'wait' (a header frame is mid-flight - stop draining until more bytes arrive).
943
- */
944
- _tryHeaderFramed() {
945
- const headerEnd = this._buf.indexOf('\r\n\r\n');
946
- if (headerEnd === -1) {
947
- // No complete header block - but a buffer that STARTS with a header line is a header
948
- // frame whose terminator is still in flight, not line-framed junk.
949
- return this._headerInFlight() ? 'wait' : false;
950
- }
951
- const header = this._buf.slice(0, headerEnd).toString('utf8');
952
- const m = /Content-Length:\s*(\d+)/i.exec(header);
953
- if (!m) return false; // a \r\n\r\n that isn't a Content-Length header -> not this framing
954
- const len = parseInt(m[1], 10);
955
- const bodyStart = headerEnd + 4;
956
- if (len > MAX_MESSAGE_BYTES) {
957
- // Refuse the message, keep the connection: consume the header and let _drain discard
958
- // exactly the declared bytes as they arrive. The reply's waiter times out with the
959
- // standard window error; everything after the refused body still flows.
960
- this._logErr(`upstream declared a ${len}-byte message (cap ${MAX_MESSAGE_BYTES}); skipping it`);
961
- this._buf = this._buf.slice(bodyStart);
962
- this._skipRemaining = len;
963
- return true;
964
- }
965
- if (this._buf.length < bodyStart + len) return 'wait'; // body still in flight - hold the drain
966
- const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
967
- this._buf = this._buf.slice(bodyStart + len);
968
- this._parseAndDispatch(body);
969
- return true;
970
- }
971
-
972
- /** Pull one newline-delimited JSON message off the front of the buffer. */
973
- _tryLineFramed() {
974
- const nl = this._buf.indexOf(0x0a); // '\n'
975
- if (nl === -1) return false;
976
- const line = this._buf.slice(0, nl).toString('utf8').trim();
977
- this._buf = this._buf.slice(nl + 1);
978
- if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
979
- // A header line at the FRONT of the buffer never reaches this path (header framing holds
980
- // the drain with 'wait'); any non-JSON line here is a framing artifact - ignore it rather
981
- // than erroring.
982
- if (line[0] !== '{' && line[0] !== '[') return true;
983
- this._parseAndDispatch(line);
984
- return true;
985
- }
986
-
987
- /** Parse one JSON body and route it to its waiter by id. Bad JSON / unmatched ids are ignored. */
988
- _parseAndDispatch(text) {
989
- let msg;
990
- try {
991
- msg = JSON.parse(text);
992
- } catch (_e) {
993
- this._logErr('dropping unparseable message from upstream');
994
- return;
995
- }
996
- if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
997
-
998
- const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
999
- if (!hasId) {
1000
- // A progress beat for an in-flight request proves the upstream is alive and WORKING that
1001
- // request - re-arm its window so a slow-but-progressing call never dies to the fixed
1002
- // timeout. The notification still relays below, untouched.
1003
- if (msg.method === 'notifications/progress' && msg.params &&
1004
- msg.params.progressToken !== undefined) {
1005
- this._bumpProgressWaiter(msg.params.progressToken);
1006
- }
1007
- // Server-initiated NOTIFICATION (method, no id) - surface it so the aggregator can bridge
1008
- // change-notifications to a modern subscriptions/listen stream. NEVER throws.
1009
- if (typeof msg.method === 'string' && typeof this.onNotification === 'function') {
1010
- try { this.onNotification(msg); } catch (_e) { this._logErr('onNotification threw (ignored)'); }
1011
- }
1012
- return;
1013
- }
1014
-
1015
- if (typeof msg.method === 'string') {
1016
- // SERVER->CLIENT REQUEST (id + method): the upstream is asking ITS client something -
1017
- // elicitation/create, sampling/createMessage, roots/list. Previously dropped, which left
1018
- // a conformant upstream holding an open request forever (the elicitation bridge). Surface to the owner;
1019
- // unhandled -> answer -32601 (the honest "this client cannot do that").
1020
- if (typeof this.onServerRequest === 'function') {
1021
- try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
1022
- }
1023
- this.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method);
1024
- return;
1025
- }
1026
-
1027
- const w = this._waiters.get(msg.id);
1028
- if (!w) return; // response to an id we no longer await (timed out / closed) - drop it
1029
- this._waiters.delete(msg.id);
1030
- try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
1031
-
1032
- if (msg.error) {
1033
- const e = msg.error;
1034
- const message = e && e.message ? e.message : 'JSON-RPC error';
1035
- const code = e && typeof e.code === 'number' ? ` (code ${e.code})` : '';
1036
- const err = new Error(`McpClient: upstream error for "${w.method}": ${message}${code}`);
1037
- // Preserve the VERBATIM JSON-RPC error object - a wrap must relay the upstream's own error
1038
- // (code/message/data) untouched; the stringified message above is for logs and lean paths.
1039
- err.rpcError = { code: e && typeof e.code === 'number' ? e.code : -32603, message };
1040
- if (e && e.data !== undefined) err.rpcError.data = e.data;
1041
- w.reject(err);
1042
- return;
1043
- }
1044
- w.resolve(msg.result);
1045
- }
1046
- }
1047
-
1048
- /**
1049
- * Frame a JSON-RPC message for the MCP stdio transport: one compact newline-delimited
1050
- * JSON message. NOT Content-Length framing - see the field note inside.
1051
- * @param {object} payload
1052
- * @returns {string}
1053
- */
1054
- function frameRequest(payload) {
1055
- // MCP stdio transport framing: one compact JSON-RPC message per line, newline-delimited - NOT
1056
- // LSP-style Content-Length. JSON.stringify emits no literal newlines, so a single trailing "\n"
1057
- // cleanly delimits the message. This is what real SDK-based MCP servers read on stdin; writing
1058
- // Content-Length here is what silently broke every real upstream (the read side still tolerates
1059
- // both framings for robustness against the occasional Content-Length server).
1060
- return JSON.stringify(payload) + '\n';
1061
- }
1062
-
1063
- module.exports = {
1064
- McpClient,
1065
- // exported for unit tests / reuse
1066
- frameRequest,
1067
- winLaunch,
1068
- PROTOCOL_VERSION,
1069
- CLIENT_INFO,
1070
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * mcp-client.js - the MCP CLIENT: the aggregator's per-upstream connection.
5
+ *
6
+ * This is the REVERSE of src/mcp/server.js. Where server.js is a JSON-RPC 2.0
7
+ * server that the CLI connects TO, this is a JSON-RPC 2.0 client that spawns an
8
+ * UPSTREAM MCP server as a child process and talks to it over stdio. The
9
+ * aggregator owns one McpClient per upstream MCP server, lists their tools, and
10
+ * routes curated calls through them.
11
+ *
12
+ * Transport contract (mirrors server.js's framing helpers but in the other direction):
13
+ * WRITE: newline-delimited JSON - one compact message per line, which is what real
14
+ * SDK-based MCP servers read on stdin (Content-Length framing silently broke
15
+ * every real upstream - see frameRequest()).
16
+ * READ: tolerate BOTH `Content-Length:` framing AND newline-delimited JSON, so the
17
+ * same client works against a real MCP server AND against our own server.js
18
+ * (which WRITES newline-delimited JSON). This is server.js's dual-drain logic
19
+ * re-used in the client.
20
+ *
21
+ * JSON-RPC requests are matched to responses by `id`. Every request carries a
22
+ * timeout (requestTimeoutMs) - a slow/dead upstream rejects the in-flight promise
23
+ * instead of leaking it. Notifications (no `id`) from the server are ignored.
24
+ *
25
+ * SAFETY / defensiveness:
26
+ * - close() is idempotent and NEVER leaves a zombie child: it ends stdin, then if the
27
+ * child is still alive tree-kills it (win32: taskkill /T /F; else child.kill()).
28
+ * - connect() that fails (spawn error or initialize timeout) calls close() so no child
29
+ * is left running, then rejects with a clear error.
30
+ * - A child that exits while requests are in flight rejects every waiter (no hang).
31
+ * - spawnImpl is injectable so a unit test can fake the child, but the real round-trip
32
+ * against the fixture is the load-bearing test.
33
+ *
34
+ * CommonJS only. Node built-ins only. No @modelcontextprotocol/sdk.
35
+ */
36
+
37
+ const childProcess = require('node:child_process');
38
+ const fs = require('node:fs');
39
+ const path = require('node:path');
40
+
41
+ const JSONRPC = '2.0';
42
+ const PROTOCOL_VERSION = '2024-11-05';
43
+ // The 2026-07-28 ("modern") protocol version this client can SPEAK to a modern upstream. Over
44
+ // stdio the modern era needs only per-request `_meta` - the Mcp-Method/Mcp-Name/MCP-Protocol-Version
45
+ // headers are HTTP-transport-only, so a stdio client is modern by adding `_meta` + skipping the
46
+ // initialize handshake.
47
+ const { MODERN_PROTOCOL_VERSION } = require('./modern'); // the ONE definition (version-literal.test.js pins it)
48
+ const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
49
+
50
+ /**
51
+ * Cross-platform launch resolution for the upstream command.
52
+ *
53
+ * POSIX: pass through unchanged - execvp resolves `npx` / `uvx` / `node` / ... on PATH.
54
+ *
55
+ * Windows: child_process with shell:false CANNOT run a `.cmd` / `.bat` shim - a bare name ENOENTs,
56
+ * and an explicit `.cmd` throws EINVAL (the CVE-2024-27980 mitigation). Most MCP servers launch via
57
+ * exactly such shims (`npx`, `npm`, `uvx`, `pnpm`, ...), so on Windows route anything that isn't already
58
+ * a concrete `.exe`/`.com` through cmd.exe, which resolves PATHEXT shims. The args stay DISCRETE
59
+ * (never shell-concatenated into one string) and there is none of the `shell:true` DEP0190 hazard.
60
+ * HONEST CAVEAT: because the spawned binary here is cmd.exe itself (an .exe), Node applies its
61
+ * STANDARD argv quoting - not cmd.exe ^-metacharacter escaping (that special-casing only fires when
62
+ * the command IS the .cmd/.bat). An arg containing cmd metacharacters (& | > < ") outside quotes can
63
+ * therefore still be reinterpreted by cmd.exe. These args come from the operator's own expose.json,
64
+ * so this is a correctness caveat on operator-authored config, not a privilege boundary; the fully
65
+ * escaped alternative (resolve the real interpreter à la auth/install.js's `node + npm-cli.js` and
66
+ * spawn it directly) is the upgrade path if it ever bites. close()'s taskkill /T still reaps the
67
+ * whole cmd -> shim -> server process tree.
68
+ *
69
+ * @param {string} command the configured upstream command (e.g. "npx", "node", an abs path)
70
+ * @param {string[]} args the configured argv
71
+ * @returns {{ command: string, args: string[] }} what to actually hand to spawn()
72
+ */
73
+ function winLaunch(command, args) {
74
+ const argv = Array.isArray(args) ? args : [];
75
+ if (process.platform !== 'win32') return { command, args: argv };
76
+ // A concrete executable can be spawned directly - no shim resolution needed. A relative one
77
+ // (`../../x/node.exe`, the drive-independent config) resolves against the spawn's cwd.
78
+ const pathShaped = path.isAbsolute(command) || /[\\/]/.test(command);
79
+ if (pathShaped && /\.(exe|com)$/i.test(command)) return { command, args: argv };
80
+ // Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT. cmd reads a '/'
81
+ // as a switch, so a path reaches it with backslashes.
82
+ const comspec = process.env.ComSpec || 'cmd.exe';
83
+ return { command: comspec, args: ['/c', pathShaped ? command.replace(/\//g, '\\') : command, ...argv] };
84
+ }
85
+
86
+ /**
87
+ * Windows only: is `command` definitely absent from everywhere cmd.exe would look - the working
88
+ * directory, then each PATH directory, trying each PATHEXT extension?
89
+ *
90
+ * winLaunch routes every non-.exe command through cmd.exe, so a missing command never fails the
91
+ * spawn with ENOENT: cmd prints a LOCALISED "is not recognized" line and exits 1, exactly like a
92
+ * server that crashed on start. The owner classifies permanent failures by error code (the
93
+ * aggregator's reconnect ceiling), so connect() asks this AFTER a failed connect and stamps ENOENT.
94
+ * It never blocks a spawn. Conservative by construction - it answers true only when every
95
+ * candidate is definitely absent: a stat error other than ENOENT/ENOTDIR (an App Execution Alias
96
+ * can refuse stat) counts as found. A PATH entry is split quote-aware (cmd.exe accepts
97
+ * "C:\a;b" as one directory), and a %VAR% the env cannot expand stays literal, as it does for cmd.exe.
98
+ *
99
+ * @param {string} command
100
+ * @param {string} [cwd] the child's working directory
101
+ * @param {object} [env] the child's merged environment
102
+ * @returns {boolean}
103
+ */
104
+ function commandMissing(command, cwd, env) {
105
+ if (process.platform !== 'win32' || typeof command !== 'string' || command.length === 0) return false;
106
+ const e = env || process.env;
107
+ // Env names are case-insensitive on Windows and a merged env can hold both `Path` and `PATH`:
108
+ // take every spelling (more candidates can only make the answer more conservative).
109
+ const all = (name) => Object.keys(e).filter((k) => k.toUpperCase() === name).map((k) => String(e[k] || ''));
110
+ const exts = all('PATHEXT').join(';').split(';').filter(Boolean);
111
+ const candidates = [command, ...(exts.length ? exts : ['.COM', '.EXE', '.BAT', '.CMD']).map((x) => command + x)];
112
+ const base = typeof cwd === 'string' && cwd.length ? path.resolve(cwd) : process.cwd();
113
+ const isPath = /[\\/]/.test(command) || path.isAbsolute(command);
114
+ const dirs = [base];
115
+ if (!isPath) {
116
+ for (const d of all('PATH').join(';').match(/(?:"[^"]*"|[^;])+/g) || []) {
117
+ const dir = d.replace(/"/g, '').trim();
118
+ if (!dir) continue;
119
+ // Expand %VAR% from the child's env where we can; an unknown one stays literal. Either way the
120
+ // extra candidate can only turn up a match (the safe direction), never hide one.
121
+ dirs.push(dir.replace(/%([^%]+)%/g, (m, v) => { const hit = all(v.toUpperCase())[0]; return hit !== undefined ? hit : m; }));
122
+ }
123
+ }
124
+ for (const dir of dirs) {
125
+ for (const name of candidates) {
126
+ try {
127
+ fs.statSync(path.resolve(dir, name));
128
+ return false;
129
+ } catch (err) {
130
+ if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) return false;
131
+ }
132
+ }
133
+ }
134
+ return true;
135
+ }
136
+
137
+ /**
138
+ * McpClient - a hand-rolled JSON-RPC 2.0 client over a child process's stdio.
139
+ *
140
+ * Lifecycle: `new McpClient(opts)` -> `await connect()` -> `listTools()` / `callTool()`
141
+ * -> `close()` (idempotent).
142
+ */
143
+ // Payload-bearing methods get the LONG window (toolTimeoutMs): a tool call legitimately runs as
144
+ // long as the tool runs. Everything else (handshake, lists, discover, ping) keeps the SHORT
145
+ // requestTimeoutMs - that is the dead-upstream detector.
146
+ const PAYLOAD_METHODS = new Set(['tools/call', 'prompts/get', 'resources/read']);
147
+
148
+ // Hard ceiling on one buffered stdout message (matches http-transport's MAX_BODY_BYTES). A
149
+ // declared Content-Length over the cap is refused and its bytes discarded as they arrive, so
150
+ // the pipe stays in frame; a buffer past the cap with NO complete frame has no resync point,
151
+ // so the connection is dropped (the owner's reconnect machinery takes over). Without the cap
152
+ // a single giant unframed line from a broken upstream grew this buffer until the whole gateway
153
+ // process died.
154
+ const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
155
+
156
+ class McpClient {
157
+ /**
158
+ * @param {object} opts
159
+ * @param {string} [opts.id] caller-chosen id for this upstream (diagnostics only)
160
+ * @param {string} opts.command executable to spawn (e.g. process.execPath)
161
+ * @param {string[]} [opts.args] argv for the child (e.g. [serverPath])
162
+ * @param {object} [opts.env] extra env merged over process.env for the child
163
+ * @param {string} [opts.cwd] child working directory
164
+ * @param {Function} [opts.spawnImpl] injectable spawn (default node:child_process.spawn)
165
+ * @param {number} [opts.requestTimeoutMs] control-plane timeout in ms (default 10000) -
166
+ * handshake/list/discover/ping; the dead-upstream detector
167
+ * @param {number} [opts.toolTimeoutMs] PAYLOAD timeout in ms (default 120000) for
168
+ * tools/call | prompts/get | resources/read; a
169
+ * token-matched progress beat re-arms it
170
+ * @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
171
+ * while not deliberately close()d). Lets the owner
172
+ * (the aggregator) schedule a background reconnect.
173
+ * @param {'legacy'|'modern'} [opts.eraHint] the owner's memo of the era negotiated LAST connect.
174
+ * 'legacy' skips the server/discover probe (a reconnect
175
+ * goes straight to initialize); 'modern' is a no-op (the
176
+ * probe IS the modern handshake). An observation, not a
177
+ * policy: the owner clears its memo when a hinted
178
+ * connect fails. modernOnly always wins over the hint.
179
+ */
180
+ constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs, toolTimeoutMs, onClose, forceLegacy = false, modernOnly = false, eraHint, onNotification, clientInfo } = {}) {
181
+ if (typeof command !== 'string' || command.length === 0) {
182
+ throw new Error('McpClient: "command" (non-empty string) is required');
183
+ }
184
+ this.id = typeof id === 'string' && id.length ? id : 'mcp-client';
185
+ // clientInfo: the identity this client PRESENTS to the upstream (legacy initialize and the
186
+ // modern _meta trio). Defaults to the built-in - absent the opt, wire behaviour is byte-identical
187
+ // to before. The wrap's identity mirroring hands the REAL downstream client's info through here.
188
+ // The FULL object is carried (title etc.), not a name/version whitelist - the wrap's serverInfo
189
+ // direction proved a whitelist silently eats spec fields.
190
+ this._clientInfo =
191
+ clientInfo && typeof clientInfo === 'object' &&
192
+ typeof clientInfo.name === 'string' && clientInfo.name.length
193
+ ? Object.freeze(Object.assign({}, clientInfo, {
194
+ version: typeof clientInfo.version === 'string' ? clientInfo.version : '0.0.0',
195
+ }))
196
+ : CLIENT_INFO;
197
+ this._command = command;
198
+ this._args = Array.isArray(args) ? args.slice() : [];
199
+ this._env = env && typeof env === 'object' ? env : {};
200
+ this._cwd = cwd;
201
+ this._spawn = typeof spawnImpl === 'function' ? spawnImpl : childProcess.spawn;
202
+ this._requestTimeoutMs =
203
+ Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 10000;
204
+ // An EXPLICITLY configured control window also governs the era probe in connect(): the
205
+ // operator opted into slower connects for a slow-boot upstream, and the probe must not
206
+ // quietly undo that (a slow-boot modern-only server could otherwise never attach).
207
+ this._explicitRequestTimeout = Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0;
208
+ // The PAYLOAD window (tools/call | prompts/get | resources/read): a tool legitimately runs
209
+ // as long as the tool runs - the fixed 10 s window hard-killed every wrapped tool slower
210
+ // than that and silently dropped the upstream's eventual result. Per-upstream
211
+ // `timeoutMs` config lands here; a token-matched progress beat re-arms the window.
212
+ this._toolTimeoutMs =
213
+ Number.isFinite(toolTimeoutMs) && toolTimeoutMs > 0 ? toolTimeoutMs : 120000;
214
+ this._onClose = typeof onClose === 'function' ? onClose : null;
215
+ // forceLegacy (legacyPin): skip the modern server/discover probe and speak legacy unconditionally.
216
+ this._forceLegacy = forceLegacy === true;
217
+ // modernOnly: the MIRROR of legacyPin - refuse the legacy fallback entirely. A modernOnly
218
+ // upstream that does not answer server/discover with the modern protocol FAILS the connect
219
+ // with a clear error instead of silently negotiating down.
220
+ this._modernOnly = modernOnly === true;
221
+ // eraHint ('legacy' is the only meaningful value): the OWNER's memo of the era this upstream
222
+ // negotiated LAST time - an observation, not a policy. It skips the server/discover probe the
223
+ // way forceLegacy does, but the caller is expected to clear its memo when a hinted connect
224
+ // FAILS (the upstream may have changed underneath) - which forceLegacy, being policy, never
225
+ // does. 'modern' is accepted-and-ignored: for a modern upstream the probe IS the handshake,
226
+ // so there is nothing to skip. modernOnly always wins over the hint - a policy that demands
227
+ // the probe cannot be undone by a stale observation.
228
+ this._eraHint = eraHint === 'legacy' ? 'legacy' : null;
229
+ // Era-observation confidence (set per connect()): true only when the server/discover probe was
230
+ // ANSWERED - a valid modern DiscoverResult, a non-modern reply, or a JSON-RPC rejection like
231
+ // -32601. A probe that TIMED OUT (or a skipped probe) observed nothing about the upstream's
232
+ // eras, and the owner must not memoise the legacy fallback it forced.
233
+ this._eraDefinitive = false;
234
+ // onNotification: called with each server-initiated NOTIFICATION (a message with a method and
235
+ // NO id) - e.g. notifications/resources/updated, .../resources/list_changed, .../prompts/
236
+ // list_changed, .../tools/list_changed. Lets the aggregator bridge upstream change-notifications
237
+ // to a modern client's subscriptions/listen stream. Settable post-construction too.
238
+ this.onNotification = typeof onNotification === 'function' ? onNotification : null;
239
+ // onServerRequest: called with each SERVER-INITIATED REQUEST (a message with a method AND an
240
+ // id - elicitation/create, sampling/createMessage, roots/list). The owner answers it via
241
+ // respondToServer/respondToServerError. Unset -> every such request is answered -32601, so a
242
+ // conformant upstream is never left holding an unanswered request (elicitation-bridge infrastructure).
243
+ this.onServerRequest = null;
244
+ this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
245
+ this._everConnected = false; // true once the handshake completed - onClose fires ONLY for the
246
+ // death of an ESTABLISHED connection, never a connect-time failure
247
+
248
+ // Transport state.
249
+ this._child = null;
250
+ this._connected = false;
251
+ this._closed = false;
252
+ this._buf = Buffer.alloc(0);
253
+ this._skipRemaining = 0; // bytes of a refused over-cap message still to discard
254
+ this._stderr = '';
255
+ this._nextId = 1;
256
+ /** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
257
+ this._waiters = new Map();
258
+
259
+ // The initialize result, captured for connect()'s return value.
260
+ this._initializeResult = null;
261
+ }
262
+
263
+ /** @returns {boolean} true once the initialize handshake has completed and not yet closed. */
264
+ get connected() {
265
+ return this._connected === true && this._closed === false;
266
+ }
267
+
268
+ /** @returns {object|null} the upstream's raw `initialize` result ({ protocolVersion, serverInfo,
269
+ * capabilities, instructions }) captured at connect. Used by the passthrough WRAP to present the
270
+ * wrapped server's OWN identity (a wrapped MCP must be indistinguishable from the real thing). */
271
+ get initializeResult() {
272
+ return this._initializeResult;
273
+ }
274
+
275
+ /**
276
+ * Raw JSON-RPC passthrough for the WRAP: forward a method to the upstream verbatim and return its
277
+ * result. Used to relay non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
278
+ * server behaves exactly as it would connected directly. Tool calls do NOT use this - they go
279
+ * through the gated forward path. Rejects on timeout / transport failure (same as callTool).
280
+ * @param {string} method
281
+ * @param {any} [params]
282
+ * @returns {Promise<any>} the upstream's raw result
283
+ */
284
+ async request(method, params, out) {
285
+ if (typeof method !== 'string' || method.length === 0) {
286
+ throw new Error('McpClient.request: "method" (non-empty string) is required');
287
+ }
288
+ let p = params == null ? {} : params;
289
+ p = this._translateLogLevel(p); // era-keyed logLevel (1.4): modern keeps it, legacy gets setLevel
290
+ // Modern upstream: ensure the per-request `_meta` protocol trio is present. MERGE with any
291
+ // caller-supplied _meta (progressToken, trace keys survive a wrap forward) - the trio wins on
292
+ // collision; a partial caller _meta must never reach a strict modern upstream trio-less.
293
+ if (this._modern && p && typeof p === 'object' && !Array.isArray(p)) {
294
+ const callerMeta = (p._meta && typeof p._meta === 'object' && !Array.isArray(p._meta)) ? p._meta : null;
295
+ p = Object.assign({}, p, { _meta: Object.assign({}, callerMeta, this._modernMeta()) });
296
+ }
297
+ return await this._request(method, p, out);
298
+ }
299
+
300
+ /**
301
+ * Send a NOTIFICATION to the upstream - no id, no waiter, no reply. Used by a wrap to relay
302
+ * client notifications (notifications/cancelled, notifications/progress ...) that a
303
+ * directly-connected server would receive. Fire-and-forget; NEVER throws.
304
+ * @param {string} method
305
+ * @param {any} params
306
+ */
307
+ notify(method, params) {
308
+ try {
309
+ if (this._closed || !this._child || !this._child.stdin) return;
310
+ if (typeof method !== 'string' || method.length === 0) return;
311
+ const payload = { jsonrpc: JSONRPC, method };
312
+ if (params !== undefined) payload.params = params;
313
+ this._child.stdin.write(frameRequest(payload));
314
+ } catch (_e) { /* fire-and-forget */ }
315
+ }
316
+
317
+ /**
318
+ * Answer a SERVER-INITIATED request (the elicitation bridge): write a JSON-RPC RESPONSE carrying the
319
+ * upstream's own request id. Fire-and-forget; NEVER throws.
320
+ * @param {number|string} id the id the UPSTREAM used on its request
321
+ * @param {any} result the result payload (e.g. an ElicitResult { action, content })
322
+ */
323
+ respondToServer(id, result) {
324
+ try {
325
+ if (this._closed || !this._child || !this._child.stdin) return;
326
+ if (id === undefined || id === null) return;
327
+ this._child.stdin.write(frameRequest({ jsonrpc: JSONRPC, id, result: result === undefined ? {} : result }));
328
+ } catch (_e) { /* fire-and-forget */ }
329
+ }
330
+
331
+ /**
332
+ * Extend the timeout of an in-flight request (the elicitation bridge): a mid-call elicitation proves the
333
+ * upstream is alive and deliberately holding the call open while a HUMAN answers - the normal
334
+ * dead-upstream timeout must not kill the suspension. Re-arms the waiter's timer; returns
335
+ * false if the request already settled. NEVER throws.
336
+ * @param {number|string} id the rpc id of the in-flight request
337
+ * @param {number} ms the new timeout window from now
338
+ */
339
+ extendRequestTimeout(id, ms) {
340
+ try {
341
+ const w = this._waiters.get(id);
342
+ if (!w) return false;
343
+ clearTimeout(w.timer);
344
+ // The extension becomes the waiter's WINDOW, not just its current timer: a token-matched
345
+ // progress beat re-arms to w.win, so leaving the old (short) win in place let a single
346
+ // beat during a Bridge-B suspension collapse the human-answer hold back to toolTimeoutMs
347
+ //Progress during a hold now re-arms to the hold.
348
+ const win = Number.isFinite(ms) && ms > 0 ? ms : this._requestTimeoutMs;
349
+ w.win = win;
350
+ w.timer = setTimeout(() => {
351
+ this._waiters.delete(id);
352
+ w.reject(new Error(`McpClient: request("${w.method}") timed out (suspended-call window elapsed)`));
353
+ }, win);
354
+ if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
355
+ return true;
356
+ } catch (_e) {
357
+ return false;
358
+ }
359
+ }
360
+
361
+ /**
362
+ * Re-arm the timeout of the in-flight request matching this progressToken:
363
+ * a progress beat is proof of life for THAT request. Token-matched only - a dead upstream
364
+ * still times out on its full window. NEVER throws.
365
+ * @param {number|string} token the progressToken from a notifications/progress params
366
+ */
367
+ _bumpProgressWaiter(token) {
368
+ try {
369
+ for (const [id, w] of this._waiters) {
370
+ if (w.progressToken === undefined || w.progressToken !== token) continue;
371
+ clearTimeout(w.timer);
372
+ const win = Number.isFinite(w.win) && w.win > 0 ? w.win : this._toolTimeoutMs;
373
+ w.timer = setTimeout(() => {
374
+ this._waiters.delete(id);
375
+ w.reject(new Error(
376
+ `McpClient: timeout after ${win}ms without progress waiting for "${w.method}". stderr so far:\n${this._stderr}`
377
+ ));
378
+ }, win);
379
+ if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
380
+ return;
381
+ }
382
+ } catch (_e) { /* re-arm is best-effort; the original timer stands */ }
383
+ }
384
+
385
+ /** Answer a server-initiated request with a JSON-RPC ERROR. Fire-and-forget; NEVER throws. */
386
+ respondToServerError(id, code, message) {
387
+ try {
388
+ if (this._closed || !this._child || !this._child.stdin) return;
389
+ if (id === undefined || id === null) return;
390
+ this._child.stdin.write(frameRequest({
391
+ jsonrpc: JSONRPC, id,
392
+ error: { code: typeof code === 'number' ? code : -32603, message: String(message || 'error') },
393
+ }));
394
+ } catch (_e) { /* fire-and-forget */ }
395
+ }
396
+
397
+ // ── Diagnostics -> never throw. ───────────────────────────────────────────────────────────────
398
+ _logErr(...parts) {
399
+ try {
400
+ process.stderr.write(`[mcp-client:${this.id}] ${parts.join(' ')}\n`);
401
+ } catch (_e) {
402
+ /* never let logging throw */
403
+ }
404
+ }
405
+
406
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
407
+ // connect(): spawn the child, perform the MCP initialize handshake, send the
408
+ // notifications/initialized notification, set connected. On ANY failure (spawn
409
+ // error, initialize timeout/error) close() the child and reject - never leave a zombie.
410
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
411
+ async connect() {
412
+ if (this._closed) throw new Error('McpClient: connect() called after close()');
413
+ if (this._connected) return this._initializeResult;
414
+
415
+ // 1) Spawn the child (shared with probeDiscover - a probe may already have spawned it).
416
+ if (!this._child) this._spawnChild();
417
+
418
+ // 2) DUAL-ERA negotiation (the spec's dual-era CLIENT rule: try MODERN first). Probe
419
+ // `server/discover`: a modern or dual-era upstream answers with a DiscoverResult that lists
420
+ // the modern version; a legacy-only upstream rejects it (method-not-found) or drops. On a
421
+ // valid modern answer we speak MODERN (no initialize; per-request `_meta` on every call).
422
+ // Otherwise (or when legacyPin FORCES legacy) we fall back to the LEGACY initialize handshake
423
+ // (byte-for-byte the prior behaviour).
424
+ let discovered = null;
425
+ // The probe is skipped by POLICY (legacyPin) or by the owner's era MEMO (eraHint 'legacy' -
426
+ // this upstream negotiated legacy last time, so a reconnect goes straight to initialize
427
+ // instead of re-paying the probe window). modernOnly overrides the hint: that policy's whole
428
+ // point is the probe, and a stale memo must never silently downgrade it.
429
+ const skipProbe = this._forceLegacy || (this._eraHint === 'legacy' && !this._modernOnly);
430
+ this._eraDefinitive = false; // no observation yet this connect (stays false when the probe is skipped)
431
+ if (!skipProbe) {
432
+ let probe = null;
433
+ // The probe gets a SHORT timeout of its own: a legacy server that silently DROPS unknown
434
+ // methods (instead of answering -32601) would otherwise stall every connect by the full
435
+ // request timeout - serialised across upstreams at startup. An EXPLICIT per-upstream
436
+ // requestTimeoutMs overrides the clamp - a slow-boot modern-only server needs the raised
437
+ // window HERE, and the bin-side era probe already honours it (keep the two symmetric).
438
+ const fullTimeout = this._requestTimeoutMs;
439
+ this._requestTimeoutMs = this._explicitRequestTimeout
440
+ ? fullTimeout : Math.min(3000, fullTimeout || 3000);
441
+ try {
442
+ probe = await this._request('server/discover', { _meta: this._modernMeta() });
443
+ this._eraDefinitive = true; // the probe was answered - whatever we conclude is observed
444
+ } catch (probeErr) {
445
+ probe = null; // legacy upstream (method-not-found) - fall through to initialize
446
+ // An ANSWERED rejection (rpcError present, e.g. -32601) is a definitive legacy
447
+ // observation; a timeout/transport drop is SILENCE and proves nothing.
448
+ this._eraDefinitive = !!(probeErr && probeErr.rpcError);
449
+ if (this._closed) throw new Error('McpClient: connect() aborted (closed during discover probe)');
450
+ if (!this._child) this._spawnChild(); // a fragile legacy server may have died on the probe
451
+ } finally {
452
+ this._requestTimeoutMs = fullTimeout;
453
+ }
454
+ // Only treat the reply as MODERN if it is a real DiscoverResult advertising the modern
455
+ // version - a permissive legacy server that answers unknown methods with `{}`/an empty object
456
+ // must NOT be misdetected as modern.
457
+ if (probe && typeof probe === 'object' && Array.isArray(probe.supportedVersions) &&
458
+ probe.supportedVersions.includes(MODERN_PROTOCOL_VERSION)) {
459
+ discovered = probe;
460
+ } else if (probe && this._child) {
461
+ // Answered, but not a modern DiscoverResult -> legacy. The child is alive (it replied), so the
462
+ // legacy initialize below runs on the same child.
463
+ discovered = null;
464
+ }
465
+ }
466
+
467
+ // modernOnly refuses the legacy fallback: no valid modern DiscoverResult means no connection,
468
+ // with an error that names the policy - never a silent downgrade.
469
+ if (!discovered && this._modernOnly) {
470
+ this.close();
471
+ // A command that was never found cannot answer discover either - say THAT, not the policy.
472
+ const missing = this._missingCommandError('McpClient: upstream could not start');
473
+ if (missing) throw missing;
474
+ throw new Error(`McpClient: upstream "${this.id}" is modernOnly but did not answer server/discover with the 2026-07-28 protocol - refusing the legacy fallback`);
475
+ }
476
+
477
+ if (discovered) {
478
+ // MODERN upstream - identity from discover; NO initialize / notifications/initialized.
479
+ this._modern = true;
480
+ this._initializeResult = this._identityFromDiscover(discovered);
481
+ } else {
482
+ // LEGACY upstream - the initialize handshake. A spawn 'error' rejects via the waiter path; a
483
+ // silent server hits the request timeout. Either way close() + a clear error.
484
+ try {
485
+ const result = await this._request('initialize', {
486
+ protocolVersion: PROTOCOL_VERSION,
487
+ clientInfo: this._clientInfo,
488
+ // elicitation: real SDK-based servers GATE ctx.elicit() on the client declaring this
489
+ // capability - without it they answer "Method not found" and never elicit at all.
490
+ // Every elicitation is handled: the elicitation bridge's MRTR path for modern callers,
491
+ // a clean decline otherwise.
492
+ // BOTH modes declared: the 2025-11-25 back-compat rule reads a bare
493
+ // `{}` as FORM ONLY, and "Servers MUST NOT send elicitation requests with modes that
494
+ // are not supported by the client" - so without `url` here a conformant upstream is
495
+ // FORBIDDEN from url mode and the relay is structurally dead. Every elicit of either
496
+ // mode gets an answer: relayed to a caller that declared it, declined otherwise.
497
+ capabilities: { elicitation: { form: {}, url: {} } },
498
+ });
499
+ this._initializeResult = result;
500
+ } catch (err) {
501
+ this.close();
502
+ const message = `McpClient: initialize failed: ${(err && err.message) || String(err)}`;
503
+ // Name a missing working directory or command first (a spawn ENOENT names only the
504
+ // executable, and the Windows launch shim hides a missing command behind "exited (code=1)"),
505
+ // else keep the cause's code - the owner classifies by it.
506
+ const missing = this._missingCommandError(message);
507
+ if (missing) throw missing;
508
+ if (err && typeof err.code === 'string') throw Object.assign(new Error(message), { code: err.code });
509
+ throw new Error(message);
510
+ }
511
+ // Tell the server the handshake is complete (notification - no id, no reply).
512
+ this._notify('notifications/initialized', {});
513
+ this._modern = false;
514
+ }
515
+
516
+ this._connected = true;
517
+ this._everConnected = true; // from here on, an exit/error is an ESTABLISHED-connection death
518
+ return this._initializeResult;
519
+ }
520
+
521
+ /** @returns {'modern'|'legacy'} the era negotiated at connect (default 'legacy' before connect). */
522
+ get era() {
523
+ return this._modern === true ? 'modern' : 'legacy';
524
+ }
525
+
526
+ /** @returns {boolean} true when the LAST connect's era came from an ANSWERED probe; false when
527
+ * the probe was skipped (policy/hint) or went unanswered (timeout/drop). The owner memoises
528
+ * the era only on a definitive observation - silence must never pin an era. */
529
+ get eraDefinitive() {
530
+ return this._eraDefinitive === true;
531
+ }
532
+
533
+ /** The per-request `_meta` a modern client MUST send on every request (stdio: no headers). */
534
+ _modernMeta() {
535
+ return {
536
+ 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
537
+ 'io.modelcontextprotocol/clientCapabilities': {},
538
+ 'io.modelcontextprotocol/clientInfo': this._clientInfo,
539
+ };
540
+ }
541
+
542
+ /** @returns {{name:string, version:string}} the identity this client presents upstream. */
543
+ get clientInfo() {
544
+ return this._clientInfo;
545
+ }
546
+
547
+ /**
548
+ * Era-keyed logLevel handling. A caller's per-request
549
+ * `io.modelcontextprotocol/logLevel` must reach the upstream in the upstream's own vocabulary:
550
+ * MODERN upstream -> left in `_meta` (the trio merge in request()/callTool() preserves it - the
551
+ * trio does not define logLevel), because the modern severity clause obliges the upstream to
552
+ * WITHHOLD notifications/message when the field is absent. LEGACY upstream -> the key is
553
+ * REMOVED (no legacy schema defines it; leaking a modern reserved key is also a wrap tell)
554
+ * and, when the upstream declared the `logging` capability, translated to a `logging/setLevel`
555
+ * request ISSUED BEFORE the main request (stdio writes are ordered - the server sees the level
556
+ * first), deduped per distinct level. Best-effort: a setLevel failure never fails the main
557
+ * call. Deprecation-sized (SEP-2577): one key, one translation, no wider _meta policy.
558
+ * @param {any} params the outgoing request params
559
+ * @returns {any} params, possibly cloned with the logLevel key removed
560
+ */
561
+ _translateLogLevel(params) {
562
+ const meta = params && typeof params === 'object' && !Array.isArray(params) ? params._meta : null;
563
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return params;
564
+ const level = meta['io.modelcontextprotocol/logLevel'];
565
+ if (level === undefined) return params;
566
+ if (this._modern) return params; // modern upstream: rides the forwarded _meta verbatim
567
+ const rest = { ...meta };
568
+ delete rest['io.modelcontextprotocol/logLevel'];
569
+ const out = { ...params };
570
+ if (Object.keys(rest).length) out._meta = rest; else delete out._meta;
571
+ const caps = this._initializeResult && this._initializeResult.capabilities;
572
+ if (typeof level === 'string' && caps && caps.logging && level !== this._lastSetLogLevel) {
573
+ this._lastSetLogLevel = level;
574
+ // Fire-and-forget, ordered by the pipe (issued BEFORE the main request). A refusal or timeout
575
+ // reverts the memo so the next call at this level asks again - memoising it as done left the
576
+ // upstream at whatever level it had for the rest of the session.
577
+ this._request('logging/setLevel', { level }).catch(() => { if (this._lastSetLogLevel === level) this._lastSetLogLevel = undefined; });
578
+ }
579
+ return out;
580
+ }
581
+
582
+ /**
583
+ * Normalise a `server/discover` result into the common identity shape wrappedIdentity + connect()
584
+ * consumers expect ({ protocolVersion, serverInfo, capabilities, instructions }) - so a MODERN
585
+ * upstream's identity reads the same as a legacy `initialize` result. serverInfo lives in the
586
+ * discover result's `_meta['io.modelcontextprotocol/serverInfo']`.
587
+ */
588
+ _identityFromDiscover(disc) {
589
+ const d = disc && typeof disc === 'object' ? disc : {};
590
+ const meta = d._meta && typeof d._meta === 'object' ? d._meta : {};
591
+ const si = meta['io.modelcontextprotocol/serverInfo'];
592
+ const versions = Array.isArray(d.supportedVersions) ? d.supportedVersions : [];
593
+ return {
594
+ // We negotiated MODERN (this path is only reached for a modern DiscoverResult), so report the
595
+ // modern version - not `versions[0]`, whose ordering the spec does not guarantee (a dual-era
596
+ // upstream listing legacy first would otherwise yield a contradictory identity).
597
+ protocolVersion: versions.includes(MODERN_PROTOCOL_VERSION) ? MODERN_PROTOCOL_VERSION : (versions[0] || MODERN_PROTOCOL_VERSION),
598
+ serverInfo: si && typeof si === 'object' ? si : undefined,
599
+ capabilities: d.capabilities && typeof d.capabilities === 'object' ? d.capabilities : undefined,
600
+ instructions: typeof d.instructions === 'string' ? d.instructions : undefined,
601
+ supportedVersions: versions,
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Spawn the child + wire its pipes (no handshake). A synchronous spawn throw (bad command) is
607
+ * caught and rethrown clean. Extracted from connect() so probeDiscover() can share it.
608
+ */
609
+ _spawnChild() {
610
+ // Per-child read state starts CLEAN. On an in-place respawn (connect/probeDiscover after the
611
+ // previous child died) a half-arrived frame, a refused over-cap skip count, or the old child's
612
+ // stderr would otherwise be prepended to the new child's first bytes: the drain then swallows
613
+ // its first reply as the rest of a dead body, and every error quotes the wrong stderr. Same
614
+ // values as the constructor. The log-level memo goes too - a new process knows no level.
615
+ this._buf = Buffer.alloc(0);
616
+ this._skipRemaining = 0;
617
+ this._stderr = '';
618
+ this._lastSetLogLevel = undefined;
619
+ try {
620
+ // Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
621
+ const launch = winLaunch(this._command, this._args);
622
+ this._child = this._spawn(launch.command, launch.args, {
623
+ stdio: ['pipe', 'pipe', 'pipe'],
624
+ cwd: this._cwd,
625
+ env: Object.assign({}, process.env, this._env),
626
+ windowsHide: true,
627
+ });
628
+ } catch (err) {
629
+ this._child = null;
630
+ const failed = new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
631
+ if (err && typeof err.code === 'string') failed.code = err.code; // the owner classifies by code
632
+ throw failed;
633
+ }
634
+
635
+ if (!this._child || !this._child.stdin || !this._child.stdout) {
636
+ // A fake/odd spawnImpl that didn't give us pipes - bail cleanly.
637
+ this.close();
638
+ throw new Error('McpClient: spawned child is missing stdio pipes');
639
+ }
640
+
641
+ this._wireChild();
642
+ }
643
+
644
+ /** A connect-failure Error carrying code ENOENT when the working directory or the command is
645
+ * definitely absent (commandMissing - Windows, where the launch shim hides it), else null. */
646
+ _missingCommandError(message) {
647
+ if (typeof this._cwd === 'string' && this._cwd.length && !fs.existsSync(this._cwd)) {
648
+ return Object.assign(new Error(`McpClient: working directory "${this._cwd}" does not exist - ${message}`), { code: 'ENOENT' });
649
+ }
650
+ if (!commandMissing(this._command, this._cwd, Object.assign({}, process.env, this._env))) return null;
651
+ // The cause goes FIRST: the message continues with the upstream's stderr, which a reader
652
+ // (or the aggregator's bounded stop line) may cut short.
653
+ return Object.assign(
654
+ new Error(`McpClient: command "${this._command}" was not found (looked in the working directory and on PATH) - ${message}`),
655
+ { code: 'ENOENT' }
656
+ );
657
+ }
658
+
659
+ /**
660
+ * Era probe - the 2026-07-28 spec's dual-era client rule for stdio: try `server/discover`
661
+ * BEFORE any legacy `initialize`. A modern or dual-era server answers with its
662
+ * supportedVersions; a legacy server rejects it (-32601 Method not found) or times out.
663
+ * Resolves the DiscoverResult, rejects otherwise. Sends the request with modern per-request
664
+ * `_meta` (required in the modern era). Does NOT set connected - this is a probe, not a
665
+ * handshake; call connect() for the legacy handshake (fresh client recommended: a badly
666
+ * behaved legacy server may react badly to an unknown method).
667
+ * @returns {Promise<object>} the server/discover result
668
+ */
669
+ async probeDiscover() {
670
+ if (this._closed) throw new Error('McpClient: probeDiscover() called after close()');
671
+ if (!this._child) this._spawnChild();
672
+ try {
673
+ return await this._request('server/discover', {
674
+ _meta: {
675
+ 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
676
+ 'io.modelcontextprotocol/clientCapabilities': {},
677
+ 'io.modelcontextprotocol/clientInfo': this._clientInfo,
678
+ },
679
+ });
680
+ } catch (err) {
681
+ // Mirror connect()'s close-on-failure contract: a probe that times out or errors must not
682
+ // leave a zombie child (the caller may not have a finally). close() is idempotent.
683
+ this.close();
684
+ throw err;
685
+ }
686
+ }
687
+
688
+ /**
689
+ * tools/list -> the upstream's COMPLETE tool definitions, following `nextCursor` pagination
690
+ * (a single-shot list would silently truncate a paginating upstream to page 1, leaving its
691
+ * later tools undiscoverable AND unrunnable). Returns the raw concatenated array
692
+ * [{name, description, inputSchema, ...}] from every page (empty array if the server returned
693
+ * no tools). Rejects on timeout / transport failure of ANY page.
694
+ *
695
+ * Defensive bounds (a misbehaving upstream must not hang discovery):
696
+ * - a cursor already seen ends the loop (a repeating cursor would spin forever);
697
+ * - MAX_LIST_PAGES caps a pathological endless-unique-cursor upstream. Both end the loop
698
+ * with the pages already fetched - partial truth beats a wedged aggregator.
699
+ * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
700
+ */
701
+ async listTools() {
702
+ const MAX_LIST_PAGES = 64;
703
+ const tools = [];
704
+ const seen = new Set();
705
+ let cursor;
706
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
707
+ // Modern upstream: every request carries per-request `_meta` (stdio needs no headers).
708
+ const params = this._modern ? { _meta: this._modernMeta() } : {};
709
+ if (cursor !== undefined) params.cursor = cursor;
710
+ const result = await this._request('tools/list', params);
711
+ if (result && Array.isArray(result.tools)) tools.push(...result.tools);
712
+ const next = result && typeof result.nextCursor === 'string' && result.nextCursor.length
713
+ ? result.nextCursor : undefined;
714
+ if (next === undefined || seen.has(next)) return tools;
715
+ seen.add(next);
716
+ cursor = next;
717
+ }
718
+ return tools;
719
+ }
720
+
721
+ /**
722
+ * tools/call -> invoke one upstream tool. Returns the MCP tools/call result
723
+ * envelope { content, isError }. Rejects on timeout / transport failure (a tool
724
+ * that ran but returned an error sets isError:true in the resolved value - that
725
+ * is NOT a rejection).
726
+ * @param {string} name the upstream tool name
727
+ * @param {any} args structured arguments (forwarded as the `arguments` field)
728
+ * @param {object} [meta] the caller's request-scoped _meta (progressToken et al., protocol keys
729
+ * already stripped by the server). Rides the forward: without it the upstream
730
+ * never emits progress for the call, so a long funnel tool cannot keep its
731
+ * own call alive.
732
+ * @returns {Promise<{content:any[], isError:boolean}>}
733
+ */
734
+ async callTool(name, args, meta) {
735
+ if (typeof name !== 'string' || name.length === 0) {
736
+ throw new Error('McpClient.callTool: "name" (non-empty string) is required');
737
+ }
738
+ let params = { name, arguments: args == null ? {} : args };
739
+ if (meta && typeof meta === 'object' && !Array.isArray(meta) && Object.keys(meta).length) {
740
+ params._meta = { ...meta };
741
+ }
742
+ params = this._translateLogLevel(params); // era-keyed logLevel (1.4)
743
+ // modern upstream: the per-request protocol trio always wins over caller keys
744
+ if (this._modern) params._meta = { ...(params._meta || {}), ...this._modernMeta() };
745
+ const result = await this._request('tools/call', params);
746
+ const r = result && typeof result === 'object' ? result : {};
747
+ const out = {
748
+ content: Array.isArray(r.content) ? r.content : [],
749
+ isError: r.isError === true,
750
+ };
751
+ // structuredContent rides along verbatim - dropping it silently stripped modern upstreams'
752
+ // structured results on the curated-direct path while the raw wrap path kept them
753
+ if (r.structuredContent !== undefined) out.structuredContent = r.structuredContent;
754
+ // result _meta rides along too (3.10, result side): the wrap relays it verbatim (the
755
+ // upstream's identity IS the presented identity there); the funnel shaping sites strip the
756
+ // protocol-identity keys before forwarding (they own their own identity).
757
+ if (r._meta !== undefined) out._meta = r._meta;
758
+ return out;
759
+ }
760
+
761
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
762
+ // close(): idempotent teardown. End child stdin so a well-behaved server exits on
763
+ // EOF; if it is still alive, tree-kill it (win32 taskkill /T /F to reap the whole
764
+ // process tree; else child.kill()). Reject every in-flight request so nothing leaks.
765
+ // NEVER throws - it is called from error paths.
766
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
767
+ close() {
768
+ if (this._closed) return;
769
+ this._closed = true;
770
+ this._connected = false;
771
+
772
+ const child = this._child;
773
+
774
+ // Reject any in-flight requests first (so they don't fire their timers after teardown).
775
+ this._rejectAllWaiters(new Error('McpClient: connection closed'));
776
+
777
+ if (!child) return;
778
+
779
+ // Best-effort: end stdin to let the server exit on EOF.
780
+ try {
781
+ if (child.stdin && !child.stdin.destroyed) child.stdin.end();
782
+ } catch (_e) {
783
+ /* ignore */
784
+ }
785
+
786
+ // If still alive, tree-kill so no descendant is orphaned (zombie guard).
787
+ this._treeKill(child);
788
+
789
+ this._child = null;
790
+ }
791
+
792
+ /**
793
+ * Tree-kill a child so no descendant is orphaned (win32 taskkill /T /F reaps the whole
794
+ * process tree; else child.kill()). NEVER throws - called from close() and from the
795
+ * read-buffer overflow path, both of which must not raise out of an event handler.
796
+ * @param {import('node:child_process').ChildProcess|null} child
797
+ */
798
+ _treeKill(child) {
799
+ try {
800
+ if (!child) return;
801
+ const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
802
+ if (alive && typeof child.pid === 'number') {
803
+ if (process.platform === 'win32') {
804
+ // /T = whole tree, /F = force. spawnSync so it completes synchronously here.
805
+ try {
806
+ childProcess.spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
807
+ windowsHide: true,
808
+ });
809
+ } catch (_e) {
810
+ // Fall back to a plain kill if taskkill is unavailable.
811
+ try { child.kill(); } catch (_e2) { /* ignore */ }
812
+ }
813
+ } else {
814
+ child.kill();
815
+ }
816
+ } else if (typeof child.kill === 'function') {
817
+ // Already exited or a fake child - a no-op kill is harmless.
818
+ try { child.kill(); } catch (_e) { /* ignore */ }
819
+ }
820
+ } catch (_e) {
821
+ /* never throws - see contract above */
822
+ }
823
+ }
824
+
825
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
826
+ // Internals.
827
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
828
+
829
+ /** Attach the stdout reader, stderr capture, and exit/error handlers to the child. */
830
+ _wireChild() {
831
+ const child = this._child;
832
+
833
+ // Every handler below answers only while `child` is STILL this._child. A child that has
834
+ // exited (or been replaced by an in-place respawn) can still drain buffered stdout/stderr and
835
+ // fire 'exit'/'error' afterwards - Node emits 'exit' before the stdio streams are done - and
836
+ // those late events would otherwise land in the NEW child's read state or reject its waiters.
837
+ child.stdout.on('data', (chunk) => { if (this._child === child) this._onData(chunk); });
838
+ child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
839
+
840
+ if (child.stderr) {
841
+ child.stderr.on('data', (d) => {
842
+ if (this._child !== child) return;
843
+ this._stderr += d.toString('utf8');
844
+ // Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
845
+ if (this._stderr.length > 64 * 1024) {
846
+ this._stderr = this._stderr.slice(-32 * 1024);
847
+ }
848
+ });
849
+ child.stderr.on('error', () => { /* ignore */ });
850
+ }
851
+
852
+ // Either of these means the child is gone - fail every in-flight request so nothing hangs, and
853
+ // NULL this._child so a later connect() on the same instance respawns instead of writing to a
854
+ // dead stdin (connect() reuses `this._child` when set - a probeDiscover() against a legacy
855
+ // server that dies on the unknown method would otherwise corrupt a follow-up connect()). Only
856
+ // null it if it is still THIS child (a fresh respawn may have replaced it).
857
+ child.on('exit', (code, signal) => {
858
+ if (this._child !== child) return; // a stale child's exit: already handled or superseded
859
+ this._connected = false;
860
+ this._child = null;
861
+ this._rejectAllWaiters(
862
+ new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
863
+ );
864
+ this._fireOnClose(`exited (code=${code}, signal=${signal})`);
865
+ });
866
+ child.on('error', (err) => {
867
+ // Spawn-level failure (e.g. ENOENT) - surfaces here, not as a throw from spawn().
868
+ if (this._child !== child) return; // a stale child's error: already handled or superseded
869
+ this._connected = false;
870
+ this._child = null;
871
+ const failed = new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`);
872
+ // Keep the code: the aggregator's reconnect ceiling classifies permanent failures by it
873
+ // (a re-wrap without it made a missing command look like any other death).
874
+ if (err && typeof err.code === 'string') failed.code = err.code;
875
+ this._rejectAllWaiters(failed);
876
+ this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
877
+ });
878
+ }
879
+
880
+ /**
881
+ * Fire the onClose callback ONCE, and ONLY for an UNEXPECTED death - never when close() was
882
+ * called deliberately (close() sets _closed=true before tree-killing the child, so the resulting
883
+ * 'exit' is suppressed here). This is the signal the aggregator uses to schedule a background
884
+ * reconnect. Wrapped - a throwing callback must not escape an event handler.
885
+ * @param {string} reason
886
+ */
887
+ _fireOnClose(reason) {
888
+ // ONLY an established connection dying counts. A connect-time failure (child exits during the
889
+ // handshake, ENOENT, etc.) must NOT fire onClose: it surfaces as connect()'s thrown error, and
890
+ // the aggregator's reconnect-retry path owns the backoff. Firing here would let a death inside a
891
+ // reconnect attempt reset the backoff counter to 0 -> an unbounded ~1s respawn storm.
892
+ if (!this._everConnected) return;
893
+ if (this._closed) return; // deliberate close() - not an unexpected death
894
+ if (this._onCloseFired) return; // exit AND error can both fire; notify once
895
+ this._onCloseFired = true;
896
+ if (typeof this._onClose === 'function') {
897
+ try { this._onClose(reason); } catch (_e) { this._logErr('onClose callback threw (ignored)'); }
898
+ }
899
+ }
900
+
901
+ /** Reject + clear every pending waiter (used on close / exit / error). */
902
+ _rejectAllWaiters(err) {
903
+ if (this._waiters.size === 0) return;
904
+ for (const [, w] of this._waiters) {
905
+ try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
906
+ try { w.reject(err); } catch (_e) { /* ignore */ }
907
+ }
908
+ this._waiters.clear();
909
+ }
910
+
911
+ /**
912
+ * Send a JSON-RPC request with an id and await its matching response. Rejects on
913
+ * timeout (requestTimeoutMs) after deleting the waiter so it can't leak. Resolves
914
+ * with the response's `result` (or rejects with its `error`).
915
+ */
916
+ _request(method, params, out) {
917
+ if (this._closed) {
918
+ return Promise.reject(new Error(`McpClient: request("${method}") after close()`));
919
+ }
920
+ if (!this._child || !this._child.stdin) {
921
+ return Promise.reject(new Error(`McpClient: request("${method}") with no live child`));
922
+ }
923
+
924
+ const id = this._nextId++;
925
+ // Report the issued rpc id synchronously (before any I/O) - a wrap needs the client-id ->
926
+ // upstream-id mapping LIVE to translate notifications/cancelled for in-flight forwards.
927
+ if (out && typeof out === 'object') out.rpcId = id;
928
+ const payload = { jsonrpc: JSONRPC, id, method };
929
+ if (params !== undefined) payload.params = params;
930
+
931
+ // Method-class window: payload methods wait toolTimeoutMs, control-plane keeps the short
932
+ // dead-upstream window. The caller's progressToken (rides params._meta on
933
+ // a wrap forward) is remembered so a matching progress beat can re-arm the window.
934
+ const win = PAYLOAD_METHODS.has(method) ? this._toolTimeoutMs : this._requestTimeoutMs;
935
+ const ptok = params && params._meta && typeof params._meta === 'object' &&
936
+ params._meta.progressToken !== undefined ? params._meta.progressToken : undefined;
937
+
938
+ return new Promise((resolve, reject) => {
939
+ const timer = setTimeout(() => {
940
+ this._waiters.delete(id);
941
+ reject(new Error(
942
+ `McpClient: timeout after ${win}ms waiting for "${method}". stderr so far:\n${this._stderr}`
943
+ ));
944
+ }, win);
945
+ // Don't let a pending timer keep the event loop (and the test process) alive.
946
+ if (timer && typeof timer.unref === 'function') timer.unref();
947
+
948
+ this._waiters.set(id, { resolve, reject, timer, method, win, progressToken: ptok });
949
+
950
+ let framed;
951
+ try {
952
+ framed = frameRequest(payload);
953
+ } catch (err) {
954
+ this._waiters.delete(id);
955
+ clearTimeout(timer);
956
+ reject(new Error(`McpClient: failed to serialise "${method}": ${(err && err.message) || String(err)}`));
957
+ return;
958
+ }
959
+
960
+ try {
961
+ this._child.stdin.write(framed, (err) => {
962
+ if (err) {
963
+ this._waiters.delete(id);
964
+ clearTimeout(timer);
965
+ reject(new Error(`McpClient: stdin write failed for "${method}": ${(err && err.message) || String(err)}`));
966
+ }
967
+ });
968
+ } catch (err) {
969
+ this._waiters.delete(id);
970
+ clearTimeout(timer);
971
+ reject(new Error(`McpClient: stdin write threw for "${method}": ${(err && err.message) || String(err)}`));
972
+ }
973
+ });
974
+ }
975
+
976
+ /** Fire-and-forget JSON-RPC notification (no id, no reply expected). Never throws. */
977
+ _notify(method, params) {
978
+ if (this._closed || !this._child || !this._child.stdin) return;
979
+ const payload = { jsonrpc: JSONRPC, method };
980
+ if (params !== undefined) payload.params = params;
981
+ let framed;
982
+ try {
983
+ framed = frameRequest(payload);
984
+ } catch (err) {
985
+ this._logErr('failed to serialise notification', method + ':', (err && err.message) || String(err));
986
+ return;
987
+ }
988
+ try {
989
+ this._child.stdin.write(framed);
990
+ } catch (err) {
991
+ this._logErr('notification write failed', method + ':', (err && err.message) || String(err));
992
+ }
993
+ }
994
+
995
+ /** Buffer incoming stdout bytes and drain every complete message (dual framing). */
996
+ _onData(chunk) {
997
+ this._buf = Buffer.concat([this._buf, chunk]);
998
+ this._drain();
999
+ if (this._buf.length > MAX_MESSAGE_BYTES) {
1000
+ // Past the cap with no complete frame there is no resync point - drop the connection.
1001
+ // The tree-kill surfaces as an unexpected exit: waiters reject and onClose lets the
1002
+ // owner schedule a reconnect (a deliberate close() would suppress that signal and
1003
+ // permanently retire this client).
1004
+ this._logErr(`buffered ${this._buf.length} bytes with no complete message (cap ${MAX_MESSAGE_BYTES}); dropping the connection`);
1005
+ this._buf = Buffer.alloc(0);
1006
+ this._treeKill(this._child);
1007
+ }
1008
+ }
1009
+
1010
+ /**
1011
+ * Drain as many complete messages as are buffered. Header (Content-Length) framing
1012
+ * is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
1013
+ * JSON. Mirrors server.js's dual-drain so the client reads either wire format.
1014
+ * A header frame that is only PARTLY here ('wait') stops the drain - handing the
1015
+ * half-arrived header to line framing would eat it and orphan the body (the frame
1016
+ * was lost and the caller saw only a timeout; chunk boundaries are the transport's
1017
+ * choice, so any CL-framing upstream could hit it under load).
1018
+ */
1019
+ _drain() {
1020
+ for (;;) {
1021
+ if (this._skipRemaining > 0) {
1022
+ // A refused over-cap message: discard exactly its declared bytes as they arrive so the
1023
+ // pipe stays in frame for everything after it.
1024
+ const eat = Math.min(this._skipRemaining, this._buf.length);
1025
+ this._buf = this._buf.slice(eat);
1026
+ this._skipRemaining -= eat;
1027
+ if (this._skipRemaining > 0) return; // the rest of the refused body is still in flight
1028
+ }
1029
+ const h = this._tryHeaderFramed();
1030
+ if (h === true) continue;
1031
+ if (h === 'wait') break; // an incomplete header frame - wait for more bytes
1032
+ if (this._tryLineFramed()) continue;
1033
+ break;
1034
+ }
1035
+ }
1036
+
1037
+ /** True when the buffer BEGINS with a framing header whose block terminator hasn't arrived. */
1038
+ _headerInFlight() {
1039
+ return /^content-(length|type):/i.test(this._buf.slice(0, 16).toString('utf8'));
1040
+ }
1041
+
1042
+ /**
1043
+ * Pull one Content-Length-framed message off the front of the buffer.
1044
+ * Returns true (consumed one), false (not header framing - try line framing), or
1045
+ * 'wait' (a header frame is mid-flight - stop draining until more bytes arrive).
1046
+ */
1047
+ _tryHeaderFramed() {
1048
+ const headerEnd = this._buf.indexOf('\r\n\r\n');
1049
+ if (headerEnd === -1) {
1050
+ // No complete header block - but a buffer that STARTS with a header line is a header
1051
+ // frame whose terminator is still in flight, not line-framed junk.
1052
+ return this._headerInFlight() ? 'wait' : false;
1053
+ }
1054
+ const header = this._buf.slice(0, headerEnd).toString('utf8');
1055
+ const m = /Content-Length:\s*(\d+)/i.exec(header);
1056
+ if (!m) return false; // a \r\n\r\n that isn't a Content-Length header -> not this framing
1057
+ const len = parseInt(m[1], 10);
1058
+ const bodyStart = headerEnd + 4;
1059
+ if (len > MAX_MESSAGE_BYTES) {
1060
+ // Refuse the message, keep the connection: consume the header and let _drain discard
1061
+ // exactly the declared bytes as they arrive. The reply's waiter times out with the
1062
+ // standard window error; everything after the refused body still flows.
1063
+ this._logErr(`upstream declared a ${len}-byte message (cap ${MAX_MESSAGE_BYTES}); skipping it`);
1064
+ this._buf = this._buf.slice(bodyStart);
1065
+ this._skipRemaining = len;
1066
+ return true;
1067
+ }
1068
+ if (this._buf.length < bodyStart + len) return 'wait'; // body still in flight - hold the drain
1069
+ const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
1070
+ this._buf = this._buf.slice(bodyStart + len);
1071
+ this._parseAndDispatch(body);
1072
+ return true;
1073
+ }
1074
+
1075
+ /** Pull one newline-delimited JSON message off the front of the buffer. */
1076
+ _tryLineFramed() {
1077
+ const nl = this._buf.indexOf(0x0a); // '\n'
1078
+ if (nl === -1) return false;
1079
+ const line = this._buf.slice(0, nl).toString('utf8').trim();
1080
+ this._buf = this._buf.slice(nl + 1);
1081
+ if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
1082
+ // A header line at the FRONT of the buffer never reaches this path (header framing holds
1083
+ // the drain with 'wait'); any non-JSON line here is a framing artifact - ignore it rather
1084
+ // than erroring.
1085
+ if (line[0] !== '{' && line[0] !== '[') return true;
1086
+ this._parseAndDispatch(line);
1087
+ return true;
1088
+ }
1089
+
1090
+ /** Parse one JSON body and route it to its waiter by id. Bad JSON / unmatched ids are ignored. */
1091
+ _parseAndDispatch(text) {
1092
+ let msg;
1093
+ try {
1094
+ msg = JSON.parse(text);
1095
+ } catch (_e) {
1096
+ this._logErr('dropping unparseable message from upstream');
1097
+ return;
1098
+ }
1099
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
1100
+
1101
+ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
1102
+ if (!hasId) {
1103
+ // A progress beat for an in-flight request proves the upstream is alive and WORKING that
1104
+ // request - re-arm its window so a slow-but-progressing call never dies to the fixed
1105
+ // timeout. The notification still relays below, untouched.
1106
+ if (msg.method === 'notifications/progress' && msg.params &&
1107
+ msg.params.progressToken !== undefined) {
1108
+ this._bumpProgressWaiter(msg.params.progressToken);
1109
+ }
1110
+ // Server-initiated NOTIFICATION (method, no id) - surface it so the aggregator can bridge
1111
+ // change-notifications to a modern subscriptions/listen stream. NEVER throws.
1112
+ if (typeof msg.method === 'string' && typeof this.onNotification === 'function') {
1113
+ try { this.onNotification(msg); } catch (_e) { this._logErr('onNotification threw (ignored)'); }
1114
+ }
1115
+ return;
1116
+ }
1117
+
1118
+ if (typeof msg.method === 'string') {
1119
+ // SERVER->CLIENT REQUEST (id + method): the upstream is asking ITS client something -
1120
+ // elicitation/create, sampling/createMessage, roots/list. Dropping one would leave a
1121
+ // conformant upstream holding an open request forever. Surface to the owner;
1122
+ // unhandled -> answer -32601 (the honest "this client cannot do that").
1123
+ if (typeof this.onServerRequest === 'function') {
1124
+ try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
1125
+ }
1126
+ this.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method);
1127
+ return;
1128
+ }
1129
+
1130
+ const w = this._waiters.get(msg.id);
1131
+ if (!w) return; // response to an id we no longer await (timed out / closed) - drop it
1132
+ this._waiters.delete(msg.id);
1133
+ try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
1134
+
1135
+ if (msg.error) {
1136
+ const e = msg.error;
1137
+ const message = e && e.message ? e.message : 'JSON-RPC error';
1138
+ const code = e && typeof e.code === 'number' ? ` (code ${e.code})` : '';
1139
+ const err = new Error(`McpClient: upstream error for "${w.method}": ${message}${code}`);
1140
+ // Preserve the VERBATIM JSON-RPC error object - a wrap must relay the upstream's own error
1141
+ // (code/message/data) untouched; the stringified message above is for logs and lean paths.
1142
+ err.rpcError = { code: e && typeof e.code === 'number' ? e.code : -32603, message };
1143
+ if (e && e.data !== undefined) err.rpcError.data = e.data;
1144
+ w.reject(err);
1145
+ return;
1146
+ }
1147
+ w.resolve(msg.result);
1148
+ }
1149
+ }
1150
+
1151
+ /**
1152
+ * Frame a JSON-RPC message for the MCP stdio transport: one compact newline-delimited
1153
+ * JSON message. NOT Content-Length framing - see the field note inside.
1154
+ * @param {object} payload
1155
+ * @returns {string}
1156
+ */
1157
+ function frameRequest(payload) {
1158
+ // MCP stdio transport framing: one compact JSON-RPC message per line, newline-delimited - NOT
1159
+ // LSP-style Content-Length. JSON.stringify emits no literal newlines, so a single trailing "\n"
1160
+ // cleanly delimits the message. This is what real SDK-based MCP servers read on stdin; writing
1161
+ // Content-Length here is what silently broke every real upstream (the read side still tolerates
1162
+ // both framings for robustness against the occasional Content-Length server).
1163
+ return JSON.stringify(payload) + '\n';
1164
+ }
1165
+
1166
+ module.exports = {
1167
+ McpClient,
1168
+ // exported for unit tests / reuse
1169
+ frameRequest,
1170
+ winLaunch,
1171
+ commandMissing,
1172
+ PROTOCOL_VERSION,
1173
+ CLIENT_INFO,
1174
+ };