toolfunnel 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  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 +112 -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 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
@@ -1,530 +1,907 @@
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: LSP-style `Content-Length: N\r\n\r\n<body>` framing — this is what a real
14
- * MCP server requires on its stdin.
15
- * READ: tolerate BOTH `Content-Length:` framing AND newline-delimited JSON, so the
16
- * same client works against a real MCP server AND against our own server.js
17
- * (which WRITES newline-delimited JSON). This is server.js's dual-drain logic
18
- * re-used in the client.
19
- *
20
- * JSON-RPC requests are matched to responses by `id`. Every request carries a
21
- * timeout (requestTimeoutMs) — a slow/dead upstream rejects the in-flight promise
22
- * instead of leaking it. Notifications (no `id`) from the server are ignored.
23
- *
24
- * SAFETY / defensiveness:
25
- * - close() is idempotent and NEVER leaves a zombie child: it ends stdin, then if the
26
- * child is still alive tree-kills it (win32: taskkill /T /F; else child.kill()).
27
- * - connect() that fails (spawn error or initialize timeout) calls close() so no child
28
- * is left running, then rejects with a clear error.
29
- * - A child that exits while requests are in flight rejects every waiter (no hang).
30
- * - spawnImpl is injectable so a unit test can fake the child, but the real round-trip
31
- * against the fixture is the load-bearing test.
32
- *
33
- * CommonJS only. Node built-ins only. No @modelcontextprotocol/sdk.
34
- */
35
-
36
- const childProcess = require('node:child_process');
37
- const path = require('node:path');
38
-
39
- const JSONRPC = '2.0';
40
- const PROTOCOL_VERSION = '2024-11-05';
41
- const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
42
-
43
- /**
44
- * Cross-platform launch resolution for the upstream command.
45
- *
46
- * POSIX: pass through unchanged — execvp resolves `npx` / `uvx` / `node` / … on PATH.
47
- *
48
- * Windows: child_process with shell:false CANNOT run a `.cmd` / `.bat` shim — a bare name ENOENTs,
49
- * and an explicit `.cmd` throws EINVAL (the CVE-2024-27980 mitigation). Most MCP servers launch via
50
- * exactly such shims (`npx`, `npm`, `uvx`, `pnpm`, …), so on Windows route anything that isn't already
51
- * a concrete `.exe`/`.com` through cmd.exe, which resolves PATHEXT shims. The args stay DISCRETE
52
- * (never shell-concatenated into one string) and there is none of the `shell:true` DEP0190 hazard.
53
- * HONEST CAVEAT: because the spawned binary here is cmd.exe itself (an .exe), Node applies its
54
- * STANDARD argv quoting — not cmd.exe ^-metacharacter escaping (that special-casing only fires when
55
- * the command IS the .cmd/.bat). An arg containing cmd metacharacters (& | > < ") outside quotes can
56
- * therefore still be reinterpreted by cmd.exe. These args come from the operator's own expose.json,
57
- * so this is a correctness caveat on operator-authored config, not a privilege boundary; the fully
58
- * escaped alternative (resolve the real interpreter à la auth/install.js's `node + npm-cli.js` and
59
- * spawn it directly) is the upgrade path if it ever bites. close()'s taskkill /T still reaps the
60
- * whole cmd → shim → server process tree.
61
- *
62
- * @param {string} command the configured upstream command (e.g. "npx", "node", an abs path)
63
- * @param {string[]} args the configured argv
64
- * @returns {{ command: string, args: string[] }} what to actually hand to spawn()
65
- */
66
- function winLaunch(command, args) {
67
- const argv = Array.isArray(args) ? args : [];
68
- if (process.platform !== 'win32') return { command, args: argv };
69
- // A concrete executable can be spawned directly — no shim resolution needed.
70
- if (path.isAbsolute(command) && /\.(exe|com)$/i.test(command)) return { command, args: argv };
71
- // Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT.
72
- const comspec = process.env.ComSpec || 'cmd.exe';
73
- return { command: comspec, args: ['/c', command, ...argv] };
74
- }
75
-
76
- /**
77
- * McpClient — a hand-rolled JSON-RPC 2.0 client over a child process's stdio.
78
- *
79
- * Lifecycle: `new McpClient(opts)` → `await connect()` → `listTools()` / `callTool()`
80
- * → `close()` (idempotent).
81
- */
82
- class McpClient {
83
- /**
84
- * @param {object} opts
85
- * @param {string} [opts.id] caller-chosen id for this upstream (diagnostics only)
86
- * @param {string} opts.command executable to spawn (e.g. process.execPath)
87
- * @param {string[]} [opts.args] argv for the child (e.g. [serverPath])
88
- * @param {object} [opts.env] extra env merged over process.env for the child
89
- * @param {string} [opts.cwd] child working directory
90
- * @param {Function} [opts.spawnImpl] injectable spawn (default node:child_process.spawn)
91
- * @param {number} [opts.requestTimeoutMs] per-request timeout in ms (default 10000)
92
- * @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
93
- * while not deliberately close()d). Lets the owner
94
- * (the aggregator) schedule a background reconnect.
95
- */
96
- constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs = 10000, onClose } = {}) {
97
- if (typeof command !== 'string' || command.length === 0) {
98
- throw new Error('McpClient: "command" (non-empty string) is required');
99
- }
100
- this.id = typeof id === 'string' && id.length ? id : 'mcp-client';
101
- this._command = command;
102
- this._args = Array.isArray(args) ? args.slice() : [];
103
- this._env = env && typeof env === 'object' ? env : {};
104
- this._cwd = cwd;
105
- this._spawn = typeof spawnImpl === 'function' ? spawnImpl : childProcess.spawn;
106
- this._requestTimeoutMs =
107
- Number.isFinite(requestTimeoutMs) && requestTimeoutMs > 0 ? requestTimeoutMs : 10000;
108
- this._onClose = typeof onClose === 'function' ? onClose : null;
109
- this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
110
- this._everConnected = false; // true once the handshake completed — onClose fires ONLY for the
111
- // death of an ESTABLISHED connection, never a connect-time failure
112
-
113
- // Transport state.
114
- this._child = null;
115
- this._connected = false;
116
- this._closed = false;
117
- this._buf = Buffer.alloc(0);
118
- this._stderr = '';
119
- this._nextId = 1;
120
- /** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
121
- this._waiters = new Map();
122
-
123
- // The initialize result, captured for connect()'s return value.
124
- this._initializeResult = null;
125
- }
126
-
127
- /** @returns {boolean} true once the initialize handshake has completed and not yet closed. */
128
- get connected() {
129
- return this._connected === true && this._closed === false;
130
- }
131
-
132
- // ── Diagnostics → never throw. ───────────────────────────────────────────────────────────────
133
- _logErr(...parts) {
134
- try {
135
- process.stderr.write(`[mcp-client:${this.id}] ${parts.join(' ')}\n`);
136
- } catch (_e) {
137
- /* never let logging throw */
138
- }
139
- }
140
-
141
- // ─────────────────────────────────────────────────────────────────────────────────────────────
142
- // connect(): spawn the child, perform the MCP initialize handshake, send the
143
- // notifications/initialized notification, set connected. On ANY failure (spawn
144
- // error, initialize timeout/error) close() the child and reject — never leave a zombie.
145
- // ─────────────────────────────────────────────────────────────────────────────────────────────
146
- async connect() {
147
- if (this._closed) throw new Error('McpClient: connect() called after close()');
148
- if (this._connected) return this._initializeResult;
149
-
150
- // 1) Spawn the child. A synchronous spawn throw (bad command) is caught and rethrown clean.
151
- try {
152
- // Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
153
- const launch = winLaunch(this._command, this._args);
154
- this._child = this._spawn(launch.command, launch.args, {
155
- stdio: ['pipe', 'pipe', 'pipe'],
156
- cwd: this._cwd,
157
- env: Object.assign({}, process.env, this._env),
158
- windowsHide: true,
159
- });
160
- } catch (err) {
161
- this._child = null;
162
- throw new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
163
- }
164
-
165
- if (!this._child || !this._child.stdin || !this._child.stdout) {
166
- // A fake/odd spawnImpl that didn't give us pipes — bail cleanly.
167
- this.close();
168
- throw new Error('McpClient: spawned child is missing stdio pipes');
169
- }
170
-
171
- this._wireChild();
172
-
173
- // 2) initialize handshake. If the child fails to spawn the 'error' event fires and
174
- // rejects this request (via the waiter-reject path); if it never answers the
175
- // request timeout fires. Either way we close() and surface a clear error.
176
- try {
177
- const result = await this._request('initialize', {
178
- protocolVersion: PROTOCOL_VERSION,
179
- clientInfo: CLIENT_INFO,
180
- capabilities: {},
181
- });
182
- this._initializeResult = result;
183
- } catch (err) {
184
- this.close();
185
- throw new Error(`McpClient: initialize failed: ${(err && err.message) || String(err)}`);
186
- }
187
-
188
- // 3) Tell the server the handshake is complete (notification — no id, no reply).
189
- this._notify('notifications/initialized', {});
190
-
191
- this._connected = true;
192
- this._everConnected = true; // from here on, an exit/error is an ESTABLISHED-connection death
193
- return this._initializeResult;
194
- }
195
-
196
- /**
197
- * tools/list → the upstream's tool definitions. Returns the raw array
198
- * [{name, description, inputSchema}] from result.tools (empty array if the
199
- * server returned no tools). Rejects on timeout / transport failure.
200
- * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
201
- */
202
- async listTools() {
203
- const result = await this._request('tools/list', {});
204
- const tools = result && Array.isArray(result.tools) ? result.tools : [];
205
- return tools;
206
- }
207
-
208
- /**
209
- * tools/call → invoke one upstream tool. Returns the MCP tools/call result
210
- * envelope { content, isError }. Rejects on timeout / transport failure (a tool
211
- * that ran but returned an error sets isError:true in the resolved value — that
212
- * is NOT a rejection).
213
- * @param {string} name the upstream tool name
214
- * @param {any} args structured arguments (forwarded as the `arguments` field)
215
- * @returns {Promise<{content:any[], isError:boolean}>}
216
- */
217
- async callTool(name, args) {
218
- if (typeof name !== 'string' || name.length === 0) {
219
- throw new Error('McpClient.callTool: "name" (non-empty string) is required');
220
- }
221
- const result = await this._request('tools/call', { name, arguments: args == null ? {} : args });
222
- const r = result && typeof result === 'object' ? result : {};
223
- return {
224
- content: Array.isArray(r.content) ? r.content : [],
225
- isError: r.isError === true,
226
- };
227
- }
228
-
229
- // ─────────────────────────────────────────────────────────────────────────────────────────────
230
- // close(): idempotent teardown. End child stdin so a well-behaved server exits on
231
- // EOF; if it is still alive, tree-kill it (win32 taskkill /T /F to reap the whole
232
- // process tree; else child.kill()). Reject every in-flight request so nothing leaks.
233
- // NEVER throws — it is called from error paths.
234
- // ─────────────────────────────────────────────────────────────────────────────────────────────
235
- close() {
236
- if (this._closed) return;
237
- this._closed = true;
238
- this._connected = false;
239
-
240
- const child = this._child;
241
-
242
- // Reject any in-flight requests first (so they don't fire their timers after teardown).
243
- this._rejectAllWaiters(new Error('McpClient: connection closed'));
244
-
245
- if (!child) return;
246
-
247
- // Best-effort: end stdin to let the server exit on EOF.
248
- try {
249
- if (child.stdin && !child.stdin.destroyed) child.stdin.end();
250
- } catch (_e) {
251
- /* ignore */
252
- }
253
-
254
- // If still alive, tree-kill so no descendant is orphaned (zombie guard).
255
- try {
256
- const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
257
- if (alive && typeof child.pid === 'number') {
258
- if (process.platform === 'win32') {
259
- // /T = whole tree, /F = force. spawnSync so it completes synchronously here.
260
- try {
261
- childProcess.spawnSync('taskkill', ['/pid', String(child.pid), '/T', '/F'], {
262
- windowsHide: true,
263
- });
264
- } catch (_e) {
265
- // Fall back to a plain kill if taskkill is unavailable.
266
- try { child.kill(); } catch (_e2) { /* ignore */ }
267
- }
268
- } else {
269
- child.kill();
270
- }
271
- } else if (typeof child.kill === 'function') {
272
- // Already exited or a fake child — a no-op kill is harmless.
273
- try { child.kill(); } catch (_e) { /* ignore */ }
274
- }
275
- } catch (_e) {
276
- /* never throw out of close */
277
- }
278
-
279
- this._child = null;
280
- }
281
-
282
- // ─────────────────────────────────────────────────────────────────────────────────────────────
283
- // Internals.
284
- // ─────────────────────────────────────────────────────────────────────────────────────────────
285
-
286
- /** Attach the stdout reader, stderr capture, and exit/error handlers to the child. */
287
- _wireChild() {
288
- const child = this._child;
289
-
290
- child.stdout.on('data', (chunk) => this._onData(chunk));
291
- child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
292
-
293
- if (child.stderr) {
294
- child.stderr.on('data', (d) => {
295
- this._stderr += d.toString('utf8');
296
- // Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
297
- if (this._stderr.length > 64 * 1024) {
298
- this._stderr = this._stderr.slice(-32 * 1024);
299
- }
300
- });
301
- child.stderr.on('error', () => { /* ignore */ });
302
- }
303
-
304
- // Either of these means the child is gone — fail every in-flight request so nothing hangs.
305
- child.on('exit', (code, signal) => {
306
- this._connected = false;
307
- this._rejectAllWaiters(
308
- new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
309
- );
310
- this._fireOnClose(`exited (code=${code}, signal=${signal})`);
311
- });
312
- child.on('error', (err) => {
313
- // Spawn-level failure (e.g. ENOENT) — surfaces here, not as a throw from spawn().
314
- this._connected = false;
315
- this._rejectAllWaiters(
316
- new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`)
317
- );
318
- this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
319
- });
320
- }
321
-
322
- /**
323
- * Fire the onClose callback ONCE, and ONLY for an UNEXPECTED death — never when close() was
324
- * called deliberately (close() sets _closed=true before tree-killing the child, so the resulting
325
- * 'exit' is suppressed here). This is the signal the aggregator uses to schedule a background
326
- * reconnect. Wrapped — a throwing callback must not escape an event handler.
327
- * @param {string} reason
328
- */
329
- _fireOnClose(reason) {
330
- // ONLY an established connection dying counts. A connect-time failure (child exits during the
331
- // handshake, ENOENT, etc.) must NOT fire onClose: it surfaces as connect()'s thrown error, and
332
- // the aggregator's reconnect-retry path owns the backoff. Firing here would let a death inside a
333
- // reconnect attempt reset the backoff counter to 0 → an unbounded ~1s respawn storm.
334
- if (!this._everConnected) return;
335
- if (this._closed) return; // deliberate close() — not an unexpected death
336
- if (this._onCloseFired) return; // exit AND error can both fire; notify once
337
- this._onCloseFired = true;
338
- if (typeof this._onClose === 'function') {
339
- try { this._onClose(reason); } catch (_e) { this._logErr('onClose callback threw (ignored)'); }
340
- }
341
- }
342
-
343
- /** Reject + clear every pending waiter (used on close / exit / error). */
344
- _rejectAllWaiters(err) {
345
- if (this._waiters.size === 0) return;
346
- for (const [, w] of this._waiters) {
347
- try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
348
- try { w.reject(err); } catch (_e) { /* ignore */ }
349
- }
350
- this._waiters.clear();
351
- }
352
-
353
- /**
354
- * Send a JSON-RPC request with an id and await its matching response. Rejects on
355
- * timeout (requestTimeoutMs) after deleting the waiter so it can't leak. Resolves
356
- * with the response's `result` (or rejects with its `error`).
357
- */
358
- _request(method, params) {
359
- if (this._closed) {
360
- return Promise.reject(new Error(`McpClient: request("${method}") after close()`));
361
- }
362
- if (!this._child || !this._child.stdin) {
363
- return Promise.reject(new Error(`McpClient: request("${method}") with no live child`));
364
- }
365
-
366
- const id = this._nextId++;
367
- const payload = { jsonrpc: JSONRPC, id, method };
368
- if (params !== undefined) payload.params = params;
369
-
370
- return new Promise((resolve, reject) => {
371
- const timer = setTimeout(() => {
372
- this._waiters.delete(id);
373
- reject(new Error(
374
- `McpClient: timeout after ${this._requestTimeoutMs}ms waiting for "${method}". stderr so far:\n${this._stderr}`
375
- ));
376
- }, this._requestTimeoutMs);
377
- // Don't let a pending timer keep the event loop (and the test process) alive.
378
- if (timer && typeof timer.unref === 'function') timer.unref();
379
-
380
- this._waiters.set(id, { resolve, reject, timer, method });
381
-
382
- let framed;
383
- try {
384
- framed = frameRequest(payload);
385
- } catch (err) {
386
- this._waiters.delete(id);
387
- clearTimeout(timer);
388
- reject(new Error(`McpClient: failed to serialise "${method}": ${(err && err.message) || String(err)}`));
389
- return;
390
- }
391
-
392
- try {
393
- this._child.stdin.write(framed, (err) => {
394
- if (err) {
395
- this._waiters.delete(id);
396
- clearTimeout(timer);
397
- reject(new Error(`McpClient: stdin write failed for "${method}": ${(err && err.message) || String(err)}`));
398
- }
399
- });
400
- } catch (err) {
401
- this._waiters.delete(id);
402
- clearTimeout(timer);
403
- reject(new Error(`McpClient: stdin write threw for "${method}": ${(err && err.message) || String(err)}`));
404
- }
405
- });
406
- }
407
-
408
- /** Fire-and-forget JSON-RPC notification (no id, no reply expected). Never throws. */
409
- _notify(method, params) {
410
- if (this._closed || !this._child || !this._child.stdin) return;
411
- const payload = { jsonrpc: JSONRPC, method };
412
- if (params !== undefined) payload.params = params;
413
- let framed;
414
- try {
415
- framed = frameRequest(payload);
416
- } catch (err) {
417
- this._logErr('failed to serialise notification', method + ':', (err && err.message) || String(err));
418
- return;
419
- }
420
- try {
421
- this._child.stdin.write(framed);
422
- } catch (err) {
423
- this._logErr('notification write failed', method + ':', (err && err.message) || String(err));
424
- }
425
- }
426
-
427
- /** Buffer incoming stdout bytes and drain every complete message (dual framing). */
428
- _onData(chunk) {
429
- this._buf = Buffer.concat([this._buf, chunk]);
430
- this._drain();
431
- }
432
-
433
- /**
434
- * Drain as many complete messages as are buffered. Header (Content-Length) framing
435
- * is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
436
- * JSON. Mirrors server.js's dual-drain so the client reads either wire format.
437
- */
438
- _drain() {
439
- for (;;) {
440
- if (this._tryHeaderFramed()) continue;
441
- if (this._tryLineFramed()) continue;
442
- break;
443
- }
444
- }
445
-
446
- /** Pull one Content-Length-framed message off the front of the buffer. */
447
- _tryHeaderFramed() {
448
- const headerEnd = this._buf.indexOf('\r\n\r\n');
449
- if (headerEnd === -1) return false;
450
- const header = this._buf.slice(0, headerEnd).toString('utf8');
451
- const m = /Content-Length:\s*(\d+)/i.exec(header);
452
- if (!m) return false; // a \r\n\r\n that isn't a Content-Length header → not this framing
453
- const len = parseInt(m[1], 10);
454
- const bodyStart = headerEnd + 4;
455
- if (this._buf.length < bodyStart + len) return false; // body not all here yet
456
- const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
457
- this._buf = this._buf.slice(bodyStart + len);
458
- this._parseAndDispatch(body);
459
- return true;
460
- }
461
-
462
- /** Pull one newline-delimited JSON message off the front of the buffer. */
463
- _tryLineFramed() {
464
- const nl = this._buf.indexOf(0x0a); // '\n'
465
- if (nl === -1) return false;
466
- const line = this._buf.slice(0, nl).toString('utf8').trim();
467
- this._buf = this._buf.slice(nl + 1);
468
- if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
469
- // A stray header line ("Content-Length: …") on the line path means the body hasn't
470
- // arrived yet — but header framing is always tried first, so anything non-JSON here is
471
- // a framing artifact; ignore it rather than erroring.
472
- if (line[0] !== '{' && line[0] !== '[') return true;
473
- this._parseAndDispatch(line);
474
- return true;
475
- }
476
-
477
- /** Parse one JSON body and route it to its waiter by id. Bad JSON / unmatched ids are ignored. */
478
- _parseAndDispatch(text) {
479
- let msg;
480
- try {
481
- msg = JSON.parse(text);
482
- } catch (_e) {
483
- this._logErr('dropping unparseable message from upstream');
484
- return;
485
- }
486
- if (!msg || typeof msg !== 'object' || Array.isArray(msg)) return;
487
-
488
- const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
489
- if (!hasId) return; // server-initiated notification — we don't consume these yet
490
-
491
- const w = this._waiters.get(msg.id);
492
- if (!w) return; // response to an id we no longer await (timed out / closed) — drop it
493
- this._waiters.delete(msg.id);
494
- try { clearTimeout(w.timer); } catch (_e) { /* ignore */ }
495
-
496
- if (msg.error) {
497
- const e = msg.error;
498
- const message = e && e.message ? e.message : 'JSON-RPC error';
499
- const code = e && typeof e.code === 'number' ? ` (code ${e.code})` : '';
500
- w.reject(new Error(`McpClient: upstream error for "${w.method}": ${message}${code}`));
501
- return;
502
- }
503
- w.resolve(msg.result);
504
- }
505
- }
506
-
507
- /**
508
- * Frame a JSON-RPC message with LSP-style Content-Length headers (the format a real
509
- * MCP server requires on its stdin). UTF-8 byte length is used so multi-byte payloads
510
- * frame correctly.
511
- * @param {object} payload
512
- * @returns {string}
513
- */
514
- function frameRequest(payload) {
515
- // MCP stdio transport framing: one compact JSON-RPC message per line, newline-delimited — NOT
516
- // LSP-style Content-Length. JSON.stringify emits no literal newlines, so a single trailing "\n"
517
- // cleanly delimits the message. This is what real SDK-based MCP servers read on stdin; writing
518
- // Content-Length here is what silently broke every real upstream (the read side still tolerates
519
- // both framings for robustness against the occasional Content-Length server).
520
- return JSON.stringify(payload) + '\n';
521
- }
522
-
523
- module.exports = {
524
- McpClient,
525
- // exported for unit tests / reuse
526
- frameRequest,
527
- winLaunch,
528
- PROTOCOL_VERSION,
529
- CLIENT_INFO,
530
- };
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
+ };