toolfunnel 0.6.0 → 0.7.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 (42) hide show
  1. package/README.md +11 -7
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +97 -18
  4. package/docs/KNOWN_BUGS.md +71 -90
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +24 -10
  7. package/hooks/hooks.manifest.json +1 -1
  8. package/hooks/scripts/example-deny-dangerous.js +12 -4
  9. package/mcp/servers/mock-upstream/server.js +36 -6
  10. package/package.json +67 -65
  11. package/src/auth/config.js +2 -2
  12. package/src/auth/resource-server.js +12 -3
  13. package/src/core/child-reaper.js +61 -0
  14. package/src/core/events.js +7 -7
  15. package/src/core/hook-engine.js +85 -31
  16. package/src/core/hook-loader.js +46 -13
  17. package/src/core/hook-runner.js +258 -38
  18. package/src/core/logger.js +138 -12
  19. package/src/core/matcher.js +59 -10
  20. package/src/core/rename-migration.js +163 -0
  21. package/src/core/requires.js +1 -1
  22. package/src/extend/add-hook.md +2 -2
  23. package/src/extend/add-mcp.md +3 -6
  24. package/src/extend/configure.md +2 -1
  25. package/src/extend/create-tool.md +1 -1
  26. package/src/extend/howto.js +3 -3
  27. package/src/extend/package.md +11 -8
  28. package/src/mcp/aggregator.js +480 -52
  29. package/src/mcp/expose-store.js +130 -13
  30. package/src/mcp/gated-run.js +26 -17
  31. package/src/mcp/http-transport.js +274 -22
  32. package/src/mcp/mcp-client.js +194 -31
  33. package/src/mcp/modern.js +44 -10
  34. package/src/mcp/server.js +513 -89
  35. package/src/tools/registry.js +172 -24
  36. package/src/ui/public/app.js +17 -4
  37. package/src/ui/server.js +297 -29
  38. package/tools/scripts/tf-hook-set.js +11 -3
  39. package/tools/tools.register.json +227 -227
  40. package/src/core/tool-registry.js +0 -180
  41. package/src/packages/packager.js +0 -715
  42. package/src/tools/drift.js +0 -138
@@ -1,11 +1,11 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * mcp-client.js - the MCP CLIENT (architecture notes §1, §7; Phase 2 aggregator).
4
+ * mcp-client.js - the MCP CLIENT: the aggregator's per-upstream connection.
5
5
  *
6
6
  * This is the REVERSE of src/mcp/server.js. Where server.js is a JSON-RPC 2.0
7
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
8
+ * UPSTREAM MCP server as a child process and talks to it over stdio. The
9
9
  * aggregator owns one McpClient per upstream MCP server, lists their tools, and
10
10
  * routes curated calls through them.
11
11
  *
@@ -90,6 +90,14 @@ function winLaunch(command, args) {
90
90
  // requestTimeoutMs - that is the dead-upstream detector.
91
91
  const PAYLOAD_METHODS = new Set(['tools/call', 'prompts/get', 'resources/read']);
92
92
 
93
+ // Hard ceiling on one buffered stdout message (matches http-transport's MAX_BODY_BYTES). A
94
+ // declared Content-Length over the cap is refused and its bytes discarded as they arrive, so
95
+ // the pipe stays in frame; a buffer past the cap with NO complete frame has no resync point,
96
+ // so the connection is dropped (the owner's reconnect machinery takes over). Without the cap
97
+ // a single giant unframed line from a broken upstream grew this buffer until the whole gateway
98
+ // process died.
99
+ const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
100
+
93
101
  class McpClient {
94
102
  /**
95
103
  * @param {object} opts
@@ -107,8 +115,14 @@ class McpClient {
107
115
  * @param {Function} [opts.onClose] called ONCE if the child dies UNEXPECTEDLY (exit/error
108
116
  * while not deliberately close()d). Lets the owner
109
117
  * (the aggregator) schedule a background reconnect.
118
+ * @param {'legacy'|'modern'} [opts.eraHint] the owner's memo of the era negotiated LAST connect.
119
+ * 'legacy' skips the server/discover probe (a reconnect
120
+ * goes straight to initialize); 'modern' is a no-op (the
121
+ * probe IS the modern handshake). An observation, not a
122
+ * policy: the owner clears its memo when a hinted
123
+ * connect fails. modernOnly always wins over the hint.
110
124
  */
111
- constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs, toolTimeoutMs, onClose, forceLegacy = false, modernOnly = false, onNotification, clientInfo } = {}) {
125
+ constructor({ id, command, args = [], env = {}, cwd, spawnImpl, requestTimeoutMs, toolTimeoutMs, onClose, forceLegacy = false, modernOnly = false, eraHint, onNotification, clientInfo } = {}) {
112
126
  if (typeof command !== 'string' || command.length === 0) {
113
127
  throw new Error('McpClient: "command" (non-empty string) is required');
114
128
  }
@@ -149,6 +163,19 @@ class McpClient {
149
163
  // upstream that does not answer server/discover with the modern protocol FAILS the connect
150
164
  // with a clear error instead of silently negotiating down (era-policy switches, 2026-07-18).
151
165
  this._modernOnly = modernOnly === true;
166
+ // eraHint ('legacy' is the only meaningful value): the OWNER's memo of the era this upstream
167
+ // negotiated LAST time - an observation, not a policy. It skips the server/discover probe the
168
+ // way forceLegacy does, but the caller is expected to clear its memo when a hinted connect
169
+ // FAILS (the upstream may have changed underneath) - which forceLegacy, being policy, never
170
+ // does. 'modern' is accepted-and-ignored: for a modern upstream the probe IS the handshake,
171
+ // so there is nothing to skip. modernOnly always wins over the hint - a policy that demands
172
+ // the probe cannot be undone by a stale observation.
173
+ this._eraHint = eraHint === 'legacy' ? 'legacy' : null;
174
+ // Era-observation confidence (set per connect()): true only when the server/discover probe was
175
+ // ANSWERED - a valid modern DiscoverResult, a non-modern reply, or a JSON-RPC rejection like
176
+ // -32601. A probe that TIMED OUT (or a skipped probe) observed nothing about the upstream's
177
+ // eras, and the owner must not memoise the legacy fallback it forced.
178
+ this._eraDefinitive = false;
152
179
  // onNotification: called with each server-initiated NOTIFICATION (a message with a method and
153
180
  // NO id) - e.g. notifications/resources/updated, .../resources/list_changed, .../prompts/
154
181
  // list_changed, .../tools/list_changed. Lets the aggregator bridge upstream change-notifications
@@ -157,7 +184,7 @@ class McpClient {
157
184
  // onServerRequest: called with each SERVER-INITIATED REQUEST (a message with a method AND an
158
185
  // id - elicitation/create, sampling/createMessage, roots/list). The owner answers it via
159
186
  // respondToServer/respondToServerError. Unset -> every such request is answered -32601, so a
160
- // conformant upstream is never left holding an unanswered request (Bridge B infrastructure).
187
+ // conformant upstream is never left holding an unanswered request (elicitation-bridge infrastructure).
161
188
  this.onServerRequest = null;
162
189
  this._onCloseFired = false; // onClose fires at most once, and never for a deliberate close()
163
190
  this._everConnected = false; // true once the handshake completed - onClose fires ONLY for the
@@ -168,6 +195,7 @@ class McpClient {
168
195
  this._connected = false;
169
196
  this._closed = false;
170
197
  this._buf = Buffer.alloc(0);
198
+ this._skipRemaining = 0; // bytes of a refused over-cap message still to discard
171
199
  this._stderr = '';
172
200
  this._nextId = 1;
173
201
  /** @type {Map<number|string, {resolve:Function, reject:Function, timer:any, method:string}>} */
@@ -203,6 +231,7 @@ class McpClient {
203
231
  throw new Error('McpClient.request: "method" (non-empty string) is required');
204
232
  }
205
233
  let p = params == null ? {} : params;
234
+ p = this._translateLogLevel(p); // era-keyed logLevel (1.4): modern keeps it, legacy gets setLevel
206
235
  // Modern upstream: ensure the per-request `_meta` protocol trio is present. MERGE with any
207
236
  // caller-supplied _meta (progressToken, trace keys survive a wrap forward) - the trio wins on
208
237
  // collision; a partial caller _meta must never reach a strict modern upstream trio-less.
@@ -231,7 +260,7 @@ class McpClient {
231
260
  }
232
261
 
233
262
  /**
234
- * Answer a SERVER-INITIATED request (Bridge B): write a JSON-RPC RESPONSE carrying the
263
+ * Answer a SERVER-INITIATED request (the elicitation bridge): write a JSON-RPC RESPONSE carrying the
235
264
  * upstream's own request id. Fire-and-forget; NEVER throws.
236
265
  * @param {number|string} id the id the UPSTREAM used on its request
237
266
  * @param {any} result the result payload (e.g. an ElicitResult { action, content })
@@ -245,7 +274,7 @@ class McpClient {
245
274
  }
246
275
 
247
276
  /**
248
- * Extend the timeout of an in-flight request (Bridge B): a mid-call elicitation proves the
277
+ * Extend the timeout of an in-flight request (the elicitation bridge): a mid-call elicitation proves the
249
278
  * upstream is alive and deliberately holding the call open while a HUMAN answers - the normal
250
279
  * dead-upstream timeout must not kill the suspension. Re-arms the waiter's timer; returns
251
280
  * false if the request already settled. NEVER throws.
@@ -338,7 +367,13 @@ class McpClient {
338
367
  // Otherwise (or when legacyPin FORCES legacy) we fall back to the LEGACY initialize handshake
339
368
  // (byte-for-byte the prior behaviour).
340
369
  let discovered = null;
341
- if (!this._forceLegacy) {
370
+ // The probe is skipped by POLICY (legacyPin) or by the owner's era MEMO (eraHint 'legacy' -
371
+ // this upstream negotiated legacy last time, so a reconnect goes straight to initialize
372
+ // instead of re-paying the probe window). modernOnly overrides the hint: that policy's whole
373
+ // point is the probe, and a stale memo must never silently downgrade it.
374
+ const skipProbe = this._forceLegacy || (this._eraHint === 'legacy' && !this._modernOnly);
375
+ this._eraDefinitive = false; // no observation yet this connect (stays false when the probe is skipped)
376
+ if (!skipProbe) {
342
377
  let probe = null;
343
378
  // The probe gets a SHORT timeout of its own: a legacy server that silently DROPS unknown
344
379
  // methods (instead of answering -32601) would otherwise stall every connect by the full
@@ -350,8 +385,12 @@ class McpClient {
350
385
  ? fullTimeout : Math.min(3000, fullTimeout || 3000);
351
386
  try {
352
387
  probe = await this._request('server/discover', { _meta: this._modernMeta() });
353
- } catch (_probeErr) {
388
+ this._eraDefinitive = true; // the probe was answered - whatever we conclude is observed
389
+ } catch (probeErr) {
354
390
  probe = null; // legacy upstream (method-not-found) - fall through to initialize
391
+ // An ANSWERED rejection (rpcError present, e.g. -32601) is a definitive legacy
392
+ // observation; a timeout/transport drop is SILENCE and proves nothing.
393
+ this._eraDefinitive = !!(probeErr && probeErr.rpcError);
355
394
  if (this._closed) throw new Error('McpClient: connect() aborted (closed during discover probe)');
356
395
  if (!this._child) this._spawnChild(); // a fragile legacy server may have died on the probe
357
396
  } finally {
@@ -390,9 +429,14 @@ class McpClient {
390
429
  clientInfo: this._clientInfo,
391
430
  // elicitation: real SDK-based servers GATE ctx.elicit() on the client declaring this
392
431
  // 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: {} },
432
+ // (found testing against real published elicitation servers, 2026-07-17). We honestly handle every elicitation now: the
433
+ // elicitation bridge's MRTR path for modern callers, a clean decline otherwise.
434
+ // BOTH modes declared (0.7.0): the 2025-11-25 back-compat rule reads a bare
435
+ // `{}` as FORM ONLY, and "Servers MUST NOT send elicitation requests with modes that
436
+ // are not supported by the client" - so without `url` here a conformant upstream is
437
+ // FORBIDDEN from url mode and the relay is structurally dead. Every elicit of either
438
+ // mode gets an answer: relayed to a caller that declared it, declined otherwise.
439
+ capabilities: { elicitation: { form: {}, url: {} } },
396
440
  });
397
441
  this._initializeResult = result;
398
442
  } catch (err) {
@@ -414,6 +458,13 @@ class McpClient {
414
458
  return this._modern === true ? 'modern' : 'legacy';
415
459
  }
416
460
 
461
+ /** @returns {boolean} true when the LAST connect's era came from an ANSWERED probe; false when
462
+ * the probe was skipped (policy/hint) or went unanswered (timeout/drop). The owner memoises
463
+ * the era only on a definitive observation - silence must never pin an era. */
464
+ get eraDefinitive() {
465
+ return this._eraDefinitive === true;
466
+ }
467
+
417
468
  /** The per-request `_meta` a modern client MUST send on every request (stdio: no headers). */
418
469
  _modernMeta() {
419
470
  return {
@@ -428,6 +479,38 @@ class McpClient {
428
479
  return this._clientInfo;
429
480
  }
430
481
 
482
+ /**
483
+ * Era-keyed logLevel handling (0.7.0). A caller's per-request
484
+ * `io.modelcontextprotocol/logLevel` must reach the upstream in the upstream's own vocabulary:
485
+ * MODERN upstream -> left in `_meta` (the trio merge in request()/callTool() preserves it - the
486
+ * trio does not define logLevel), because the modern severity clause obliges the upstream to
487
+ * WITHHOLD notifications/message when the field is absent. LEGACY upstream -> the key is
488
+ * REMOVED (no legacy schema defines it; leaking a modern reserved key is also a wrap tell)
489
+ * and, when the upstream declared the `logging` capability, translated to a `logging/setLevel`
490
+ * request ISSUED BEFORE the main request (stdio writes are ordered - the server sees the level
491
+ * first), deduped per distinct level. Best-effort: a setLevel failure never fails the main
492
+ * call. Deprecation-sized (SEP-2577): one key, one translation, no wider _meta policy.
493
+ * @param {any} params the outgoing request params
494
+ * @returns {any} params, possibly cloned with the logLevel key removed
495
+ */
496
+ _translateLogLevel(params) {
497
+ const meta = params && typeof params === 'object' && !Array.isArray(params) ? params._meta : null;
498
+ if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return params;
499
+ const level = meta['io.modelcontextprotocol/logLevel'];
500
+ if (level === undefined) return params;
501
+ if (this._modern) return params; // modern upstream: rides the forwarded _meta verbatim
502
+ const rest = { ...meta };
503
+ delete rest['io.modelcontextprotocol/logLevel'];
504
+ const out = { ...params };
505
+ if (Object.keys(rest).length) out._meta = rest; else delete out._meta;
506
+ const caps = this._initializeResult && this._initializeResult.capabilities;
507
+ if (typeof level === 'string' && caps && caps.logging && level !== this._lastSetLogLevel) {
508
+ this._lastSetLogLevel = level;
509
+ this._request('logging/setLevel', { level }).catch(() => {}); // fire-and-forget, ordered by the pipe
510
+ }
511
+ return out;
512
+ }
513
+
431
514
  /**
432
515
  * Normalise a `server/discover` result into the common identity shape wrappedIdentity + connect()
433
516
  * consumers expect ({ protocolVersion, serverInfo, capabilities, instructions }) - so a MODERN
@@ -509,16 +592,35 @@ class McpClient {
509
592
  }
510
593
 
511
594
  /**
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.
595
+ * tools/list -> the upstream's COMPLETE tool definitions, following `nextCursor` pagination
596
+ * (0.7.0 - the single-shot version silently truncated a paginating upstream to page 1,
597
+ * leaving its later tools undiscoverable AND unrunnable). Returns the raw concatenated array
598
+ * [{name, description, inputSchema, ...}] from every page (empty array if the server returned
599
+ * no tools). Rejects on timeout / transport failure of ANY page.
600
+ *
601
+ * Defensive bounds (a misbehaving upstream must not hang discovery):
602
+ * - a cursor already seen ends the loop (a repeating cursor would spin forever);
603
+ * - MAX_LIST_PAGES caps a pathological endless-unique-cursor upstream. Both end the loop
604
+ * with the pages already fetched - partial truth beats a wedged aggregator.
515
605
  * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
516
606
  */
517
607
  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 : [];
608
+ const MAX_LIST_PAGES = 64;
609
+ const tools = [];
610
+ const seen = new Set();
611
+ let cursor;
612
+ for (let page = 0; page < MAX_LIST_PAGES; page += 1) {
613
+ // Modern upstream: every request carries per-request `_meta` (stdio needs no headers).
614
+ const params = this._modern ? { _meta: this._modernMeta() } : {};
615
+ if (cursor !== undefined) params.cursor = cursor;
616
+ const result = await this._request('tools/list', params);
617
+ if (result && Array.isArray(result.tools)) tools.push(...result.tools);
618
+ const next = result && typeof result.nextCursor === 'string' && result.nextCursor.length
619
+ ? result.nextCursor : undefined;
620
+ if (next === undefined || seen.has(next)) return tools;
621
+ seen.add(next);
622
+ cursor = next;
623
+ }
522
624
  return tools;
523
625
  }
524
626
 
@@ -539,10 +641,11 @@ class McpClient {
539
641
  if (typeof name !== 'string' || name.length === 0) {
540
642
  throw new Error('McpClient.callTool: "name" (non-empty string) is required');
541
643
  }
542
- const params = { name, arguments: args == null ? {} : args };
644
+ let params = { name, arguments: args == null ? {} : args };
543
645
  if (meta && typeof meta === 'object' && !Array.isArray(meta) && Object.keys(meta).length) {
544
646
  params._meta = { ...meta };
545
647
  }
648
+ params = this._translateLogLevel(params); // era-keyed logLevel (1.4)
546
649
  // modern upstream: the per-request protocol trio always wins over caller keys
547
650
  if (this._modern) params._meta = { ...(params._meta || {}), ...this._modernMeta() };
548
651
  const result = await this._request('tools/call', params);
@@ -554,6 +657,10 @@ class McpClient {
554
657
  // structuredContent rides along verbatim - dropping it silently stripped modern upstreams'
555
658
  // structured results on the curated-direct path while the raw wrap path kept them
556
659
  if (r.structuredContent !== undefined) out.structuredContent = r.structuredContent;
660
+ // result _meta rides along too (3.10, result side): the wrap relays it verbatim (the
661
+ // upstream's identity IS the presented identity there); the funnel shaping sites strip the
662
+ // protocol-identity keys before forwarding (they own their own identity).
663
+ if (r._meta !== undefined) out._meta = r._meta;
557
664
  return out;
558
665
  }
559
666
 
@@ -583,7 +690,20 @@ class McpClient {
583
690
  }
584
691
 
585
692
  // If still alive, tree-kill so no descendant is orphaned (zombie guard).
693
+ this._treeKill(child);
694
+
695
+ this._child = null;
696
+ }
697
+
698
+ /**
699
+ * Tree-kill a child so no descendant is orphaned (win32 taskkill /T /F reaps the whole
700
+ * process tree; else child.kill()). NEVER throws - called from close() and from the
701
+ * read-buffer overflow path, both of which must not raise out of an event handler.
702
+ * @param {import('node:child_process').ChildProcess|null} child
703
+ */
704
+ _treeKill(child) {
586
705
  try {
706
+ if (!child) return;
587
707
  const alive = child.exitCode === null && child.signalCode === null && child.killed !== true;
588
708
  if (alive && typeof child.pid === 'number') {
589
709
  if (process.platform === 'win32') {
@@ -604,10 +724,8 @@ class McpClient {
604
724
  try { child.kill(); } catch (_e) { /* ignore */ }
605
725
  }
606
726
  } catch (_e) {
607
- /* never throw out of close */
727
+ /* never throws - see contract above */
608
728
  }
609
-
610
- this._child = null;
611
729
  }
612
730
 
613
731
  // ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -635,7 +753,7 @@ class McpClient {
635
753
  // Either of these means the child is gone - fail every in-flight request so nothing hangs, and
636
754
  // NULL this._child so a later connect() on the same instance respawns instead of writing to a
637
755
  // 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
756
+ // server that dies on the unknown method would otherwise corrupt a follow-up connect()). Only
639
757
  // null it if it is still THIS child (a fresh respawn may have replaced it).
640
758
  child.on('exit', (code, signal) => {
641
759
  this._connected = false;
@@ -775,31 +893,76 @@ class McpClient {
775
893
  _onData(chunk) {
776
894
  this._buf = Buffer.concat([this._buf, chunk]);
777
895
  this._drain();
896
+ if (this._buf.length > MAX_MESSAGE_BYTES) {
897
+ // Past the cap with no complete frame there is no resync point - drop the connection.
898
+ // The tree-kill surfaces as an unexpected exit: waiters reject and onClose lets the
899
+ // owner schedule a reconnect (a deliberate close() would suppress that signal and
900
+ // permanently retire this client).
901
+ this._logErr(`buffered ${this._buf.length} bytes with no complete message (cap ${MAX_MESSAGE_BYTES}); dropping the connection`);
902
+ this._buf = Buffer.alloc(0);
903
+ this._treeKill(this._child);
904
+ }
778
905
  }
779
906
 
780
907
  /**
781
908
  * Drain as many complete messages as are buffered. Header (Content-Length) framing
782
909
  * is tried FIRST (it counts exact bytes and is unambiguous), then newline-delimited
783
910
  * JSON. Mirrors server.js's dual-drain so the client reads either wire format.
911
+ * A header frame that is only PARTLY here ('wait') stops the drain - handing the
912
+ * half-arrived header to line framing would eat it and orphan the body (the frame
913
+ * was lost and the caller saw only a timeout; chunk boundaries are the transport's
914
+ * choice, so any CL-framing upstream could hit it under load).
784
915
  */
785
916
  _drain() {
786
917
  for (;;) {
787
- if (this._tryHeaderFramed()) continue;
918
+ if (this._skipRemaining > 0) {
919
+ // A refused over-cap message: discard exactly its declared bytes as they arrive so the
920
+ // pipe stays in frame for everything after it.
921
+ const eat = Math.min(this._skipRemaining, this._buf.length);
922
+ this._buf = this._buf.slice(eat);
923
+ this._skipRemaining -= eat;
924
+ if (this._skipRemaining > 0) return; // the rest of the refused body is still in flight
925
+ }
926
+ const h = this._tryHeaderFramed();
927
+ if (h === true) continue;
928
+ if (h === 'wait') break; // an incomplete header frame - wait for more bytes
788
929
  if (this._tryLineFramed()) continue;
789
930
  break;
790
931
  }
791
932
  }
792
933
 
793
- /** Pull one Content-Length-framed message off the front of the buffer. */
934
+ /** True when the buffer BEGINS with a framing header whose block terminator hasn't arrived. */
935
+ _headerInFlight() {
936
+ return /^content-(length|type):/i.test(this._buf.slice(0, 16).toString('utf8'));
937
+ }
938
+
939
+ /**
940
+ * Pull one Content-Length-framed message off the front of the buffer.
941
+ * Returns true (consumed one), false (not header framing - try line framing), or
942
+ * 'wait' (a header frame is mid-flight - stop draining until more bytes arrive).
943
+ */
794
944
  _tryHeaderFramed() {
795
945
  const headerEnd = this._buf.indexOf('\r\n\r\n');
796
- if (headerEnd === -1) return false;
946
+ if (headerEnd === -1) {
947
+ // No complete header block - but a buffer that STARTS with a header line is a header
948
+ // frame whose terminator is still in flight, not line-framed junk.
949
+ return this._headerInFlight() ? 'wait' : false;
950
+ }
797
951
  const header = this._buf.slice(0, headerEnd).toString('utf8');
798
952
  const m = /Content-Length:\s*(\d+)/i.exec(header);
799
953
  if (!m) return false; // a \r\n\r\n that isn't a Content-Length header -> not this framing
800
954
  const len = parseInt(m[1], 10);
801
955
  const bodyStart = headerEnd + 4;
802
- if (this._buf.length < bodyStart + len) return false; // body not all here yet
956
+ if (len > MAX_MESSAGE_BYTES) {
957
+ // Refuse the message, keep the connection: consume the header and let _drain discard
958
+ // exactly the declared bytes as they arrive. The reply's waiter times out with the
959
+ // standard window error; everything after the refused body still flows.
960
+ this._logErr(`upstream declared a ${len}-byte message (cap ${MAX_MESSAGE_BYTES}); skipping it`);
961
+ this._buf = this._buf.slice(bodyStart);
962
+ this._skipRemaining = len;
963
+ return true;
964
+ }
965
+ if (this._buf.length < bodyStart + len) return 'wait'; // body still in flight - hold the drain
803
966
  const body = this._buf.slice(bodyStart, bodyStart + len).toString('utf8');
804
967
  this._buf = this._buf.slice(bodyStart + len);
805
968
  this._parseAndDispatch(body);
@@ -813,9 +976,9 @@ class McpClient {
813
976
  const line = this._buf.slice(0, nl).toString('utf8').trim();
814
977
  this._buf = this._buf.slice(nl + 1);
815
978
  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.
979
+ // A header line at the FRONT of the buffer never reaches this path (header framing holds
980
+ // the drain with 'wait'); any non-JSON line here is a framing artifact - ignore it rather
981
+ // than erroring.
819
982
  if (line[0] !== '{' && line[0] !== '[') return true;
820
983
  this._parseAndDispatch(line);
821
984
  return true;
@@ -852,7 +1015,7 @@ class McpClient {
852
1015
  if (typeof msg.method === 'string') {
853
1016
  // SERVER->CLIENT REQUEST (id + method): the upstream is asking ITS client something -
854
1017
  // 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;
1018
+ // a conformant upstream holding an open request forever (the elicitation bridge). Surface to the owner;
856
1019
  // unhandled -> answer -32601 (the honest "this client cannot do that").
857
1020
  if (typeof this.onServerRequest === 'function') {
858
1021
  try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
package/src/mcp/modern.js CHANGED
@@ -113,7 +113,13 @@ function validateModernRequest(msg) {
113
113
  return {
114
114
  code: ERR_MODERN.UNSUPPORTED_PROTOCOL_VERSION,
115
115
  message: 'Unsupported protocol version',
116
- data: { supported: supportedVersions(), requested: version },
116
+ // Filtered AT THE ERROR SITE only: this `supported` answers "what may _meta
117
+ // protocolVersion carry" and the only lawful value is the modern version. Advertising the
118
+ // legacy versions here re-armed a measured retry loop - a client offered 2024-11-05
119
+ // re-pins it in _meta and is rejected forever (they are initialize-channel vocabulary,
120
+ // not _meta vocabulary). supportedVersions() itself stays whole: server/discover and
121
+ // /health legitimately span both eras.
122
+ data: { supported: [MODERN_PROTOCOL_VERSION], requested: version },
117
123
  httpStatus: 400,
118
124
  };
119
125
  }
@@ -142,6 +148,26 @@ function validateModernRequest(msg) {
142
148
  * @param {object} msg the parsed JSON-RPC body
143
149
  * @returns {null | { code:number, message:string, httpStatus:number }}
144
150
  */
151
+ /**
152
+ * Decode a header value that carries the Base64 sentinel `=?base64?{data}?=` (the
153
+ * 2026-07-28 Streamable HTTP spec, Base64-Encoded Header Values). UNCONDITIONAL on the
154
+ * sentinel shape - the spec obliges clients
155
+ * to encode ANY value that even looks like the sentinel, so there is no legal raw value of this
156
+ * shape and no try-decode-and-fall-back: a sentinel-shaped header decodes (possibly to a
157
+ * mismatching string - node's forgiving base64 makes garbage compare unequal, which fails
158
+ * closed at the comparison site). Markers are case-sensitive lowercase per spec. Non-sentinel
159
+ * values pass through verbatim.
160
+ * @param {any} v the raw header value
161
+ * @returns {any} the decoded string, or v untouched when not sentinel-shaped
162
+ */
163
+ function decodeHeaderSentinel(v) {
164
+ if (typeof v !== 'string') return v;
165
+ if (v.length >= 11 && v.startsWith('=?base64?') && v.endsWith('?=')) {
166
+ return Buffer.from(v.slice(9, -2), 'base64').toString('utf8');
167
+ }
168
+ return v;
169
+ }
170
+
145
171
  function validateModernHeaders(headers, msg) {
146
172
  const h = headers || {};
147
173
  const mismatch = (message) => ({ code: ERR_MODERN.HEADER_MISMATCH, message, httpStatus: 400 });
@@ -171,13 +197,17 @@ function validateModernHeaders(headers, msg) {
171
197
  const nameField = NAMED_METHODS[msg.method];
172
198
  if (nameField) {
173
199
  const bodyName = msg.params ? msg.params[nameField] : undefined;
174
- const headerName = h['mcp-name'];
175
- if (typeof headerName !== 'string' || headerName.length === 0) {
200
+ const rawHeaderName = h['mcp-name'];
201
+ if (typeof rawHeaderName !== 'string' || rawHeaderName.length === 0) {
176
202
  return mismatch(`Header mismatch: Mcp-Name header is required for ${msg.method}`);
177
203
  }
204
+ // 4.2: decode the Base64 sentinel BEFORE comparing (spec MUST) - tool names are only
205
+ // SHOULD-constrained to header-safe characters and resource URIs are not constrained at
206
+ // all, so an encoded Mcp-Name is a first-class shape, not an error.
207
+ const headerName = decodeHeaderSentinel(rawHeaderName);
178
208
  if (headerName !== bodyName) {
179
209
  return mismatch(
180
- `Header mismatch: Mcp-Name header value '${headerName}' does not match body value '${bodyName}'`
210
+ `Header mismatch: Mcp-Name header value '${rawHeaderName}' does not match body value '${bodyName}'`
181
211
  );
182
212
  }
183
213
  }
@@ -185,20 +215,22 @@ function validateModernHeaders(headers, msg) {
185
215
  }
186
216
 
187
217
  /** The protocol versions this dual-era server speaks (modern first; legacy negotiated via
188
- * initialize). Kept as a function so the legacy version is injected once by the server, not
189
- * duplicated here. server.js seeds it via setLegacyVersion(). */
190
- let _legacyVersion = null;
218
+ * 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). */
221
+ let _legacyVersions = [];
191
222
  function setLegacyVersion(v) {
192
- if (typeof v === 'string' && v.length > 0) _legacyVersion = v;
223
+ if (typeof v === 'string' && v.length > 0) _legacyVersions = [v];
224
+ else if (Array.isArray(v)) _legacyVersions = v.filter((x) => typeof x === 'string' && x.length > 0);
193
225
  }
194
226
  function supportedVersions() {
195
- return _legacyVersion ? [MODERN_PROTOCOL_VERSION, _legacyVersion] : [MODERN_PROTOCOL_VERSION];
227
+ return [MODERN_PROTOCOL_VERSION].concat(_legacyVersions);
196
228
  }
197
229
 
198
230
  /**
199
231
  * Decorate a result object for the modern era, in place-safe copy:
200
232
  * - `resultType: 'complete'` when absent - the preserve-if-present branch below is
201
- * LOAD-BEARING for Bridge B, whose suspensions arrive here already carrying
233
+ * LOAD-BEARING for the elicitation bridge, whose suspensions arrive here already carrying
202
234
  * `resultType: 'input_required'` and must pass through undamaged (the elicitation
203
235
  * bridge DOES originate input_required).
204
236
  * - `_meta[serverInfo]` (SHOULD on every result).
@@ -359,6 +391,8 @@ module.exports = {
359
391
  isModernRequest,
360
392
  validateModernRequest,
361
393
  validateModernHeaders,
394
+ // 4.2: exported for the transport's Mcp-Param-* handling (4.3) + tests.
395
+ decodeHeaderSentinel,
362
396
  setLegacyVersion,
363
397
  supportedVersions,
364
398
  decorateResult,