toolfunnel 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -72,7 +72,7 @@ const SSE_KEEPALIVE_MS = 25000;
72
72
  // Hard cap on a POSTed JSON-RPC body so a pathological/huge request can't exhaust memory. A
73
73
  // JSON-RPC tools/call payload is tiny; 4 MiB is generous headroom. Over the cap -> -32700.
74
74
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
75
- // Hard cap on JSON-RPC batch members (0.7.0). MAX_BODY_BYTES alone is no bound on WORK: a
75
+ // Hard cap on JSON-RPC batch members. MAX_BODY_BYTES alone is no bound on WORK: a
76
76
  // tools/call member is ~100 bytes, so 4 MiB buys tens of thousands of gated invocations - each
77
77
  // spawning hook children and a tool child - executed serially while the socket is held. 64 is well
78
78
  // past any real client's batching and far short of a denial of service.
@@ -137,6 +137,12 @@ function defaultBuildFactory() {
137
137
  return require('./server').buildProtocol();
138
138
  }
139
139
 
140
+ /** A host as it appears in a URL: a literal IPv6 address is bracketed (`[::1]`), anything else verbatim. */
141
+ function urlHost(h) {
142
+ const v = String(h == null ? '' : h);
143
+ return v.includes(':') && !v.startsWith('[') ? '[' + v + ']' : v;
144
+ }
145
+
140
146
  /**
141
147
  * Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound request could still
142
148
  * arrive with a non-loopback Host. Defence-in-depth: reject anything whose host part is not a
@@ -393,7 +399,7 @@ function createHttpMcpServer(opts = {}) {
393
399
 
394
400
  /** The advertised base URL once bound (null before start / after stop). */
395
401
  function currentUrl() {
396
- return boundPort == null ? null : `http://${host}:${boundPort}`;
402
+ return boundPort == null ? null : `http://${urlHost(host)}:${boundPort}`;
397
403
  }
398
404
 
399
405
  // ── SSE plumbing ──────────────────────────────────────────────────────────────────────────
@@ -405,6 +411,13 @@ function createHttpMcpServer(opts = {}) {
405
411
  * server stop) the client is deregistered and its timer cleared.
406
412
  */
407
413
  function openSse(req, res) {
414
+ // Same ceiling as the modern listen streams: every open stream is a registered response plus
415
+ // a keep-alive timer for the life of the socket, and nothing bounded the legacy channel - a
416
+ // client opening streams it never closed grew the host without limit. A GET has no JSON-RPC
417
+ // id, so the refusal is a plain JSON body, not an error envelope.
418
+ if (sseClients.size >= MODERN_LISTENERS_MAX) {
419
+ return sendJson(res, 503, { error: 'sse stream limit reached (' + MODERN_LISTENERS_MAX + ') - close an existing stream first' });
420
+ }
408
421
  res.writeHead(200, {
409
422
  'Content-Type': 'text/event-stream',
410
423
  'Cache-Control': 'no-store',
@@ -487,9 +500,9 @@ function createHttpMcpServer(opts = {}) {
487
500
  // At capacity -> refuse the NEW stream with a clear error BEFORE any SSE bytes are written;
488
501
  // existing streams are never evicted (they may carry subscriptions the client relies on).
489
502
  //
490
- // ORDER MATTERS (0.7.0): this check used to sit AFTER subscribeResources below, and the 503
491
- // return path registers no cleanup - so a refused listen took refcounts it never released.
492
- // Each leaked ref pinned its URI above zero (no resources/unsubscribe ever sent) and kept it
503
+ // ORDER MATTERS: this check must sit BEFORE subscribeResources below, because the 503
504
+ // return path registers no cleanup - a refused listen would take refcounts it never releases.
505
+ // Each leaked ref would pin its URI above zero (no resources/unsubscribe ever sent) and keep it
493
506
  // in the replay set, re-subscribed on every future upstream reconnect, unboundedly.
494
507
  if (modernListeners.size >= MODERN_LISTENERS_MAX) {
495
508
  return sendJson(res, 503, makeErrorWithId(subId, ERR.INTERNAL,
@@ -539,7 +552,7 @@ function createHttpMcpServer(opts = {}) {
539
552
  clearInterval(keepAlive);
540
553
  modernListeners.delete(entry);
541
554
  // 5.4: the stream WAS the subscription - closing it releases the listen-owned resource
542
- // URIs (this cleanup previously released nothing: leak site #2). _released guards the
555
+ // URIs. _released guards the
543
556
  // up-to-four close/error events that all route here.
544
557
  if (entry._released !== true && entry.agreed && Array.isArray(entry.agreed.resourceSubscriptions)) {
545
558
  entry._released = true;
@@ -739,7 +752,7 @@ function createHttpMcpServer(opts = {}) {
739
752
  raw = await readBody(req);
740
753
  } catch (err) {
741
754
  // Oversized / aborted body -> the SAME flush-then-close -32700 as the Content-Length pre-check
742
- // (readBody no longer destroys the request, so the reply can actually reach the client; a
755
+ // (readBody does not destroy the request, so the reply can actually reach the client; a
743
756
  // genuinely-dead socket is fine too - sendJsonAndClose's writes are defensively wrapped).
744
757
  log('POST /mcp body read failed:', (err && err.message) || String(err));
745
758
  return sendJsonAndClose(req, res, 200, makeError(ERR.PARSE, 'Parse error: ' + ((err && err.message) || 'bad body')));
@@ -765,16 +778,16 @@ function createHttpMcpServer(opts = {}) {
765
778
  if (hv === undefined || hv === '2025-03-26') {
766
779
  // A MODERN-shaped member cannot legally travel in a batch: batching exists ONLY in
767
780
  // 2025-03-26, which predates the modern era entirely. Refusing here closes an era-mixing
768
- // hole (0.7.0): this branch runs BEFORE bodyIsModern below, so a modern body wrapped in a
769
- // one-element array used to skip validateModernHeaders, the Mcp-Param cross-check and the
770
- // disconnect-cancel accounting, and still be SERVED with full modern semantics because
781
+ // hole: this branch runs BEFORE bodyIsModern below, so a modern body wrapped in a
782
+ // one-element array would otherwise skip validateModernHeaders, the Mcp-Param cross-check and
783
+ // the disconnect-cancel accounting, and still be SERVED with full modern semantics because
771
784
  // handleMessage re-detects the era from the body.
772
785
  if (msg.some((m) => modern.isModernRequest(m))) {
773
786
  return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
774
787
  'modern-era messages cannot be batched - JSON-RPC batching is 2025-03-26 only; send each modern request as its own POST'));
775
788
  }
776
789
  // A batch is executed member-by-member with a gated tool call possible in every slot, so an
777
- // uncapped array is one POST buying unbounded sequential execution (0.7.0).
790
+ // uncapped array is one POST buying unbounded sequential execution.
778
791
  if (msg.length > MAX_BATCH_MEMBERS) {
779
792
  return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
780
793
  `batch too large (${msg.length} members, max ${MAX_BATCH_MEMBERS}) - split it`));
@@ -889,13 +902,43 @@ function createHttpMcpServer(opts = {}) {
889
902
  });
890
903
  }
891
904
 
905
+ // Per-request progress. A single request that carries a progressToken and accepts
906
+ // text/event-stream gets an SSE answer the moment its FIRST beat arrives: its own progress (its
907
+ // own token - the aggregator scopes tokens per call), then its result, then the end. Streamable
908
+ // HTTP allows exactly this ("MAY send ... notifications before sending the JSON-RPC response.
909
+ // These messages SHOULD relate to the originating client request"), and it is the only place
910
+ // progress may go: the shared GET stream would hand one caller's beats to every listener. No beat
911
+ // arrives -> the same JSON answer (status mapping included) as before.
912
+ const ptok = msg && msg.params && msg.params._meta && msg.params._meta.progressToken;
913
+ const wantsStream = !!(msg && !Array.isArray(msg) && msg.id !== undefined && msg.id !== null &&
914
+ ((typeof ptok === 'string' && ptok.length) || Number.isInteger(ptok)) &&
915
+ /text\/event-stream/i.test(String(req.headers.accept || '')));
916
+ let streaming = false;
917
+ const progress = wantsStream ? (params) => {
918
+ if (modernDisconnected || res.writableEnded || res.destroyed) return;
919
+ try {
920
+ if (!streaming) {
921
+ streaming = true;
922
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
923
+ }
924
+ res.write('data: ' + JSON.stringify({ jsonrpc: JSONRPC, method: 'notifications/progress', params }) + '\n\n');
925
+ } catch (_e) { /* a dead socket: the result path below meets it too */ }
926
+ } : undefined;
927
+
892
928
  let response;
893
929
  try {
894
- response = await handleMessage(build, msg);
930
+ response = await handleMessage(build, msg, undefined, { progress });
895
931
  } catch (err) {
896
932
  // handleMessage is contracted never to throw, but the transport must survive even if a
897
933
  // wiring bug breaks that contract - a single bad request can never crash the host.
898
934
  log('handleMessage threw (should not):', (err && err.stack) || String(err));
935
+ if (streaming) {
936
+ try {
937
+ res.write('data: ' + JSON.stringify(makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err))) + '\n\n');
938
+ res.end();
939
+ } catch (_e) { /* ignore */ }
940
+ return;
941
+ }
899
942
  return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err)));
900
943
  }
901
944
 
@@ -903,6 +946,15 @@ function createHttpMcpServer(opts = {}) {
903
946
  // returning here keeps the contract explicit and the counter honest.
904
947
  if (modernDisconnected) return;
905
948
 
949
+ if (streaming) {
950
+ // Streaming began (status 200 already sent): the result - or the JSON-RPC error - is the
951
+ // stream's last event, then the stream ends ("After the JSON-RPC response has been sent, the
952
+ // server SHOULD terminate the SSE stream").
953
+ try { if (response != null) res.write('data: ' + JSON.stringify(response) + '\n\n'); } catch (_e) { /* ignore */ }
954
+ try { res.end(); } catch (_e) { /* ignore */ }
955
+ return;
956
+ }
957
+
906
958
  // A notification (no reply) -> 202 Accepted, no body. A request -> 200 with the JSON-RPC result.
907
959
  if (response == null) {
908
960
  res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
@@ -1058,10 +1110,9 @@ function createHttpMcpServer(opts = {}) {
1058
1110
  // alias (the older HTTP+SSE shape, deprecated but still supported). Both routes
1059
1111
  // hand off to the same openSse() - the loopback/auth guard above already gated the request.
1060
1112
  if (method === 'GET' && (pathName === '/mcp' || pathName === '/mcp/sse')) {
1061
- // MODERN-ONLY POLICY (0.7.0): the GET-SSE stream is a LEGACY-era channel (modern uses
1113
+ // MODERN-ONLY POLICY: the GET-SSE stream is a LEGACY-era channel (modern uses
1062
1114
  // subscriptions/listen on POST) - answer the spec's own compatibility shape, 405 Method
1063
1115
  // Not Allowed + Allow: POST (the Streamable HTTP spec's rule; a MUST across three revisions).
1064
- // Replaces the old 400/-32020 - HeaderMismatch was an allocation-policy misuse here too.
1065
1116
  if (build && build.serveLegacy === false) {
1066
1117
  res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1067
1118
  return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
@@ -1112,6 +1163,42 @@ function createHttpMcpServer(opts = {}) {
1112
1163
  * upstreamsConnected:number, upstreams:number, toolsExposed:number,
1113
1164
  * connected:string[], failed:Array<{id:string, error:string}> }}
1114
1165
  */
1166
+ /** The aggregator's stopped upstreams, defensively (a degraded or older build reports none). */
1167
+ function stoppedNow() {
1168
+ try {
1169
+ const agg = build && build.aggregator;
1170
+ const s = agg && typeof agg.stoppedUpstreams === 'function' ? agg.stoppedUpstreams() : [];
1171
+ return Array.isArray(s) ? s.map((x) => ({ id: x.id, error: x.error, attempts: x.attempts })) : [];
1172
+ } catch (_e) {
1173
+ return [];
1174
+ }
1175
+ }
1176
+
1177
+ /** Tools the aggregator dropped for a broken descriptor, defensively (none on a degraded build). */
1178
+ function invalidToolsNow() {
1179
+ try {
1180
+ const agg = build && build.aggregator;
1181
+ const v = agg && typeof agg.invalidTools === 'function' ? agg.invalidTools() : [];
1182
+ return Array.isArray(v) ? v : [];
1183
+ } catch (_e) {
1184
+ return [];
1185
+ }
1186
+ }
1187
+
1188
+ /** The aggregator's tool descriptor changes, defensively (a degraded or older build: none). */
1189
+ function toolChangesNow() {
1190
+ try {
1191
+ const agg = build && build.aggregator;
1192
+ const c = agg && typeof agg.toolChanges === 'function' ? agg.toolChanges() : null;
1193
+ return {
1194
+ pending: c && Array.isArray(c.pending) ? c.pending : [],
1195
+ upgraded: c && Array.isArray(c.upgraded) ? c.upgraded : [],
1196
+ };
1197
+ } catch (_e) {
1198
+ return { pending: [], upgraded: [] };
1199
+ }
1200
+ }
1201
+
1115
1202
  function health() {
1116
1203
  let toolsExposed = 0;
1117
1204
  let upstreamsConnected = 0;
@@ -1184,6 +1271,13 @@ function createHttpMcpServer(opts = {}) {
1184
1271
  // visible. Fresh copies so the caller can never mutate the internal snapshot.
1185
1272
  connected: lastConnect.connected.slice(),
1186
1273
  failed: lastConnect.failed.map((f) => ({ id: f.id, error: f.error })),
1274
+ // LIVE (not the startup snapshot): upstreams that hit the permanent-failure ceiling and
1275
+ // stopped retrying - they stay here until a config reload or a reconnect.
1276
+ stopped: stoppedNow(),
1277
+ // Tool descriptor approval: changes waiting for acceptance + recent upgrades, live.
1278
+ toolChanges: toolChangesNow(),
1279
+ // Tools dropped because their descriptor is structurally broken, live.
1280
+ invalidTools: invalidToolsNow(),
1187
1281
  // OAuth resource-server status (no secrets - issuer/audience live in the well-known doc):
1188
1282
  // whether auth is enabled, whether the optional jose dependency is installed, and whether the
1189
1283
  // current config is coherent enough to enforce (misconfigured: configError() reason present).
@@ -1497,9 +1591,9 @@ function createHttpMcpServer(opts = {}) {
1497
1591
  prevAggForSubs._subscribedUris instanceof Set && prevAggForSubs._subscribedUris.size) {
1498
1592
  next.aggregator._subscribedUris = new Set(prevAggForSubs._subscribedUris);
1499
1593
  // Carry the REFCOUNTS too, exactly as reloadExpose does (server.js). Without them the new
1500
- // aggregator held the URIs with an empty refs Map, and releaseResources treats refcount 0 as
1501
- // "last holder" - so the first listener to close unsubscribed upstream and killed the
1502
- // channel for every other listener still holding that URI (0.7.0).
1594
+ // aggregator holds the URIs with an empty refs Map, and releaseResources treats refcount 0 as
1595
+ // "last holder" - so the first listener to close would unsubscribe upstream and kill the
1596
+ // channel for every other listener still holding that URI.
1503
1597
  if (prevAggForSubs._subscribedRefs instanceof Map) {
1504
1598
  next.aggregator._subscribedRefs = new Map(prevAggForSubs._subscribedRefs);
1505
1599
  }
@@ -35,6 +35,7 @@
35
35
  */
36
36
 
37
37
  const childProcess = require('node:child_process');
38
+ const fs = require('node:fs');
38
39
  const path = require('node:path');
39
40
 
40
41
  const JSONRPC = '2.0';
@@ -42,8 +43,8 @@ const PROTOCOL_VERSION = '2024-11-05';
42
43
  // The 2026-07-28 ("modern") protocol version this client can SPEAK to a modern upstream. Over
43
44
  // stdio the modern era needs only per-request `_meta` - the Mcp-Method/Mcp-Name/MCP-Protocol-Version
44
45
  // 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';
46
+ // initialize handshake.
47
+ const { MODERN_PROTOCOL_VERSION } = require('./modern'); // the ONE definition (version-literal.test.js pins it)
47
48
  const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
48
49
 
49
50
  /**
@@ -72,11 +73,65 @@ const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client'
72
73
  function winLaunch(command, args) {
73
74
  const argv = Array.isArray(args) ? args : [];
74
75
  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.
76
+ // A concrete executable can be spawned directly - no shim resolution needed. A relative one
77
+ // (`../../x/node.exe`, the drive-independent config) resolves against the spawn's cwd.
78
+ const pathShaped = path.isAbsolute(command) || /[\\/]/.test(command);
79
+ if (pathShaped && /\.(exe|com)$/i.test(command)) return { command, args: argv };
80
+ // Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT. cmd reads a '/'
81
+ // as a switch, so a path reaches it with backslashes.
78
82
  const comspec = process.env.ComSpec || 'cmd.exe';
79
- return { command: comspec, args: ['/c', command, ...argv] };
83
+ return { command: comspec, args: ['/c', pathShaped ? command.replace(/\//g, '\\') : command, ...argv] };
84
+ }
85
+
86
+ /**
87
+ * Windows only: is `command` definitely absent from everywhere cmd.exe would look - the working
88
+ * directory, then each PATH directory, trying each PATHEXT extension?
89
+ *
90
+ * winLaunch routes every non-.exe command through cmd.exe, so a missing command never fails the
91
+ * spawn with ENOENT: cmd prints a LOCALISED "is not recognized" line and exits 1, exactly like a
92
+ * server that crashed on start. The owner classifies permanent failures by error code (the
93
+ * aggregator's reconnect ceiling), so connect() asks this AFTER a failed connect and stamps ENOENT.
94
+ * It never blocks a spawn. Conservative by construction - it answers true only when every
95
+ * candidate is definitely absent: a stat error other than ENOENT/ENOTDIR (an App Execution Alias
96
+ * can refuse stat) counts as found. A PATH entry is split quote-aware (cmd.exe accepts
97
+ * "C:\a;b" as one directory), and a %VAR% the env cannot expand stays literal, as it does for cmd.exe.
98
+ *
99
+ * @param {string} command
100
+ * @param {string} [cwd] the child's working directory
101
+ * @param {object} [env] the child's merged environment
102
+ * @returns {boolean}
103
+ */
104
+ function commandMissing(command, cwd, env) {
105
+ if (process.platform !== 'win32' || typeof command !== 'string' || command.length === 0) return false;
106
+ const e = env || process.env;
107
+ // Env names are case-insensitive on Windows and a merged env can hold both `Path` and `PATH`:
108
+ // take every spelling (more candidates can only make the answer more conservative).
109
+ const all = (name) => Object.keys(e).filter((k) => k.toUpperCase() === name).map((k) => String(e[k] || ''));
110
+ const exts = all('PATHEXT').join(';').split(';').filter(Boolean);
111
+ const candidates = [command, ...(exts.length ? exts : ['.COM', '.EXE', '.BAT', '.CMD']).map((x) => command + x)];
112
+ const base = typeof cwd === 'string' && cwd.length ? path.resolve(cwd) : process.cwd();
113
+ const isPath = /[\\/]/.test(command) || path.isAbsolute(command);
114
+ const dirs = [base];
115
+ if (!isPath) {
116
+ for (const d of all('PATH').join(';').match(/(?:"[^"]*"|[^;])+/g) || []) {
117
+ const dir = d.replace(/"/g, '').trim();
118
+ if (!dir) continue;
119
+ // Expand %VAR% from the child's env where we can; an unknown one stays literal. Either way the
120
+ // extra candidate can only turn up a match (the safe direction), never hide one.
121
+ dirs.push(dir.replace(/%([^%]+)%/g, (m, v) => { const hit = all(v.toUpperCase())[0]; return hit !== undefined ? hit : m; }));
122
+ }
123
+ }
124
+ for (const dir of dirs) {
125
+ for (const name of candidates) {
126
+ try {
127
+ fs.statSync(path.resolve(dir, name));
128
+ return false;
129
+ } catch (err) {
130
+ if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) return false;
131
+ }
132
+ }
133
+ }
134
+ return true;
80
135
  }
81
136
 
82
137
  /**
@@ -161,7 +216,7 @@ class McpClient {
161
216
  this._forceLegacy = forceLegacy === true;
162
217
  // modernOnly: the MIRROR of legacyPin - refuse the legacy fallback entirely. A modernOnly
163
218
  // 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).
219
+ // with a clear error instead of silently negotiating down.
165
220
  this._modernOnly = modernOnly === true;
166
221
  // eraHint ('legacy' is the only meaningful value): the OWNER's memo of the era this upstream
167
222
  // negotiated LAST time - an observation, not a policy. It skips the server/discover probe the
@@ -413,6 +468,9 @@ class McpClient {
413
468
  // with an error that names the policy - never a silent downgrade.
414
469
  if (!discovered && this._modernOnly) {
415
470
  this.close();
471
+ // A command that was never found cannot answer discover either - say THAT, not the policy.
472
+ const missing = this._missingCommandError('McpClient: upstream could not start');
473
+ if (missing) throw missing;
416
474
  throw new Error(`McpClient: upstream "${this.id}" is modernOnly but did not answer server/discover with the 2026-07-28 protocol - refusing the legacy fallback`);
417
475
  }
418
476
 
@@ -428,10 +486,10 @@ class McpClient {
428
486
  protocolVersion: PROTOCOL_VERSION,
429
487
  clientInfo: this._clientInfo,
430
488
  // 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
489
+ // capability - without it they answer "Method not found" and never elicit at all.
490
+ // Every elicitation is handled: the elicitation bridge's MRTR path for modern callers,
491
+ // a clean decline otherwise.
492
+ // BOTH modes declared: the 2025-11-25 back-compat rule reads a bare
435
493
  // `{}` as FORM ONLY, and "Servers MUST NOT send elicitation requests with modes that
436
494
  // are not supported by the client" - so without `url` here a conformant upstream is
437
495
  // FORBIDDEN from url mode and the relay is structurally dead. Every elicit of either
@@ -441,7 +499,14 @@ class McpClient {
441
499
  this._initializeResult = result;
442
500
  } catch (err) {
443
501
  this.close();
444
- throw new Error(`McpClient: initialize failed: ${(err && err.message) || String(err)}`);
502
+ const message = `McpClient: initialize failed: ${(err && err.message) || String(err)}`;
503
+ // Name a missing working directory or command first (a spawn ENOENT names only the
504
+ // executable, and the Windows launch shim hides a missing command behind "exited (code=1)"),
505
+ // else keep the cause's code - the owner classifies by it.
506
+ const missing = this._missingCommandError(message);
507
+ if (missing) throw missing;
508
+ if (err && typeof err.code === 'string') throw Object.assign(new Error(message), { code: err.code });
509
+ throw new Error(message);
445
510
  }
446
511
  // Tell the server the handshake is complete (notification - no id, no reply).
447
512
  this._notify('notifications/initialized', {});
@@ -480,7 +545,7 @@ class McpClient {
480
545
  }
481
546
 
482
547
  /**
483
- * Era-keyed logLevel handling (0.7.0). A caller's per-request
548
+ * Era-keyed logLevel handling. A caller's per-request
484
549
  * `io.modelcontextprotocol/logLevel` must reach the upstream in the upstream's own vocabulary:
485
550
  * MODERN upstream -> left in `_meta` (the trio merge in request()/callTool() preserves it - the
486
551
  * trio does not define logLevel), because the modern severity clause obliges the upstream to
@@ -506,7 +571,10 @@ class McpClient {
506
571
  const caps = this._initializeResult && this._initializeResult.capabilities;
507
572
  if (typeof level === 'string' && caps && caps.logging && level !== this._lastSetLogLevel) {
508
573
  this._lastSetLogLevel = level;
509
- this._request('logging/setLevel', { level }).catch(() => {}); // fire-and-forget, ordered by the pipe
574
+ // Fire-and-forget, ordered by the pipe (issued BEFORE the main request). A refusal or timeout
575
+ // reverts the memo so the next call at this level asks again - memoising it as done left the
576
+ // upstream at whatever level it had for the rest of the session.
577
+ this._request('logging/setLevel', { level }).catch(() => { if (this._lastSetLogLevel === level) this._lastSetLogLevel = undefined; });
510
578
  }
511
579
  return out;
512
580
  }
@@ -539,6 +607,15 @@ class McpClient {
539
607
  * caught and rethrown clean. Extracted from connect() so probeDiscover() can share it.
540
608
  */
541
609
  _spawnChild() {
610
+ // Per-child read state starts CLEAN. On an in-place respawn (connect/probeDiscover after the
611
+ // previous child died) a half-arrived frame, a refused over-cap skip count, or the old child's
612
+ // stderr would otherwise be prepended to the new child's first bytes: the drain then swallows
613
+ // its first reply as the rest of a dead body, and every error quotes the wrong stderr. Same
614
+ // values as the constructor. The log-level memo goes too - a new process knows no level.
615
+ this._buf = Buffer.alloc(0);
616
+ this._skipRemaining = 0;
617
+ this._stderr = '';
618
+ this._lastSetLogLevel = undefined;
542
619
  try {
543
620
  // Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
544
621
  const launch = winLaunch(this._command, this._args);
@@ -550,7 +627,9 @@ class McpClient {
550
627
  });
551
628
  } catch (err) {
552
629
  this._child = null;
553
- throw new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
630
+ const failed = new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
631
+ if (err && typeof err.code === 'string') failed.code = err.code; // the owner classifies by code
632
+ throw failed;
554
633
  }
555
634
 
556
635
  if (!this._child || !this._child.stdin || !this._child.stdout) {
@@ -562,6 +641,21 @@ class McpClient {
562
641
  this._wireChild();
563
642
  }
564
643
 
644
+ /** A connect-failure Error carrying code ENOENT when the working directory or the command is
645
+ * definitely absent (commandMissing - Windows, where the launch shim hides it), else null. */
646
+ _missingCommandError(message) {
647
+ if (typeof this._cwd === 'string' && this._cwd.length && !fs.existsSync(this._cwd)) {
648
+ return Object.assign(new Error(`McpClient: working directory "${this._cwd}" does not exist - ${message}`), { code: 'ENOENT' });
649
+ }
650
+ if (!commandMissing(this._command, this._cwd, Object.assign({}, process.env, this._env))) return null;
651
+ // The cause goes FIRST: the message continues with the upstream's stderr, which a reader
652
+ // (or the aggregator's bounded stop line) may cut short.
653
+ return Object.assign(
654
+ new Error(`McpClient: command "${this._command}" was not found (looked in the working directory and on PATH) - ${message}`),
655
+ { code: 'ENOENT' }
656
+ );
657
+ }
658
+
565
659
  /**
566
660
  * Era probe - the 2026-07-28 spec's dual-era client rule for stdio: try `server/discover`
567
661
  * BEFORE any legacy `initialize`. A modern or dual-era server answers with its
@@ -578,7 +672,7 @@ class McpClient {
578
672
  try {
579
673
  return await this._request('server/discover', {
580
674
  _meta: {
581
- 'io.modelcontextprotocol/protocolVersion': '2026-07-28',
675
+ 'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
582
676
  'io.modelcontextprotocol/clientCapabilities': {},
583
677
  'io.modelcontextprotocol/clientInfo': this._clientInfo,
584
678
  },
@@ -593,8 +687,8 @@ class McpClient {
593
687
 
594
688
  /**
595
689
  * 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
690
+ * (a single-shot list would silently truncate a paginating upstream to page 1, leaving its
691
+ * later tools undiscoverable AND unrunnable). Returns the raw concatenated array
598
692
  * [{name, description, inputSchema, ...}] from every page (empty array if the server returned
599
693
  * no tools). Rejects on timeout / transport failure of ANY page.
600
694
  *
@@ -736,11 +830,16 @@ class McpClient {
736
830
  _wireChild() {
737
831
  const child = this._child;
738
832
 
739
- child.stdout.on('data', (chunk) => this._onData(chunk));
833
+ // Every handler below answers only while `child` is STILL this._child. A child that has
834
+ // exited (or been replaced by an in-place respawn) can still drain buffered stdout/stderr and
835
+ // fire 'exit'/'error' afterwards - Node emits 'exit' before the stdio streams are done - and
836
+ // those late events would otherwise land in the NEW child's read state or reject its waiters.
837
+ child.stdout.on('data', (chunk) => { if (this._child === child) this._onData(chunk); });
740
838
  child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
741
839
 
742
840
  if (child.stderr) {
743
841
  child.stderr.on('data', (d) => {
842
+ if (this._child !== child) return;
744
843
  this._stderr += d.toString('utf8');
745
844
  // Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
746
845
  if (this._stderr.length > 64 * 1024) {
@@ -756,8 +855,9 @@ class McpClient {
756
855
  // server that dies on the unknown method would otherwise corrupt a follow-up connect()). Only
757
856
  // null it if it is still THIS child (a fresh respawn may have replaced it).
758
857
  child.on('exit', (code, signal) => {
858
+ if (this._child !== child) return; // a stale child's exit: already handled or superseded
759
859
  this._connected = false;
760
- if (this._child === child) this._child = null;
860
+ this._child = null;
761
861
  this._rejectAllWaiters(
762
862
  new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
763
863
  );
@@ -765,11 +865,14 @@ class McpClient {
765
865
  });
766
866
  child.on('error', (err) => {
767
867
  // Spawn-level failure (e.g. ENOENT) - surfaces here, not as a throw from spawn().
868
+ if (this._child !== child) return; // a stale child's error: already handled or superseded
768
869
  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
- );
870
+ this._child = null;
871
+ const failed = new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`);
872
+ // Keep the code: the aggregator's reconnect ceiling classifies permanent failures by it
873
+ // (a re-wrap without it made a missing command look like any other death).
874
+ if (err && typeof err.code === 'string') failed.code = err.code;
875
+ this._rejectAllWaiters(failed);
773
876
  this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
774
877
  });
775
878
  }
@@ -1014,8 +1117,8 @@ class McpClient {
1014
1117
 
1015
1118
  if (typeof msg.method === 'string') {
1016
1119
  // 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;
1120
+ // elicitation/create, sampling/createMessage, roots/list. Dropping one would leave a
1121
+ // conformant upstream holding an open request forever. Surface to the owner;
1019
1122
  // unhandled -> answer -32601 (the honest "this client cannot do that").
1020
1123
  if (typeof this.onServerRequest === 'function') {
1021
1124
  try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
@@ -1065,6 +1168,7 @@ module.exports = {
1065
1168
  // exported for unit tests / reuse
1066
1169
  frameRequest,
1067
1170
  winLaunch,
1171
+ commandMissing,
1068
1172
  PROTOCOL_VERSION,
1069
1173
  CLIENT_INFO,
1070
1174
  };
package/src/mcp/modern.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * `server/discover`, the `subscriptions/listen` message shapes and the modern error codes - and
18
18
  * executes nothing. `server.js` (stdio) and `http-transport.js` bind it to their wires.
19
19
  *
20
- * Spec sources (RC, locked 2026-05-21; re-verify against the FINAL spec before release):
20
+ * Spec sources:
21
21
  * - modelcontextprotocol.io/specification/draft/changelog
22
22
  * - .../draft/basic/transports/streamable-http (headers, validation, error examples)
23
23
  * - .../draft/basic/versioning (era model, compatibility matrix)
@@ -64,12 +64,15 @@ const CACHE_HINTS = Object.freeze({
64
64
  discover: Object.freeze({ ttlMs: 3600000, cacheScope: 'private' }),
65
65
  });
66
66
 
67
- /** The methods whose Mcp-Name header must mirror params.name / params.uri. */
68
- const NAMED_METHODS = Object.freeze({
67
+ /** The methods whose Mcp-Name header must mirror params.name / params.uri. Null-prototype: the
68
+ * lookup is keyed by the CLIENT's method string, and a plain literal answered 'toString' /
69
+ * 'constructor' / 'valueOf' with an inherited function (truthy), demanding an Mcp-Name header for
70
+ * a method the table does not name. */
71
+ const NAMED_METHODS = Object.freeze(Object.assign(Object.create(null), {
69
72
  'tools/call': 'name',
70
73
  'resources/read': 'uri',
71
74
  'prompts/get': 'name',
72
- });
75
+ }));
73
76
 
74
77
  /** Safely pull params._meta from a parsed JSON-RPC message. Returns null when absent/malformed. */
75
78
  function getMeta(msg) {
@@ -216,8 +219,8 @@ function validateModernHeaders(headers, msg) {
216
219
 
217
220
  /** The protocol versions this dual-era server speaks (modern first; legacy negotiated via
218
221
  * initialize). Kept as a function so the legacy set is injected once by the server, not
219
- * duplicated here. server.js seeds it via setLegacyVersion() - a LIST since 0.7.0
220
- * (a single string is still accepted for compatibility). */
222
+ * duplicated here. server.js seeds it via setLegacyVersion() with a LIST
223
+ * (a single string is also accepted). */
221
224
  let _legacyVersions = [];
222
225
  function setLegacyVersion(v) {
223
226
  if (typeof v === 'string' && v.length > 0) _legacyVersions = [v];
@@ -160,7 +160,8 @@ function makeProtocol(deps) {
160
160
  try {
161
161
  const docs = await registry.instructions(name);
162
162
  if (docs === undefined || docs === null) {
163
- return errorResult(`toolfunnel_tool_instructions: no tool named "${name}"`);
163
+ return errorResult(`toolfunnel_tool_instructions: no tool named "${name}" - call toolfunnel_list_tools ` +
164
+ '(a filter narrows it) to find the right name.');
164
165
  }
165
166
  return okResult(docs);
166
167
  } catch (err) {
@@ -173,7 +174,7 @@ function makeProtocol(deps) {
173
174
  // ONLY through gatedRun (PreToolUse gate -> execute -> PostToolUse). This function
174
175
  // must NEVER call `execute` itself - that would bypass the gate and break the entire
175
176
  // safety case.
176
- async function runTool(args) {
177
+ async function runTool(args, callOpts) {
177
178
  const a = args || {};
178
179
  const name = a.name;
179
180
  if (typeof name !== 'string' || name.length === 0) {
@@ -193,7 +194,9 @@ function makeProtocol(deps) {
193
194
  // executor. A null/undefined resolution means "no such tool" - a clean error, not a throw.
194
195
  let resolution;
195
196
  try {
196
- resolution = await registry.resolveExecution(name, toolArgs);
197
+ // callOpts { meta, progress } reach an upstream tool's forward; a local register
198
+ // tool ignores them (a script has no progress channel).
199
+ resolution = await registry.resolveExecution(name, toolArgs, callOpts);
197
200
  } catch (err) {
198
201
  return errorResult(`toolfunnel_run_tool: could not resolve "${name}": ${(err && err.message) || err}`);
199
202
  }
@@ -262,7 +265,8 @@ function makeProtocol(deps) {
262
265
 
263
266
  if (!resolution || typeof resolution.execute !== 'function') {
264
267
  return errorResult(
265
- `toolfunnel_run_tool: tool "${name}" is not runnable (no executable resolution from the register)`
268
+ `toolfunnel_run_tool: tool "${name}" is not runnable (no executable resolution from the register) - ` +
269
+ 'check the name with toolfunnel_list_tools; an upstream tool is missing while its server is disconnected or disabled.'
266
270
  );
267
271
  }
268
272
 
@@ -343,7 +347,7 @@ function makeProtocol(deps) {
343
347
  // ---- dispatch -----------------------------------------------------------
344
348
  // The server's single entry point. Pure routing; never throws - an unknown tool or any
345
349
  // internal failure becomes a clear error result (defensive by construction).
346
- async function dispatch(toolName, args) {
350
+ async function dispatch(toolName, args, callOpts) {
347
351
  try {
348
352
  switch (toolName) {
349
353
  case META_TOOLS.LIST:
@@ -351,13 +355,14 @@ function makeProtocol(deps) {
351
355
  case META_TOOLS.INSTRUCTIONS:
352
356
  return await toolInstructions(args);
353
357
  case META_TOOLS.RUN:
354
- return await runTool(args);
358
+ return await runTool(args, callOpts);
355
359
  case META_TOOLS.HOWTO:
356
360
  return await howtoTool(args);
357
361
  default: {
358
362
  const known = Object.values(META_TOOLS).join(', ');
359
363
  return errorResult(
360
- `Unknown meta-tool "${toolName}". This MCP exposes only: ${known}.`
364
+ `Unknown tool "${toolName}". This MCP's own tools are: ${known}. Register and upstream tools are ` +
365
+ 'listed by toolfunnel_list_tools and run with toolfunnel_run_tool.'
361
366
  );
362
367
  }
363
368
  }