toolfunnel 0.6.0 → 0.7.1

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 (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  7. package/docs/packaging.md +115 -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 -31
  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 +380 -350
  14. package/package.json +3 -1
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,907 +1,1070 @@
1
- 'use strict';
2
-
3
- /**
4
- * mcp-client.js - the MCP CLIENT (architecture notes §1, §7; Phase 2 aggregator).
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 Phase 2
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
- class McpClient {
94
- /**
95
- * @param {object} opts
96
- * @param {string} [opts.id] caller-chosen id for this upstream (diagnostics only)
97
- * @param {string} opts.command executable to spawn (e.g. process.execPath)
98
- * @param {string[]} [opts.args] argv for the child (e.g. [serverPath])
99
- * @param {object} [opts.env] extra env merged over process.env for the child
100
- * @param {string} [opts.cwd] child working directory
101
- * @param {Function} [opts.spawnImpl] injectable spawn (default node:child_process.spawn)
102
- * @param {number} [opts.requestTimeoutMs] control-plane timeout in ms (default 10000) -
103
- * handshake/list/discover/ping; the dead-upstream detector
104
- * @param {number} [opts.toolTimeoutMs] PAYLOAD timeout in ms (default 120000) for
105
- * tools/call | prompts/get | resources/read; a
106
- * token-matched progress beat re-arms it
107
- * @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
108
- * while not deliberately close()d). Lets the owner
109
- * (the aggregator) schedule a background reconnect.
110
- */
111
- constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs, toolTimeoutMs, onClose, forceLegacy = false, modernOnly = false, onNotification, clientInfo } = {}) {
112
- if (typeof command !== 'string' || command.length === 0) {
113
- throw new Error('McpClient: "command" (non-empty string) is required');
114
- }
115
- this.id = typeof id === 'string' && id.length ? id : 'mcp-client';
116
- // clientInfo: the identity this client PRESENTS to the upstream (legacy initialize and the
117
- // modern _meta trio). Defaults to the built-in - absent the opt, wire behaviour is byte-identical
118
- // to before. The wrap's identity mirroring hands the REAL downstream client's info through here.
119
- // The FULL object is carried (title etc.), not a name/version whitelist - the wrap's serverInfo
120
- // direction proved a whitelist silently eats spec fields.
121
- this._clientInfo =
122
- clientInfo && typeof clientInfo === 'object' &&
123
- typeof clientInfo.name === 'string' && clientInfo.name.length
124
- ? Object.freeze(Object.assign({}, clientInfo, {
125
- version: typeof clientInfo.version === 'string' ? clientInfo.version : '0.0.0',
126
- }))
127
- : CLIENT_INFO;
128
- this._command = command;
129
- this._args = Array.isArray(args) ? args.slice() : [];
130
- this._env = env && typeof env === 'object' ? env : {};
131
- this._cwd = cwd;
132
- this._spawn = typeof spawnImpl === 'function' ? spawnImpl : childProcess.spawn;
133
- this._requestTimeoutMs =
134
- Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 10000;
135
- // An EXPLICITLY configured control window also governs the era probe in connect(): the
136
- // operator opted into slower connects for a slow-boot upstream, and the probe must not
137
- // quietly undo that (a slow-boot modern-only server could otherwise never attach).
138
- this._explicitRequestTimeout = Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0;
139
- // The PAYLOAD window (tools/call | prompts/get | resources/read): a tool legitimately runs
140
- // as long as the tool runs - the fixed 10 s window hard-killed every wrapped tool slower
141
- // than that and silently dropped the upstream's eventual result. Per-upstream
142
- // `timeoutMs` config lands here; a token-matched progress beat re-arms the window.
143
- this._toolTimeoutMs =
144
- Number.isFinite(toolTimeoutMs) && toolTimeoutMs > 0 ? toolTimeoutMs : 120000;
145
- this._onClose = typeof onClose === 'function' ? onClose : null;
146
- // forceLegacy (legacyPin): skip the modern server/discover probe and speak legacy unconditionally.
147
- this._forceLegacy = forceLegacy === true;
148
- // modernOnly: the MIRROR of legacyPin - refuse the legacy fallback entirely. A modernOnly
149
- // upstream that does not answer server/discover with the modern protocol FAILS the connect
150
- // with a clear error instead of silently negotiating down (era-policy switches, 2026-07-18).
151
- this._modernOnly = modernOnly === true;
152
- // onNotification: called with each server-initiated NOTIFICATION (a message with a method and
153
- // NO id) - e.g. notifications/resources/updated, .../resources/list_changed, .../prompts/
154
- // list_changed, .../tools/list_changed. Lets the aggregator bridge upstream change-notifications
155
- // to a modern client's subscriptions/listen stream. Settable post-construction too.
156
- this.onNotification = typeof onNotification === 'function' ? onNotification : null;
157
- // onServerRequest: called with each SERVER-INITIATED REQUEST (a message with a method AND an
158
- // id - elicitation/create, sampling/createMessage, roots/list). The owner answers it via
159
- // respondToServer/respondToServerError. Unset -> every such request is answered -32601, so a
160
- // conformant upstream is never left holding an unanswered request (Bridge B infrastructure).
161
- this.onServerRequest = null;
162
- this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
163
- this._everConnected = false; // true once the handshake completed - onClose fires ONLY for the
164
- // death of an ESTABLISHED connection, never a connect-time failure
165
-
166
- // Transport state.
167
- this._child = null;
168
- this._connected = false;
169
- this._closed = false;
170
- this._buf = Buffer.alloc(0);
171
- this._stderr = '';
172
- this._nextId = 1;
173
- /** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
174
- this._waiters = new Map();
175
-
176
- // The initialize result, captured for connect()'s return value.
177
- this._initializeResult = null;
178
- }
179
-
180
- /** @returns {boolean} true once the initialize handshake has completed and not yet closed. */
181
- get connected() {
182
- return this._connected === true && this._closed === false;
183
- }
184
-
185
- /** @returns {object|null} the upstream's raw `initialize` result ({ protocolVersion, serverInfo,
186
- * capabilities, instructions }) captured at connect. Used by the passthrough WRAP to present the
187
- * wrapped server's OWN identity (a wrapped MCP must be indistinguishable from the real thing). */
188
- get initializeResult() {
189
- return this._initializeResult;
190
- }
191
-
192
- /**
193
- * Raw JSON-RPC passthrough for the WRAP: forward a method to the upstream verbatim and return its
194
- * result. Used to relay non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
195
- * server behaves exactly as it would connected directly. Tool calls do NOT use this - they go
196
- * through the gated forward path. Rejects on timeout / transport failure (same as callTool).
197
- * @param {string} method
198
- * @param {any} [params]
199
- * @returns {Promise<any>} the upstream's raw result
200
- */
201
- async request(method, params, out) {
202
- if (typeof method !== 'string' || method.length === 0) {
203
- throw new Error('McpClient.request: "method" (non-empty string) is required');
204
- }
205
- let p = params == null ? {} : params;
206
- // Modern upstream: ensure the per-request `_meta` protocol trio is present. MERGE with any
207
- // caller-supplied _meta (progressToken, trace keys survive a wrap forward) - the trio wins on
208
- // collision; a partial caller _meta must never reach a strict modern upstream trio-less.
209
- if (this._modern && p && typeof p === 'object' && !Array.isArray(p)) {
210
- const callerMeta = (p._meta && typeof p._meta === 'object' && !Array.isArray(p._meta)) ? p._meta : null;
211
- p = Object.assign({}, p, { _meta: Object.assign({}, callerMeta, this._modernMeta()) });
212
- }
213
- return await this._request(method, p, out);
214
- }
215
-
216
- /**
217
- * Send a NOTIFICATION to the upstream - no id, no waiter, no reply. Used by a wrap to relay
218
- * client notifications (notifications/cancelled, notifications/progress ...) that a
219
- * directly-connected server would receive. Fire-and-forget; NEVER throws.
220
- * @param {string} method
221
- * @param {any} params
222
- */
223
- notify(method, params) {
224
- try {
225
- if (this._closed || !this._child || !this._child.stdin) return;
226
- if (typeof method !== 'string' || method.length === 0) return;
227
- const payload = { jsonrpc: JSONRPC, method };
228
- if (params !== undefined) payload.params = params;
229
- this._child.stdin.write(frameRequest(payload));
230
- } catch (_e) { /* fire-and-forget */ }
231
- }
232
-
233
- /**
234
- * Answer a SERVER-INITIATED request (Bridge B): write a JSON-RPC RESPONSE carrying the
235
- * upstream's own request id. Fire-and-forget; NEVER throws.
236
- * @param {number|string} id the id the UPSTREAM used on its request
237
- * @param {any} result the result payload (e.g. an ElicitResult { action, content })
238
- */
239
- respondToServer(id, result) {
240
- try {
241
- if (this._closed || !this._child || !this._child.stdin) return;
242
- if (id === undefined || id === null) return;
243
- this._child.stdin.write(frameRequest({ jsonrpc: JSONRPC, id, result: result === undefined ? {} : result }));
244
- } catch (_e) { /* fire-and-forget */ }
245
- }
246
-
247
- /**
248
- * Extend the timeout of an in-flight request (Bridge B): a mid-call elicitation proves the
249
- * upstream is alive and deliberately holding the call open while a HUMAN answers - the normal
250
- * dead-upstream timeout must not kill the suspension. Re-arms the waiter's timer; returns
251
- * false if the request already settled. NEVER throws.
252
- * @param {number|string} id the rpc id of the in-flight request
253
- * @param {number} ms the new timeout window from now
254
- */
255
- extendRequestTimeout(id, ms) {
256
- try {
257
- const w = this._waiters.get(id);
258
- if (!w) return false;
259
- clearTimeout(w.timer);
260
- // The extension becomes the waiter's WINDOW, not just its current timer: a token-matched
261
- // progress beat re-arms to w.win, so leaving the old (short) win in place let a single
262
- // beat during a Bridge-B suspension collapse the human-answer hold back to toolTimeoutMs
263
- //Progress during a hold now re-arms to the hold.
264
- const win = Number.isFinite(ms) && ms > 0 ? ms : this._requestTimeoutMs;
265
- w.win = win;
266
- w.timer = setTimeout(() => {
267
- this._waiters.delete(id);
268
- w.reject(new Error(`McpClient: request("${w.method}") timed out (suspended-call window elapsed)`));
269
- }, win);
270
- if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
271
- return true;
272
- } catch (_e) {
273
- return false;
274
- }
275
- }
276
-
277
- /**
278
- * Re-arm the timeout of the in-flight request matching this progressToken:
279
- * a progress beat is proof of life for THAT request. Token-matched only - a dead upstream
280
- * still times out on its full window. NEVER throws.
281
- * @param {number|string} token the progressToken from a notifications/progress params
282
- */
283
- _bumpProgressWaiter(token) {
284
- try {
285
- for (const [id, w] of this._waiters) {
286
- if (w.progressToken === undefined || w.progressToken !== token) continue;
287
- clearTimeout(w.timer);
288
- const win = Number.isFinite(w.win) && w.win > 0 ? w.win : this._toolTimeoutMs;
289
- w.timer = setTimeout(() => {
290
- this._waiters.delete(id);
291
- w.reject(new Error(
292
- `McpClient: timeout after ${win}ms without progress waiting for "${w.method}". stderr so far:\n${this._stderr}`
293
- ));
294
- }, win);
295
- if (w.timer && typeof w.timer.unref === 'function') w.timer.unref();
296
- return;
297
- }
298
- } catch (_e) { /* re-arm is best-effort; the original timer stands */ }
299
- }
300
-
301
- /** Answer a server-initiated request with a JSON-RPC ERROR. Fire-and-forget; NEVER throws. */
302
- respondToServerError(id, code, message) {
303
- try {
304
- if (this._closed || !this._child || !this._child.stdin) return;
305
- if (id === undefined || id === null) return;
306
- this._child.stdin.write(frameRequest({
307
- jsonrpc: JSONRPC, id,
308
- error: { code: typeof code === 'number' ? code : -32603, message: String(message || 'error') },
309
- }));
310
- } catch (_e) { /* fire-and-forget */ }
311
- }
312
-
313
- // ── Diagnostics -> never throw. ───────────────────────────────────────────────────────────────
314
- _logErr(...parts) {
315
- try {
316
- process.stderr.write(`[mcp-client:${this.id}] ${parts.join(' ')}\n`);
317
- } catch (_e) {
318
- /* never let logging throw */
319
- }
320
- }
321
-
322
- // ─────────────────────────────────────────────────────────────────────────────────────────────
323
- // connect(): spawn the child, perform the MCP initialize handshake, send the
324
- // notifications/initialized notification, set connected. On ANY failure (spawn
325
- // error, initialize timeout/error) close() the child and reject - never leave a zombie.
326
- // ─────────────────────────────────────────────────────────────────────────────────────────────
327
- async connect() {
328
- if (this._closed) throw new Error('McpClient: connect() called after close()');
329
- if (this._connected) return this._initializeResult;
330
-
331
- // 1) Spawn the child (shared with probeDiscover - a probe may already have spawned it).
332
- if (!this._child) this._spawnChild();
333
-
334
- // 2) DUAL-ERA negotiation (the spec's dual-era CLIENT rule: try MODERN first). Probe
335
- // `server/discover`: a modern or dual-era upstream answers with a DiscoverResult that lists
336
- // the modern version; a legacy-only upstream rejects it (method-not-found) or drops. On a
337
- // valid modern answer we speak MODERN (no initialize; per-request `_meta` on every call).
338
- // Otherwise (or when legacyPin FORCES legacy) we fall back to the LEGACY initialize handshake
339
- // (byte-for-byte the prior behaviour).
340
- let discovered = null;
341
- if (!this._forceLegacy) {
342
- let probe = null;
343
- // The probe gets a SHORT timeout of its own: a legacy server that silently DROPS unknown
344
- // methods (instead of answering -32601) would otherwise stall every connect by the full
345
- // request timeout - serialised across upstreams at startup. An EXPLICIT per-upstream
346
- // requestTimeoutMs overrides the clamp - a slow-boot modern-only server needs the raised
347
- // window HERE, and the bin-side era probe already honours it (keep the two symmetric).
348
- const fullTimeout = this._requestTimeoutMs;
349
- this._requestTimeoutMs = this._explicitRequestTimeout
350
- ? fullTimeout : Math.min(3000, fullTimeout || 3000);
351
- try {
352
- probe = await this._request('server/discover', { _meta: this._modernMeta() });
353
- } catch (_probeErr) {
354
- probe = null; // legacy upstream (method-not-found) - fall through to initialize
355
- if (this._closed) throw new Error('McpClient: connect() aborted (closed during discover probe)');
356
- if (!this._child) this._spawnChild(); // a fragile legacy server may have died on the probe
357
- } finally {
358
- this._requestTimeoutMs = fullTimeout;
359
- }
360
- // Only treat the reply as MODERN if it is a real DiscoverResult advertising the modern
361
- // version - a permissive legacy server that answers unknown methods with `{}`/an empty object
362
- // must NOT be misdetected as modern.
363
- if (probe && typeof probe === 'object' && Array.isArray(probe.supportedVersions) &&
364
- probe.supportedVersions.includes(MODERN_PROTOCOL_VERSION)) {
365
- discovered = probe;
366
- } else if (probe && this._child) {
367
- // Answered, but not a modern DiscoverResult -> legacy. The child is alive (it replied), so the
368
- // legacy initialize below runs on the same child.
369
- discovered = null;
370
- }
371
- }
372
-
373
- // modernOnly refuses the legacy fallback: no valid modern DiscoverResult means no connection,
374
- // with an error that names the policy - never a silent downgrade.
375
- if (!discovered && this._modernOnly) {
376
- this.close();
377
- 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`);
378
- }
379
-
380
- if (discovered) {
381
- // MODERN upstream - identity from discover; NO initialize / notifications/initialized.
382
- this._modern = true;
383
- this._initializeResult = this._identityFromDiscover(discovered);
384
- } else {
385
- // LEGACY upstream - the initialize handshake. A spawn 'error' rejects via the waiter path; a
386
- // silent server hits the request timeout. Either way close() + a clear error.
387
- try {
388
- const result = await this._request('initialize', {
389
- protocolVersion: PROTOCOL_VERSION,
390
- clientInfo: this._clientInfo,
391
- // elicitation: real SDK-based servers GATE ctx.elicit() on the client declaring this
392
- // capability - without it they answer "Method not found" and never elicit at all
393
- // (wild-scout finding, 2026-07-17). We honestly handle every elicitation now: the
394
- // Bridge B MRTR path for modern callers, a clean decline otherwise.
395
- capabilities: { elicitation: {} },
396
- });
397
- this._initializeResult = result;
398
- } catch (err) {
399
- this.close();
400
- throw new Error(`McpClient: initialize failed: ${(err && err.message) || String(err)}`);
401
- }
402
- // Tell the server the handshake is complete (notification - no id, no reply).
403
- this._notify('notifications/initialized', {});
404
- this._modern = false;
405
- }
406
-
407
- this._connected = true;
408
- this._everConnected = true; // from here on, an exit/error is an ESTABLISHED-connection death
409
- return this._initializeResult;
410
- }
411
-
412
- /** @returns {'modern'|'legacy'} the era negotiated at connect (default 'legacy' before connect). */
413
- get era() {
414
- return this._modern === true ? 'modern' : 'legacy';
415
- }
416
-
417
- /** The per-request `_meta` a modern client MUST send on every request (stdio: no headers). */
418
- _modernMeta() {
419
- return {
420
- 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
421
- 'io.modelcontextprotocol/clientCapabilities': {},
422
- 'io.modelcontextprotocol/clientInfo': this._clientInfo,
423
- };
424
- }
425
-
426
- /** @returns {{name:string, version:string}} the identity this client presents upstream. */
427
- get clientInfo() {
428
- return this._clientInfo;
429
- }
430
-
431
- /**
432
- * Normalise a `server/discover` result into the common identity shape wrappedIdentity + connect()
433
- * consumers expect ({ protocolVersion, serverInfo, capabilities, instructions }) - so a MODERN
434
- * upstream's identity reads the same as a legacy `initialize` result. serverInfo lives in the
435
- * discover result's `_meta['io.modelcontextprotocol/serverInfo']`.
436
- */
437
- _identityFromDiscover(disc) {
438
- const d = disc && typeof disc === 'object' ? disc : {};
439
- const meta = d._meta && typeof d._meta === 'object' ? d._meta : {};
440
- const si = meta['io.modelcontextprotocol/serverInfo'];
441
- const versions = Array.isArray(d.supportedVersions) ? d.supportedVersions : [];
442
- return {
443
- // We negotiated MODERN (this path is only reached for a modern DiscoverResult), so report the
444
- // modern version - not `versions[0]`, whose ordering the spec does not guarantee (a dual-era
445
- // upstream listing legacy first would otherwise yield a contradictory identity).
446
- protocolVersion: versions.includes(MODERN_PROTOCOL_VERSION) ? MODERN_PROTOCOL_VERSION : (versions[0] || MODERN_PROTOCOL_VERSION),
447
- serverInfo: si && typeof si === 'object' ? si : undefined,
448
- capabilities: d.capabilities && typeof d.capabilities === 'object' ? d.capabilities : undefined,
449
- instructions: typeof d.instructions === 'string' ? d.instructions : undefined,
450
- supportedVersions: versions,
451
- };
452
- }
453
-
454
- /**
455
- * Spawn the child + wire its pipes (no handshake). A synchronous spawn throw (bad command) is
456
- * caught and rethrown clean. Extracted from connect() so probeDiscover() can share it.
457
- */
458
- _spawnChild() {
459
- try {
460
- // Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
461
- const launch = winLaunch(this._command, this._args);
462
- this._child = this._spawn(launch.command, launch.args, {
463
- stdio: ['pipe', 'pipe', 'pipe'],
464
- cwd: this._cwd,
465
- env: Object.assign({}, process.env, this._env),
466
- windowsHide: true,
467
- });
468
- } catch (err) {
469
- this._child = null;
470
- throw new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
471
- }
472
-
473
- if (!this._child || !this._child.stdin || !this._child.stdout) {
474
- // A fake/odd spawnImpl that didn't give us pipes - bail cleanly.
475
- this.close();
476
- throw new Error('McpClient: spawned child is missing stdio pipes');
477
- }
478
-
479
- this._wireChild();
480
- }
481
-
482
- /**
483
- * Era probe - the 2026-07-28 spec's dual-era client rule for stdio: try `server/discover`
484
- * BEFORE any legacy `initialize`. A modern or dual-era server answers with its
485
- * supportedVersions; a legacy server rejects it (-32601 Method not found) or times out.
486
- * Resolves the DiscoverResult, rejects otherwise. Sends the request with modern per-request
487
- * `_meta` (required in the modern era). Does NOT set connected - this is a probe, not a
488
- * handshake; call connect() for the legacy handshake (fresh client recommended: a badly
489
- * behaved legacy server may react badly to an unknown method).
490
- * @returns {Promise<object>} the server/discover result
491
- */
492
- async probeDiscover() {
493
- if (this._closed) throw new Error('McpClient: probeDiscover() called after close()');
494
- if (!this._child) this._spawnChild();
495
- try {
496
- return await this._request('server/discover', {
497
- _meta: {
498
- 'io.modelcontextprotocol/protocolVersion': '2026-07-28',
499
- 'io.modelcontextprotocol/clientCapabilities': {},
500
- 'io.modelcontextprotocol/clientInfo': this._clientInfo,
501
- },
502
- });
503
- } catch (err) {
504
- // Mirror connect()'s close-on-failure contract: a probe that times out or errors must not
505
- // leave a zombie child (the caller may not have a finally). close() is idempotent.
506
- this.close();
507
- throw err;
508
- }
509
- }
510
-
511
- /**
512
- * tools/list -> the upstream's tool definitions. Returns the raw array
513
- * [{name, description, inputSchema}] from result.tools (empty array if the
514
- * server returned no tools). Rejects on timeout / transport failure.
515
- * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
516
- */
517
- async listTools() {
518
- // Modern upstream: every request carries per-request `_meta` (stdio needs no headers).
519
- const params = this._modern ? { _meta: this._modernMeta() } : {};
520
- const result = await this._request('tools/list', params);
521
- const tools = result && Array.isArray(result.tools) ? result.tools : [];
522
- return tools;
523
- }
524
-
525
- /**
526
- * tools/call -> invoke one upstream tool. Returns the MCP tools/call result
527
- * envelope { content, isError }. Rejects on timeout / transport failure (a tool
528
- * that ran but returned an error sets isError:true in the resolved value - that
529
- * is NOT a rejection).
530
- * @param {string} name the upstream tool name
531
- * @param {any} args structured arguments (forwarded as the `arguments` field)
532
- * @param {object} [meta] the caller's request-scoped _meta (progressToken et al., protocol keys
533
- * already stripped by the server). Rides the forward: without it the upstream
534
- * never emits progress for the call, so a long funnel tool cannot keep its
535
- * own call alive.
536
- * @returns {Promise<{content:any[], isError:boolean}>}
537
- */
538
- async callTool(name, args, meta) {
539
- if (typeof name !== 'string' || name.length === 0) {
540
- throw new Error('McpClient.callTool: "name" (non-empty string) is required');
541
- }
542
- const params = { name, arguments: args == null ? {} : args };
543
- if (meta && typeof meta === 'object' && !Array.isArray(meta) && Object.keys(meta).length) {
544
- params._meta = { ...meta };
545
- }
546
- // modern upstream: the per-request protocol trio always wins over caller keys
547
- if (this._modern) params._meta = { ...(params._meta || {}), ...this._modernMeta() };
548
- const result = await this._request('tools/call', params);
549
- const r = result && typeof result === 'object' ? result : {};
550
- const out = {
551
- content: Array.isArray(r.content) ? r.content : [],
552
- isError: r.isError === true,
553
- };
554
- // structuredContent rides along verbatim - dropping it silently stripped modern upstreams'
555
- // structured results on the curated-direct path while the raw wrap path kept them
556
- if (r.structuredContent !== undefined) out.structuredContent = r.structuredContent;
557
- return out;
558
- }
559
-
560
- // ─────────────────────────────────────────────────────────────────────────────────────────────
561
- // close(): idempotent teardown. End child stdin so a well-behaved server exits on
562
- // EOF; if it is still alive, tree-kill it (win32 taskkill /T /F to reap the whole
563
- // process tree; else child.kill()). Reject every in-flight request so nothing leaks.
564
- // NEVER throws - it is called from error paths.
565
- // ─────────────────────────────────────────────────────────────────────────────────────────────
566
- close() {
567
- if (this._closed) return;
568
- this._closed = true;
569
- this._connected = false;
570
-
571
- const child = this._child;
572
-
573
- // Reject any in-flight requests first (so they don't fire their timers after teardown).
574
- this._rejectAllWaiters(new Error('McpClient: connection closed'));
575
-
576
- if (!child) return;
577
-
578
- // Best-effort: end stdin to let the server exit on EOF.
579
- try {
580
- if (child.stdin && !child.stdin.destroyed) child.stdin.end();
581
- } catch (_e) {
582
- /* ignore */
583
- }
584
-
585
- // If still alive, tree-kill so no descendant is orphaned (zombie guard).
586
- try {
587
- const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
588
- if (alive && typeof child.pid === 'number') {
589
- if (process.platform === 'win32') {
590
- // /T = whole tree, /F = force. spawnSync so it completes synchronously here.
591
- try {
592
- childProcess.spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
593
- windowsHide: true,
594
- });
595
- } catch (_e) {
596
- // Fall back to a plain kill if taskkill is unavailable.
597
- try { child.kill(); } catch (_e2) { /* ignore */ }
598
- }
599
- } else {
600
- child.kill();
601
- }
602
- } else if (typeof child.kill === 'function') {
603
- // Already exited or a fake child - a no-op kill is harmless.
604
- try { child.kill(); } catch (_e) { /* ignore */ }
605
- }
606
- } catch (_e) {
607
- /* never throw out of close */
608
- }
609
-
610
- this._child = null;
611
- }
612
-
613
- // ─────────────────────────────────────────────────────────────────────────────────────────────
614
- // Internals.
615
- // ─────────────────────────────────────────────────────────────────────────────────────────────
616
-
617
- /** Attach the stdout reader, stderr capture, and exit/error handlers to the child. */
618
- _wireChild() {
619
- const child = this._child;
620
-
621
- child.stdout.on('data', (chunk) => this._onData(chunk));
622
- child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
623
-
624
- if (child.stderr) {
625
- child.stderr.on('data', (d) => {
626
- this._stderr += d.toString('utf8');
627
- // Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
628
- if (this._stderr.length > 64 * 1024) {
629
- this._stderr = this._stderr.slice(-32 * 1024);
630
- }
631
- });
632
- child.stderr.on('error', () => { /* ignore */ });
633
- }
634
-
635
- // Either of these means the child is gone - fail every in-flight request so nothing hangs, and
636
- // NULL this._child so a later connect() on the same instance respawns instead of writing to a
637
- // dead stdin (connect() reuses `this._child` when set - a probeDiscover() against a legacy
638
- // server that dies on the unknown method would otherwise poison a follow-up connect()). Only
639
- // null it if it is still THIS child (a fresh respawn may have replaced it).
640
- child.on('exit', (code, signal) => {
641
- this._connected = false;
642
- if (this._child === child) this._child = null;
643
- this._rejectAllWaiters(
644
- new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
645
- );
646
- this._fireOnClose(`exited (code=${code}, signal=${signal})`);
647
- });
648
- child.on('error', (err) => {
649
- // Spawn-level failure (e.g. ENOENT) - surfaces here, not as a throw from spawn().
650
- this._connected = false;
651
- if (this._child === child) this._child = null;
652
- this._rejectAllWaiters(
653
- new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`)
654
- );
655
- this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
656
- });
657
- }
658
-
659
- /**
660
- * Fire the onClose callback ONCE, and ONLY for an UNEXPECTED death - never when close() was
661
- * called deliberately (close() sets _closed=true before tree-killing the child, so the resulting
662
- * 'exit' is suppressed here). This is the signal the aggregator uses to schedule a background
663
- * reconnect. Wrapped - a throwing callback must not escape an event handler.
664
- * @param {string} reason
665
- */
666
- _fireOnClose(reason) {
667
- // ONLY an established connection dying counts. A connect-time failure (child exits during the
668
- // handshake, ENOENT, etc.) must NOT fire onClose: it surfaces as connect()'s thrown error, and
669
- // the aggregator's reconnect-retry path owns the backoff. Firing here would let a death inside a
670
- // reconnect attempt reset the backoff counter to 0 -> an unbounded ~1s respawn storm.
671
- if (!this._everConnected) return;
672
- if (this._closed) return; // deliberate close() - not an unexpected death
673
- if (this._onCloseFired) return; // exit AND error can both fire; notify once
674
- this._onCloseFired = true;
675
- if (typeof this._onClose === 'function') {
676
- try { this._onClose(reason); } catch (_e) { this._logErr('onClose callback threw (ignored)'); }
677
- }
678
- }
679
-
680
- /** Reject + clear every pending waiter (used on close / exit / error). */
681
- _rejectAllWaiters(err) {
682
- if (this._waiters.size === 0) return;
683
- for (const [, w] of this._waiters) {
684
- try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
685
- try { w.reject(err); } catch (_e) { /* ignore */ }
686
- }
687
- this._waiters.clear();
688
- }
689
-
690
- /**
691
- * Send a JSON-RPC request with an id and await its matching response. Rejects on
692
- * timeout (requestTimeoutMs) after deleting the waiter so it can't leak. Resolves
693
- * with the response's `result` (or rejects with its `error`).
694
- */
695
- _request(method, params, out) {
696
- if (this._closed) {
697
- return Promise.reject(new Error(`McpClient: request("${method}") after close()`));
698
- }
699
- if (!this._child || !this._child.stdin) {
700
- return Promise.reject(new Error(`McpClient: request("${method}") with no live child`));
701
- }
702
-
703
- const id = this._nextId++;
704
- // Report the issued rpc id synchronously (before any I/O) - a wrap needs the client-id ->
705
- // upstream-id mapping LIVE to translate notifications/cancelled for in-flight forwards.
706
- if (out && typeof out === 'object') out.rpcId = id;
707
- const payload = { jsonrpc: JSONRPC, id, method };
708
- if (params !== undefined) payload.params = params;
709
-
710
- // Method-class window: payload methods wait toolTimeoutMs, control-plane keeps the short
711
- // dead-upstream window. The caller's progressToken (rides params._meta on
712
- // a wrap forward) is remembered so a matching progress beat can re-arm the window.
713
- const win = PAYLOAD_METHODS.has(method) ? this._toolTimeoutMs : this._requestTimeoutMs;
714
- const ptok = params && params._meta && typeof params._meta === 'object' &&
715
- params._meta.progressToken !== undefined ? params._meta.progressToken : undefined;
716
-
717
- return new Promise((resolve, reject) => {
718
- const timer = setTimeout(() => {
719
- this._waiters.delete(id);
720
- reject(new Error(
721
- `McpClient: timeout after ${win}ms waiting for "${method}". stderr so far:\n${this._stderr}`
722
- ));
723
- }, win);
724
- // Don't let a pending timer keep the event loop (and the test process) alive.
725
- if (timer && typeof timer.unref === 'function') timer.unref();
726
-
727
- this._waiters.set(id, { resolve, reject, timer, method, win, progressToken: ptok });
728
-
729
- let framed;
730
- try {
731
- framed = frameRequest(payload);
732
- } catch (err) {
733
- this._waiters.delete(id);
734
- clearTimeout(timer);
735
- reject(new Error(`McpClient: failed to serialise "${method}": ${(err && err.message) || String(err)}`));
736
- return;
737
- }
738
-
739
- try {
740
- this._child.stdin.write(framed, (err) => {
741
- if (err) {
742
- this._waiters.delete(id);
743
- clearTimeout(timer);
744
- reject(new Error(`McpClient: stdin write failed for "${method}": ${(err && err.message) || String(err)}`));
745
- }
746
- });
747
- } catch (err) {
748
- this._waiters.delete(id);
749
- clearTimeout(timer);
750
- reject(new Error(`McpClient: stdin write threw for "${method}": ${(err && err.message) || String(err)}`));
751
- }
752
- });
753
- }
754
-
755
- /** Fire-and-forget JSON-RPC notification (no id, no reply expected). Never throws. */
756
- _notify(method, params) {
757
- if (this._closed || !this._child || !this._child.stdin) return;
758
- const payload = { jsonrpc: JSONRPC, method };
759
- if (params !== undefined) payload.params = params;
760
- let framed;
761
- try {
762
- framed = frameRequest(payload);
763
- } catch (err) {
764
- this._logErr('failed to serialise notification', method + ':', (err && err.message) || String(err));
765
- return;
766
- }
767
- try {
768
- this._child.stdin.write(framed);
769
- } catch (err) {
770
- this._logErr('notification write failed', method + ':', (err && err.message) || String(err));
771
- }
772
- }
773
-
774
- /** Buffer incoming stdout bytes and drain every complete message (dual framing). */
775
- _onData(chunk) {
776
- this._buf = Buffer.concat([this._buf, chunk]);
777
- this._drain();
778
- }
779
-
780
- /**
781
- * Drain as many complete messages as are buffered. Header (Content-Length) framing
782
- * is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
783
- * JSON. Mirrors server.js's dual-drain so the client reads either wire format.
784
- */
785
- _drain() {
786
- for (;;) {
787
- if (this._tryHeaderFramed()) continue;
788
- if (this._tryLineFramed()) continue;
789
- break;
790
- }
791
- }
792
-
793
- /** Pull one Content-Length-framed message off the front of the buffer. */
794
- _tryHeaderFramed() {
795
- const headerEnd = this._buf.indexOf('\r\n\r\n');
796
- if (headerEnd === -1) return false;
797
- const header = this._buf.slice(0, headerEnd).toString('utf8');
798
- const m = /Content-Length:\s*(\d+)/i.exec(header);
799
- if (!m) return false; // a \r\n\r\n that isn't a Content-Length header -> not this framing
800
- const len = parseInt(m[1], 10);
801
- const bodyStart = headerEnd + 4;
802
- if (this._buf.length < bodyStart + len) return false; // body not all here yet
803
- const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
804
- this._buf = this._buf.slice(bodyStart + len);
805
- this._parseAndDispatch(body);
806
- return true;
807
- }
808
-
809
- /** Pull one newline-delimited JSON message off the front of the buffer. */
810
- _tryLineFramed() {
811
- const nl = this._buf.indexOf(0x0a); // '\n'
812
- if (nl === -1) return false;
813
- const line = this._buf.slice(0, nl).toString('utf8').trim();
814
- this._buf = this._buf.slice(nl + 1);
815
- if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
816
- // A stray header line ("Content-Length: ...") on the line path means the body hasn't
817
- // arrived yet - but header framing is always tried first, so anything non-JSON here is
818
- // a framing artifact; ignore it rather than erroring.
819
- if (line[0] !== '{' && line[0] !== '[') return true;
820
- this._parseAndDispatch(line);
821
- return true;
822
- }
823
-
824
- /** Parse one JSON body and route it to its waiter by id. Bad JSON / unmatched ids are ignored. */
825
- _parseAndDispatch(text) {
826
- let msg;
827
- try {
828
- msg = JSON.parse(text);
829
- } catch (_e) {
830
- this._logErr('dropping unparseable message from upstream');
831
- return;
832
- }
833
- if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
834
-
835
- const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
836
- if (!hasId) {
837
- // A progress beat for an in-flight request proves the upstream is alive and WORKING that
838
- // request - re-arm its window so a slow-but-progressing call never dies to the fixed
839
- // timeout. The notification still relays below, untouched.
840
- if (msg.method === 'notifications/progress' && msg.params &&
841
- msg.params.progressToken !== undefined) {
842
- this._bumpProgressWaiter(msg.params.progressToken);
843
- }
844
- // Server-initiated NOTIFICATION (method, no id) - surface it so the aggregator can bridge
845
- // change-notifications to a modern subscriptions/listen stream. NEVER throws.
846
- if (typeof msg.method === 'string' && typeof this.onNotification === 'function') {
847
- try { this.onNotification(msg); } catch (_e) { this._logErr('onNotification threw (ignored)'); }
848
- }
849
- return;
850
- }
851
-
852
- if (typeof msg.method === 'string') {
853
- // SERVER->CLIENT REQUEST (id + method): the upstream is asking ITS client something -
854
- // elicitation/create, sampling/createMessage, roots/list. Previously dropped, which left
855
- // a conformant upstream holding an open request forever (Bridge B). Surface to the owner;
856
- // unhandled -> answer -32601 (the honest "this client cannot do that").
857
- if (typeof this.onServerRequest === 'function') {
858
- try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
859
- }
860
- this.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method);
861
- return;
862
- }
863
-
864
- const w = this._waiters.get(msg.id);
865
- if (!w) return; // response to an id we no longer await (timed out / closed) - drop it
866
- this._waiters.delete(msg.id);
867
- try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
868
-
869
- if (msg.error) {
870
- const e = msg.error;
871
- const message = e && e.message ? e.message : 'JSON-RPC error';
872
- const code = e && typeof e.code === 'number' ? ` (code ${e.code})` : '';
873
- const err = new Error(`McpClient: upstream error for "${w.method}": ${message}${code}`);
874
- // Preserve the VERBATIM JSON-RPC error object - a wrap must relay the upstream's own error
875
- // (code/message/data) untouched; the stringified message above is for logs and lean paths.
876
- err.rpcError = { code: e && typeof e.code === 'number' ? e.code : -32603, message };
877
- if (e && e.data !== undefined) err.rpcError.data = e.data;
878
- w.reject(err);
879
- return;
880
- }
881
- w.resolve(msg.result);
882
- }
883
- }
884
-
885
- /**
886
- * Frame a JSON-RPC message for the MCP stdio transport: one compact newline-delimited
887
- * JSON message. NOT Content-Length framing - see the field note inside.
888
- * @param {object} payload
889
- * @returns {string}
890
- */
891
- function frameRequest(payload) {
892
- // MCP stdio transport framing: one compact JSON-RPC message per line, newline-delimited - NOT
893
- // LSP-style Content-Length. JSON.stringify emits no literal newlines, so a single trailing "\n"
894
- // cleanly delimits the message. This is what real SDK-based MCP servers read on stdin; writing
895
- // Content-Length here is what silently broke every real upstream (the read side still tolerates
896
- // both framings for robustness against the occasional Content-Length server).
897
- return JSON.stringify(payload) + '\n';
898
- }
899
-
900
- module.exports = {
901
- McpClient,
902
- // exported for unit tests / reuse
903
- frameRequest,
904
- winLaunch,
905
- PROTOCOL_VERSION,
906
- CLIENT_INFO,
907
- };
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
+ };