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
package/src/mcp/server.js CHANGED
@@ -2,7 +2,7 @@
2
2
  'use strict';
3
3
 
4
4
  /**
5
- * server.js - the HAND-ROLLED minimal MCP server over stdio (see the architecture notes §7).
5
+ * server.js - the HAND-ROLLED minimal MCP server over stdio.
6
6
  *
7
7
  * Runnable as: node src/mcp/server.js
8
8
  *
@@ -13,7 +13,7 @@
13
13
  * JSON-RPC methods handled (MCP, the subset our test client speaks):
14
14
  * - "initialize" -> { protocolVersion, serverInfo, capabilities: { tools: {} } }
15
15
  * - "tools/list" -> { tools: [ ...protocol.toolDefinitions() ] } (the 4 meta-tools;
16
- * a clear seam is left for curated-direct tools - Phase 2)
16
+ * a clear seam is left for curated-direct tools)
17
17
  * - "tools/call" { name, arguments } -> protocol.dispatch(name, arguments), wrapped in the
18
18
  * MCP tools/call result shape: { content:[{ type:"text", text: JSON }],
19
19
  * isError? }. On a protocol error result we set isError:true.
@@ -26,7 +26,7 @@
26
26
  * framing on the way out keeps the transport trivial and matches "newline-delimited JSON is fine
27
27
  * for our test client".
28
28
  *
29
- * Wiring (see the architecture notes §8 - Phase 1): a real Registry (loaded from
29
+ * Wiring: a real Registry (loaded from
30
30
  * src/tools/tools.register.json - the canonical tool register), a real HookEngine over the hooks
31
31
  * manifest, the real gatedRun, and the real howto. A small ADAPTER bridges the register's
32
32
  * resolveExecution({type,run}) shape to the { execute, toolName, args } shape protocol.runTool
@@ -39,10 +39,11 @@
39
39
  const fs = require('node:fs');
40
40
  const path = require('node:path');
41
41
  const { spawn } = require('node:child_process');
42
+ const reaper = require('../core/child-reaper');
42
43
 
43
44
  const { makeProtocol } = require('./protocol');
44
45
  const { gatedRun } = require('./gated-run');
45
- const { Aggregator } = require('./aggregator');
46
+ const { Aggregator, withForwardedToolMeta } = require('./aggregator');
46
47
  const { loadExposeStore } = require('./expose-store');
47
48
  const { loadRegistry } = require('../tools/registry');
48
49
  const { loadToolState, loadToolStateResult, isToolEnabled, isToolHot, getPassthrough } = require('../tools/tool-state');
@@ -73,19 +74,58 @@ const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
73
74
  const TOOL_STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
74
75
  const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
75
76
  const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
76
- // Phase 2: the persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
77
+ // The persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
77
78
  // aggregator connects to nothing and the curated-direct surface is empty - the server is
78
- // behaviourally identical to Phase 1 until an upstream + expose entry is added.
79
+ // behaviourally identical to the bare gateway until an upstream + expose entry is added.
79
80
  const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
80
81
 
82
+ // Every legacy revision this server speaks, NEWEST FIRST (0.7.0). initialize echoes the
83
+ // requested member (2.1); an unsupported string gets LEGACY_VERSIONS[0] (lifecycle SHOULD:
84
+ // "the latest version supported by the server").
85
+ const LEGACY_VERSIONS = Object.freeze(['2025-11-25', '2025-06-18', '2025-03-26', '2024-11-05']);
86
+ // Hard cap on JSON-RPC batch members (0.7.0) - see handleBatch. Well past any real client's
87
+ // batching, far short of buying unbounded gated execution with one message.
88
+ const MAX_BATCH_MEMBERS = 64;
89
+ // LOCAL shell-tool bounds (0.7.0), matching tools/registry.js's script executor: 120 s (the
90
+ // upstream PAYLOAD window) and a 4 MiB output ceiling. Without a clock a hanging shell tool
91
+ // wedged the serialised stdio chain and starved every later request, `ping` included.
92
+ // Test seam: the suite proves the timeout/cap BEHAVIOUR by shrinking the windows via env;
93
+ // production never sets them (same seam as tools/registry.js).
94
+ const SHELL_TIMEOUT_MS = Number(process.env.TOOLFUNNEL_SHELL_TIMEOUT_MS) > 0
95
+ ? Number(process.env.TOOLFUNNEL_SHELL_TIMEOUT_MS) : 120000;
96
+ const MAX_SHELL_OUTPUT_BYTES = Number(process.env.TOOLFUNNEL_SHELL_OUTPUT_CAP) > 0
97
+ ? Number(process.env.TOOLFUNNEL_SHELL_OUTPUT_CAP) : 4 * 1024 * 1024;
98
+
99
+ /**
100
+ * Kill a spawned shell AND its descendants, per platform: taskkill /T on Windows, the process
101
+ * group on POSIX (the spawn sets `detached` so the shell leads one). Killing the shell alone
102
+ * orphans grandchildren that keep the stdio pipes open. Best-effort; never throws.
103
+ * @param {import('node:child_process').ChildProcess} child
104
+ */
105
+ function killShellTree(child) {
106
+ if (!child) return;
107
+ try {
108
+ if (child.pid != null && process.platform === 'win32') {
109
+ require('node:child_process').execFile('taskkill', ['/pid', String(child.pid), '/T', '/F'], () => {});
110
+ } else if (child.pid != null) {
111
+ try { process.kill(-child.pid, 'SIGKILL'); } catch (_eg) { child.kill('SIGKILL'); }
112
+ } else {
113
+ child.kill('SIGKILL');
114
+ }
115
+ } catch (_e) {
116
+ try { child.kill(); } catch (_e2) { /* nothing more we can do */ }
117
+ }
118
+ }
119
+ // The FROZEN answer for an absent or non-string protocolVersion - byte-for-byte the 0.6.0
120
+ // behaviour (pinned frozen behaviour: absent-pass stays the oldest, not the newest).
81
121
  const PROTOCOL_VERSION = '2024-11-05';
82
122
  // The 2026-07-28 ("modern") era, as a pure module - era detection, validation, result decoration,
83
123
  // server/discover and subscriptions/listen shapes. This server is DUAL-ERA: a request carrying
84
124
  // modern per-request _meta is served under 2026-07-28 semantics; initialize selects THIS legacy
85
- // path, byte-for-byte the 0.5.0 behaviour. modern.js needs the legacy version string only for
125
+ // path, byte-for-byte the 0.5.0 behaviour. modern.js needs the legacy version list only for
86
126
  // server/discover's supportedVersions - seeded once here so it is never duplicated.
87
127
  const modern = require('./modern');
88
- modern.setLegacyVersion(PROTOCOL_VERSION);
128
+ modern.setLegacyVersion(LEGACY_VERSIONS);
89
129
  // Identity comes from the OPTIONAL toolfunnel.json at the root (absent -> name "toolfunnel",
90
130
  // version from package.json - serverInfo can never drift from the released version). The config
91
131
  // seam is what lets a wrapped MCP introduce itself as ITSELF in the initialize handshake and
@@ -161,26 +201,79 @@ function makeRegistryAdapter(registry, opts) {
161
201
  cwd: ROOT,
162
202
  env: Object.assign({}, process.env, { TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args == null ? null : args) }),
163
203
  windowsHide: true,
204
+ // POSIX process group so the timeout can kill the shell's descendants too; Windows
205
+ // uses taskkill /T inside killTree.
206
+ detached: process.platform !== 'win32',
164
207
  });
165
208
  } catch (err) {
166
209
  reject(new Error(`shell spawn failed: ${err.message}`));
167
210
  return;
168
211
  }
212
+ reaper.track(child); // last-resort sweep if the gateway dies without its kill path
169
213
  let stdout = '';
170
214
  let stderr = '';
171
215
  let settled = false;
172
- if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
173
- if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
216
+ // TIMED + BOUNDED (0.7.0) - the twin of the script executor's fix in tools/registry.js. A
217
+ // shell tool with no clock wedged the serialised stdio chain for its whole lifetime, and
218
+ // unbounded accumulation made a chatty tool a memory risk in the gateway process.
219
+ const finish = (result) => {
220
+ if (settled) return;
221
+ settled = true;
222
+ clearTimeout(timer);
223
+ // Release the pipes and unref the handle - the ChildProcess itself keeps the event loop
224
+ // referenced until the child exits (same fix as core/hook-runner.js and tools/registry.js).
225
+ for (const s of [child.stdout, child.stderr]) {
226
+ try { if (s && typeof s.destroy === 'function') s.destroy(); } catch (_e) { /* ignore */ }
227
+ }
228
+ try { if (typeof child.unref === 'function') child.unref(); } catch (_e) { /* ignore */ }
229
+ reaper.untrack(child);
230
+ resolve(result);
231
+ };
232
+ const timer = setTimeout(() => {
233
+ killShellTree(child);
234
+ finish({
235
+ ok: false,
236
+ code: -1,
237
+ stdout,
238
+ stderr: (stderr ? stderr + '\n' : '') + `shell tool timed out after ${SHELL_TIMEOUT_MS}ms and was terminated`,
239
+ timedOut: true,
240
+ });
241
+ }, SHELL_TIMEOUT_MS);
242
+ if (typeof timer.unref === 'function') timer.unref();
243
+ // setEncoding so multi-byte UTF-8 split across chunks decodes correctly; count REAL bytes
244
+ // (String.length is UTF-16 code units - non-ASCII ran ~3x past a "byte" cap).
245
+ let shOutBytes = 0;
246
+ let shErrBytes = 0;
247
+ if (child.stdout) {
248
+ child.stdout.setEncoding('utf8');
249
+ child.stdout.on('data', (d) => {
250
+ if (shOutBytes >= MAX_SHELL_OUTPUT_BYTES) return;
251
+ stdout += d;
252
+ shOutBytes += Buffer.byteLength(d, 'utf8');
253
+ if (shOutBytes >= MAX_SHELL_OUTPUT_BYTES) {
254
+ killShellTree(child);
255
+ finish({ ok: false, code: -1, stdout, stderr: stderr + '\nshell tool output exceeded the cap and was terminated', outputCapped: true });
256
+ }
257
+ });
258
+ }
259
+ if (child.stderr) {
260
+ child.stderr.setEncoding('utf8');
261
+ child.stderr.on('data', (d) => {
262
+ if (shErrBytes >= MAX_SHELL_OUTPUT_BYTES) return;
263
+ stderr += d;
264
+ shErrBytes += Buffer.byteLength(d, 'utf8');
265
+ });
266
+ }
174
267
  child.on('error', (err) => {
175
268
  if (settled) return;
176
269
  settled = true;
270
+ clearTimeout(timer);
271
+ reaper.untrack(child);
177
272
  reject(new Error(`shell spawn failed: ${err.message}`));
178
273
  });
179
274
  child.on('close', (code) => {
180
- if (settled) return;
181
- settled = true;
182
275
  const c = typeof code === 'number' ? code : -1;
183
- resolve({ ok: c === 0, code: c, stdout, stderr });
276
+ finish({ ok: c === 0, code: c, stdout, stderr });
184
277
  });
185
278
  });
186
279
  }
@@ -235,7 +328,13 @@ function makeRegistryAdapter(registry, opts) {
235
328
  // Build the { execute, toolName, args, mode } resolution protocol.runTool hands to gatedRun.
236
329
  resolveExecution(nameOrId, args) {
237
330
  const id = resolveId(nameOrId);
331
+ // ENABLED means "lean-visible AND runnable" (tool-state.js) - the off-switch must switch the
332
+ // tool OFF, not merely hide it from the lean list (0.7.0). Read FRESH like list()
333
+ // so a UI toggle is live. Disabled resolves to null -> the existing clean "not runnable"
334
+ // error; indistinguishable from absent, which is the point.
335
+ const state = toolStatePath ? loadToolState(toolStatePath) : null;
238
336
  if (id) {
337
+ if (state && !isToolEnabled(state, id)) return null;
239
338
  // LOCAL tool - byte-for-byte the pre-slice-2 path. A local name that resolves but isn't
240
339
  // runnable returns null and does NOT fall through to upstream (local-register WINS: a local
241
340
  // name never silently resolves to a remote upstream).
@@ -272,6 +371,8 @@ function makeRegistryAdapter(registry, opts) {
272
371
  // No local tool by this name - try the LEAN upstream forward. resolveLeanExecution returns a
273
372
  // resolution whose execute thunk lazy-(re)connects + unwraps; the gate matches fwd.toolName (the
274
373
  // surfaced name). mode is 'gateway' so it goes THROUGH gatedRun - never the reference handoff.
374
+ // Same enabled rule as the local branch, keyed by the SURFACED name (what the overlay stores).
375
+ if (state && !isToolEnabled(state, nameOrId)) return null;
275
376
  const agg = liveAggregator();
276
377
  if (!agg || typeof agg.resolveLeanExecution !== 'function') return null;
277
378
  let fwd = null;
@@ -286,10 +387,10 @@ function makeRegistryAdapter(registry, opts) {
286
387
  // All wiring failures are caught so the server still starts and reports a clean JSON-RPC error
287
388
  // instead of dying - a dead server hangs the client (and the test).
288
389
  //
289
- // Returns an OBJECT, not just the protocol, because the Phase-2 curated-direct call path needs
390
+ // Returns an OBJECT, not just the protocol, because the curated-direct call path needs
290
391
  // more than the protocol:
291
392
  // { protocol, aggregator, engine, ctx }
292
- // - protocol : the 4 meta-tools (unchanged Phase-1 logic) - callers needing just the
393
+ // - protocol : the 4 meta-tools (the meta-tool logic, unchanged) - callers needing just the
293
394
  // protocol read it off `.protocol` (backward compat).
294
395
  // - aggregator : the upstream-MCP connection + curated-expose set (built from expose.json).
295
396
  // Default expose.json is EMPTY so it connects to nothing.
@@ -342,12 +443,12 @@ function buildProtocol() {
342
443
  });
343
444
  build.engine = new HookEngine(loader, { cwd: ROOT });
344
445
 
345
- // Phase 2: the aggregator over expose.json (EMPTY by default -> nothing connects).
446
+ // The aggregator over expose.json (EMPTY by default -> nothing connects).
346
447
  // loadExposeStore on a missing/empty file returns an empty store; Aggregator over an empty store
347
448
  // advertises no curated-direct tools and connectAll() is an instant no-op.
348
449
  const store = loadExposeStore(EXPOSE_PATH);
349
450
  build.aggregator = new Aggregator({
350
- store, v3Root: ROOT,
451
+ store, gatewayRoot: ROOT,
351
452
  // The WRAPPED upstream is exempt from the path-isolation guard (transparent-wrapper mode,
352
453
  // by design). Fresh state read per connect, so a live wrap change counts.
353
454
  wrapTargetProvider: () => {
@@ -412,7 +513,7 @@ function makeError(id, code, message, data) {
412
513
  // A wrap must be indistinguishable from connecting to the wrapped MCP directly, so we present ITS
413
514
  // identity verbatim - name, version, capabilities, instructions - instead of ToolFunnel's own.
414
515
  function handleInitialize(params, wrapId) {
415
- // protocolVersion + serverInfo + capabilities { tools:{} } (see the architecture notes §7,
516
+ // protocolVersion + serverInfo + capabilities { tools:{} } (
416
517
  // mcp-server.test.js asserts a `tools` capability + a protocolVersion).
417
518
  // Activity log (self-gating; no-op unless logging is enabled): the CLIENT-connected half of
418
519
  // connect logging - the aggregator logs the upstream half. Wrapped though logger.log never throws.
@@ -443,11 +544,19 @@ function handleInitialize(params, wrapId) {
443
544
  return out;
444
545
  }
445
546
 
547
+ // NEGOTIATE (non-wrap only - the wrap branch above stays upstream-clamped): echo the
548
+ // requested version when we speak it; an unsupported STRING gets our latest legacy (lifecycle
549
+ // SHOULD - includes a requested 2026-07-28: initialize IS the legacy path); an absent or
550
+ // non-string request keeps the frozen 2024-11-05 answer.
551
+ const requested = params && typeof params.protocolVersion === 'string' ? params.protocolVersion : null;
552
+ const negotiated = requested === null
553
+ ? PROTOCOL_VERSION
554
+ : (LEGACY_VERSIONS.includes(requested) ? requested : LEGACY_VERSIONS[0]);
446
555
  return {
447
- protocolVersion: PROTOCOL_VERSION,
556
+ protocolVersion: negotiated,
448
557
  serverInfo: SERVER_INFO,
449
558
  capabilities: {
450
- // We are a tool host. `tools: {}` declares the capability. Phase 2 wires curated-direct
559
+ // We are a tool host. `tools: {}` declares the capability. The aggregator wires curated-direct
451
560
  // hot-updates, so we now advertise listChanged:true - the server may emit
452
561
  // notifications/tools/list_changed when the curated-direct expose set changes (see
453
562
  // emitToolsListChanged). The mcp-server.test.js assertion only requires a `tools`
@@ -472,7 +581,7 @@ function handleToolsList(protocol, aggregator, opts) {
472
581
  // Precedence is local-register-wins over an upstream of the same name (matches the lean rule), and
473
582
  // a META-tool can NEVER be shadowed by a local/upstream tool of the same name (safety). With the
474
583
  // default EMPTY state + EMPTY expose.json this is exactly the 4 meta-tools - byte-identical to the
475
- // pre-matrix surface. `opts` is OPTIONAL: a Phase-1 / bare-protocol caller (no registry/state) gets
584
+ // pre-matrix surface. `opts` is OPTIONAL: a bare-protocol caller (no registry/state) gets
476
585
  // meta-tools (all hot) + curated-direct, the prior behaviour. NEVER throws.
477
586
  const o = opts || {};
478
587
  const registry = o.registry || null;
@@ -520,12 +629,16 @@ function handleToolsList(protocol, aggregator, opts) {
520
629
  }
521
630
  }
522
631
 
523
- // 3. CURATED-DIRECT (expose[]) - unchanged. exposedToolDefinitions never throws, but guard anyway.
524
- // Skip any def colliding with a RESERVED meta name (never advertise a phantom uncallable tool).
632
+ // 3. CURATED-DIRECT (expose[]) - AND enabled: a state-disabled tool refuses to run,
633
+ // so advertising it would put a phantom uncallable tool on the surface. exposedToolDefinitions
634
+ // never throws, but guard anyway. Skip any def colliding with a RESERVED meta name, OR with a
635
+ // name a LOCAL register tool owns (id or display name) - the same guard block 4 applies below:
636
+ // the advertised name must resolve to what actually runs, and a local-register collision would
637
+ // otherwise advertise the upstream's schema while the call resolves to the local tool.
525
638
  if (aggregator && typeof aggregator.exposedToolDefinitions === 'function') {
526
639
  try {
527
640
  const defs = aggregator.exposedToolDefinitions();
528
- if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name)) byName.set(def.name, def);
641
+ if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name) && !localNameTaken(registry, def.name) && isToolEnabled(state, def.name)) byName.set(def.name, def);
529
642
  } catch (err) {
530
643
  logErr('exposedToolDefinitions failed:', (err && err.message) || String(err));
531
644
  }
@@ -537,10 +650,26 @@ function handleToolsList(protocol, aggregator, opts) {
537
650
  // (a local display-name collision would otherwise advertise the upstream but run the local tool).
538
651
  if (aggregator && typeof aggregator.leanToolDefinitions === 'function') {
539
652
  try {
540
- for (const d of aggregator.leanToolDefinitions()) {
653
+ // 6.2: iterate hot-upstream tools in STORE order (expose.json's upstream sequence), not
654
+ // connection-map order - a death/recovery re-insertion re-orders the map and would
655
+ // silently shuffle the advertised surface between restarts. Scoped HERE only: the wrap's
656
+ // passthroughDefinitions walks leanToolDefinitions itself and must stay untouched.
657
+ const leanDefs = aggregator.leanToolDefinitions();
658
+ const order = typeof aggregator.upstreamOrder === 'function' ? aggregator.upstreamOrder() : [];
659
+ const rank = new Map(order.map((id, i) => [id, i]));
660
+ const stable = leanDefs.map((d, i) => ({ d, i }));
661
+ stable.sort((a, b) => {
662
+ const ra = rank.has(a.d && a.d.upstream) ? rank.get(a.d.upstream) : order.length;
663
+ const rb = rank.has(b.d && b.d.upstream) ? rank.get(b.d.upstream) : order.length;
664
+ return ra !== rb ? ra - rb : a.i - b.i; // store order first; original order within an upstream
665
+ });
666
+ for (const { d } of stable) {
541
667
  if (!d || byName.has(d.name) || metaNames.has(d.name) || localNameTaken(registry, d.name)) continue;
542
668
  if (isToolHot(state, d.name, false) && isToolEnabled(state, d.name)) {
543
- byName.set(d.name, { name: d.name, description: d.description || '', inputSchema: d.inputSchema || { type: 'object' } });
669
+ // 3.7: the same selective-add as the aggregator's builders - the hot projection must
670
+ // never re-strip what the lean def now carries (nor leak its routing ids via spread).
671
+ byName.set(d.name, withForwardedToolMeta(
672
+ { name: d.name, description: d.description || '', inputSchema: d.inputSchema || { type: 'object' } }, d));
544
673
  }
545
674
  }
546
675
  } catch (err) {
@@ -594,7 +723,7 @@ function passthroughDefinitions(aggregator, state, upstreamId) {
594
723
  // The upstream's RAW discovered defs - a wrapped surface must advertise the tool exactly as
595
724
  // the upstream does (annotations, title, outputSchema, future fields). The old projection to
596
725
  // {name, description, inputSchema} made all 13 real server-everything tools differ from a
597
- // direct connection (wrap-lab, 2026-07-17). leanToolDefinitions still drives the walk: it
726
+ // direct connection (found wire-testing the wrap, 2026-07-17). leanToolDefinitions still drives the walk: it
598
727
  // carries the SURFACED name (the stable enabled:false key) and the collision guard.
599
728
  const rawByName = new Map();
600
729
  if (typeof aggregator.toolsByUpstream === 'function') {
@@ -719,7 +848,7 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
719
848
  };
720
849
  }
721
850
 
722
- // ── Routing (see the architecture notes §2; the curated-direct invariant in §9; the MATRIX) ────
851
+ // ── Routing (the curated-direct invariant + the MATRIX) ─────────────────────────────────────
723
852
  // 1. A meta-tool name -> protocol.dispatch (unchanged; a meta-tool can NEVER be shadowed).
724
853
  // 2. ELSE a LOCAL tool promoted HOT, called directly -> run it exactly like toolfunnel_run_tool
725
854
  // {name,args}: ONE path, reusing the PreToolUse gate + reference-mode + register resolution.
@@ -746,7 +875,7 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
746
875
  // wrap must run the upstream regardless of a local shadow; runLeanForward resolves straight off
747
876
  // the aggregator, so the gate still fires on the surfaced name and the upstream always runs.
748
877
  const passthroughId = getPassthrough(state);
749
- // Bridge B RESUME: an MRTR retry carries requestState (+ inputResponses) - answer the
878
+ // Elicitation-bridge RESUME: an MRTR retry carries requestState (+ inputResponses) - answer the
750
879
  // upstream's held question and continue the SUSPENDED call. It never re-enters the gate:
751
880
  // the gate fired when the original call started, and no new upstream call is issued.
752
881
  // Checked BEFORE the passthrough guard: a retry after the wrap was CLEARED mid-suspension
@@ -755,7 +884,8 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
755
884
  // leaking an "Unknown meta-tool" funnel tell mid-conversation. requestState
756
885
  // is protocol-level MRTR vocabulary, never ordinary tool args, so this intercept is safe.
757
886
  if (p.requestState !== undefined || p.inputResponses !== undefined) {
758
- return await resumeElicit(p, d0.modernCaller === true, passthroughId, d0.cancelKey || null);
887
+ return await resumeElicit(p, d0.modernCaller === true, passthroughId, d0.cancelKey || null,
888
+ d0.clientCapabilities || null);
759
889
  }
760
890
  if (passthroughId) {
761
891
  // The client calls the ORIGINAL tool name (as advertised); resolve it to the SURFACED name the
@@ -767,7 +897,8 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
767
897
  // for every wrapped call.
768
898
  const meta = stripProtocolMeta(p._meta);
769
899
  return await runLeanForward(aggregator, { engine: d0.engine, ctx: d0.ctx }, surfaced, args,
770
- { calledName: name, meta, cancelKey: d0.cancelKey || null, modernCaller: d0.modernCaller === true });
900
+ { calledName: name, meta, cancelKey: d0.cancelKey || null, modernCaller: d0.modernCaller === true,
901
+ clientCapabilities: d0.clientCapabilities || null });
771
902
  }
772
903
  return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
773
904
  }
@@ -790,6 +921,12 @@ async function handleToolsCall(protocol, aggregator, deps, params) {
790
921
  }
791
922
 
792
923
  if (aggregator && typeof aggregator.isExposed === 'function' && aggregator.isExposed(name)) {
924
+ // enabled:false must actually switch the tool OFF (tool-state contract: "AND runnable" -
925
+ // 0.7.0): the same clean unknown-tool error as every other not-on-the-surface call,
926
+ // so a disabled tool is indistinguishable from an absent one.
927
+ if (!isToolEnabled(state, name)) {
928
+ return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
929
+ }
793
930
  // The caller's non-protocol _meta rides the forward here too - the progressToken is the
794
931
  // upstream-side keep-alive (beats are NOT fanned out to funnel clients; wrap mode relays them).
795
932
  return await runCuratedDirect(aggregator, deps, name, args, stripProtocolMeta(p._meta));
@@ -877,7 +1014,10 @@ function stripProtocolMeta(m) {
877
1014
  if (!m || typeof m !== 'object' || Array.isArray(m)) return undefined;
878
1015
  const out = {};
879
1016
  for (const k of Object.keys(m)) {
880
- if (!k.startsWith('io.modelcontextprotocol/')) out[k] = m[k];
1017
+ // logLevel is EXEMPT (1.4): the caller's severity choice must survive to the upstream hop -
1018
+ // the McpClient owns the era boundary (modern: forwarded in _meta; legacy: translated to
1019
+ // logging/setLevel and never leaked). Every other protocol key is ours to strip.
1020
+ if (!k.startsWith('io.modelcontextprotocol/') || k === modern.META_KEYS.LOG_LEVEL) out[k] = m[k];
881
1021
  }
882
1022
  return Object.keys(out).length ? out : undefined;
883
1023
  }
@@ -929,7 +1069,7 @@ async function runCuratedDirect(aggregator, deps, name, args, meta) {
929
1069
  result && result.reason,
930
1070
  'curated tool call failed'
931
1071
  );
932
- return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
1072
+ return { content: [{ type: 'text', text: stringifyContent(withUpstreamData(message, result)) }], isError: true };
933
1073
  }
934
1074
 
935
1075
  // Success: result.output IS the upstream's own tools/call envelope ({ content, isError }).
@@ -944,6 +1084,11 @@ async function runCuratedDirect(aggregator, deps, name, args, meta) {
944
1084
  // silently stripped modern upstreams' structured results on the curated-direct path while
945
1085
  // the wrap path returned them verbatim.
946
1086
  if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
1087
+ // Result _meta forwards MINUS the protocol-identity keys (3.10, funnel half): an upstream's
1088
+ // io.modelcontextprotocol/* result keys (serverInfo!) would misidentify this gateway; app
1089
+ // keys are the client's business and ride through. Same rule as the request-side strip.
1090
+ const fm = stripProtocolMeta(out._meta);
1091
+ if (fm !== undefined) shaped._meta = fm;
947
1092
  return shaped;
948
1093
  }
949
1094
  // Defensive fallback: a non-standard upstream payload - wrap it as text so the client still gets
@@ -998,14 +1143,16 @@ async function runHotUpstreamDirect(aggregator, deps, name, args, meta) {
998
1143
  result && result.reason,
999
1144
  'tool call failed'
1000
1145
  );
1001
- return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
1146
+ return { content: [{ type: 'text', text: stringifyContent(withUpstreamData(message, result)) }], isError: true };
1002
1147
  }
1003
1148
  // Success: result.output IS the upstream's own tools/call envelope - pass it through, with
1004
- // structuredContent riding along (same shaping as runCuratedDirect).
1149
+ // structuredContent + app-key result _meta riding along (same shaping as runCuratedDirect).
1005
1150
  const out = result.output;
1006
1151
  if (out && typeof out === 'object' && Array.isArray(out.content)) {
1007
1152
  const shaped = { content: out.content, isError: out.isError === true };
1008
1153
  if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
1154
+ const fm = stripProtocolMeta(out._meta);
1155
+ if (fm !== undefined) shaped._meta = fm;
1009
1156
  return shaped;
1010
1157
  }
1011
1158
  // Defensive fallback: a non-standard upstream payload still returns a well-formed envelope.
@@ -1043,7 +1190,7 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1043
1190
  // forwardWrapped - a wrapped tools/call is the DOMINANT long-running-cancel case and was the
1044
1191
  // one hole left in the map. Registered post-gate: a cancel landing during the gate phase is a
1045
1192
  // best-effort drop (micro-window, same accepted class as the rpc-issue microtask window).
1046
- // The rpc id is ALSO captured locally: a Bridge B suspension must extend that request's
1193
+ // The rpc id is ALSO captured locally: an elicitation-bridge suspension must extend that request's
1047
1194
  // timeout (a human answers at human speed; the dead-upstream clock must not kill the hold).
1048
1195
  let upstreamRpcId = null;
1049
1196
  const track = {
@@ -1061,7 +1208,7 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1061
1208
  if (!resolution || typeof resolution.execute !== 'function') {
1062
1209
  return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1063
1210
  }
1064
- // Bridge B: the upstream may send `elicitation/create` mid-call and hold the call open. Race
1211
+ // Elicitation bridge: the upstream may send `elicitation/create` mid-call and hold the call open. Race
1065
1212
  // the real forward against an elicit INSIDE the gated execute, so the gate + PostToolUse fire
1066
1213
  // exactly once around whichever wins. An elicit win resolves gatedRun with a sentinel; the real
1067
1214
  // upstream promise keeps running and is either suspended (modern caller -> MRTR) or completed
@@ -1097,17 +1244,24 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1097
1244
  }
1098
1245
  if (result && result.ok === true && result.output && result.output.__tfElicit && elicitCaught) {
1099
1246
  const elicit = elicitCaught;
1100
- if (o.modernCaller === true) {
1247
+ // 5.1/5.2: suspension requires a modern caller that DECLARED the matching elicitation
1248
+ // capability on THIS request. Everyone else - legacy, capability-less modern, form-only
1249
+ // modern facing a url-mode question - takes the existing auto-decline branch (no new
1250
+ // error channel; handleToolsCall is shared with the frozen legacy path).
1251
+ if (o.modernCaller === true && callerAcceptsElicit(o.clientCapabilities, elicit.params)) {
1101
1252
  return suspendForElicit(elicit, realPromise, resolution.upstream, called, upstreamRpcId);
1102
1253
  }
1103
- // Legacy caller: it cannot receive a backwards request, and the relay is future work -
1104
- // DECLINE so the upstream's held call completes, then serve its actual outcome.
1105
- logErr(`bridge-b: legacy client cannot receive elicitation - auto-declined for "${called}"`);
1254
+ // The caller cannot receive this backwards request - DECLINE so the upstream's held call
1255
+ // completes, then serve its actual outcome.
1256
+ logErr(`bridge-b: caller cannot receive this elicitation (legacy, or capability not declared) - auto-declined for "${called}"`);
1106
1257
  try { elicit.client.respondToServer(elicit.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
1107
1258
  try {
1108
1259
  const out = await realPromise;
1109
1260
  result = { ok: true, blocked: false, output: out };
1110
- } catch (_err) {
1261
+ } catch (err) {
1262
+ // The declined-elicit completion can ALSO end in a raw upstream JSON-RPC error - relay it
1263
+ // with the same fidelity as the direct path.
1264
+ if (err && err.rpcError) return rpcErrorSentinel(err.rpcError);
1111
1265
  return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1112
1266
  }
1113
1267
  }
@@ -1117,6 +1271,12 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1117
1271
  Array.isArray(result.output.content)) {
1118
1272
  return result.output;
1119
1273
  }
1274
+ // A raw JSON-RPC error from the wrapped upstream relays VERBATIM: McpClient preserved
1275
+ // it as error.rpcError; ride the sentinel to the dispatch seam, where the caller's era shapes
1276
+ // the wire error. (A gate block never carries error - the guard is belt-and-braces.)
1277
+ if (result && result.ok !== true && result.blocked !== true && result.error && result.error.rpcError) {
1278
+ return rpcErrorSentinel(result.error.rpcError);
1279
+ }
1120
1280
  // A gate BLOCK carries its reason ONLY when it is deliberate operator policy. An INTERNAL
1121
1281
  // fail-closed (engine wiring/crash - gatedRun marks it internal:true) holds gateway strings:
1122
1282
  // neutralise those too.
@@ -1128,6 +1288,50 @@ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1128
1288
  return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1129
1289
  }
1130
1290
 
1291
+ /**
1292
+ * The wrap error-relay sentinel (0.7.0). A raw JSON-RPC error from the WRAPPED upstream
1293
+ * must reach the caller as a JSON-RPC ERROR (README: identity, tools, results, ERRORS,
1294
+ * notifications - byte-for-byte), never a flattened `tool "X" failed` result. The tools/call
1295
+ * handlers return result ENVELOPES, so the verbatim error rides this sentinel to the dispatch
1296
+ * seams (legacy + modern), where relayedRpcError mints the era-correct wire error.
1297
+ */
1298
+ function rpcErrorSentinel(rpcError) {
1299
+ const e = rpcError || {};
1300
+ return { __tfRpcError: { code: e.code, message: e.message, data: e.data } };
1301
+ }
1302
+
1303
+ /**
1304
+ * Mint the wire JSON-RPC error for a relayed upstream error, era-shaped:
1305
+ * - LEGACY caller: VERBATIM - code + message + data byte-for-byte (the wrap promise).
1306
+ * - MODERN caller: 2026-07-28's error-code allocation policy (base protocol, Error Handling)
1307
+ * forbids emitting implementation-band codes it does not define, and names -32602 as -32002's
1308
+ * replacement. Band codes are re-minted (-32002 -> -32602, all others -> internal) with the
1309
+ * original preserved VERBATIM under data.upstreamCode / data.upstreamData. Standard JSON-RPC
1310
+ * codes and application-space codes relay untouched in both eras.
1311
+ */
1312
+ function relayedRpcError(id, sentinel, modernCaller) {
1313
+ const e = (sentinel && sentinel.__tfRpcError) || {};
1314
+ if (!modernCaller) return makeError(id, e.code, e.message, e.data);
1315
+ const preserved = e.data !== undefined
1316
+ ? { upstreamCode: e.code, upstreamData: e.data }
1317
+ : { upstreamCode: e.code };
1318
+ if (e.code === -32002) return makeError(id, -32602, e.message, preserved);
1319
+ if (typeof e.code === 'number' && e.code >= -32099 && e.code <= -32000) {
1320
+ return makeError(id, ERR.INTERNAL, e.message, preserved);
1321
+ }
1322
+ return makeError(id, e.code, e.message, e.data);
1323
+ }
1324
+
1325
+ /** Append relayed upstream error DATA to a funnel error message (the funnel half: the
1326
+ * message + code already survive via McpClient's text; the structured data was the lost third).
1327
+ * Wire-parsed data has no cycles, but stay defensive - shaping must never throw. */
1328
+ function withUpstreamData(message, result) {
1329
+ const e = result && result.error && result.error.rpcError;
1330
+ if (!e || e.data === undefined) return message;
1331
+ try { return `${message} | upstream error data: ${JSON.stringify(e.data)}`; }
1332
+ catch (_e) { return message; }
1333
+ }
1334
+
1131
1335
  /**
1132
1336
  * Wrap a protocol dispatch result ({ ok, output, error?, reason? }) in the MCP tools/call
1133
1337
  * envelope. Extracted so the meta-tool path and the unknown-tool path share one shaping.
@@ -1146,6 +1350,8 @@ function wrapProtocolResult(result) {
1146
1350
  result && result.reason,
1147
1351
  'tool call failed'
1148
1352
  );
1353
+ // Same data preservation as the curated/hot paths - covers the lean run_tool route.
1354
+ payload = withUpstreamData(payload, result);
1149
1355
  } else if (result && result.output !== undefined) {
1150
1356
  payload = result.output;
1151
1357
  } else if (result && result.mode === 'reference') {
@@ -1222,9 +1428,9 @@ function renderUpstreamInstructions(def) {
1222
1428
  }
1223
1429
 
1224
1430
  /**
1225
- * Normalise the first argument of handleMessage / createStdioLoop into the canonical Phase-2
1431
+ * Normalise the first argument of handleMessage / createStdioLoop into the canonical full-build
1226
1432
  * build shape { protocol, aggregator, engine, ctx }. Backward compat: a caller may still pass a
1227
- * bare protocol (Phase-1 callers / older tests) - we wrap it with a null aggregator + no deps so
1433
+ * bare protocol (older tests) - we wrap it with a null aggregator + no deps so
1228
1434
  * the meta-tool path is identical and the curated-direct path is simply never taken (isExposed
1229
1435
  * is unreachable without an aggregator). A protocol is detected by its dispatch() method.
1230
1436
  * @param {object} arg either { protocol, aggregator, engine, ctx } or a bare protocol
@@ -1233,7 +1439,7 @@ function renderUpstreamInstructions(def) {
1233
1439
  function normaliseBuild(arg) {
1234
1440
  const a = arg || {};
1235
1441
  if (a && typeof a.dispatch === 'function') {
1236
- // It's a bare protocol - Phase-1 shape (no register/state -> no hot promotion; meta + curated only).
1442
+ // It's a bare protocol - the meta-only shape (no register/state -> no hot promotion; meta + curated only).
1237
1443
  return { protocol: a, aggregator: null, engine: null, ctx: null, registry: null, toolStatePath: null };
1238
1444
  }
1239
1445
  return {
@@ -1252,11 +1458,48 @@ function normaliseBuild(arg) {
1252
1458
  // Dispatch ONE parsed JSON-RPC message. Returns a response object to send, or null for
1253
1459
  // notifications / messages that take no reply. NEVER throws.
1254
1460
  //
1255
- // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
1461
+ // `build` is the full build object { protocol, aggregator, engine, ctx } (a bare protocol is
1256
1462
  // still accepted for backward compat - see normaliseBuild). The aggregator + engine + ctx are
1257
1463
  // threaded into tools/list and tools/call so the curated-direct surface is advertised and runs
1258
1464
  // THROUGH the PreToolUse gate.
1259
1465
  // ─────────────────────────────────────────────────────────────────────────────────────────────
1466
+ /**
1467
+ * handleBatch - process a JSON-RPC batch (0.7.0). Batching exists ONLY in the
1468
+ * 2025-03-26 revision (added there, removed again in 2025-06-18), so the CALLER owns the gate:
1469
+ * stdio admits a batch iff the session negotiated 2025-03-26 (build.negotiatedLegacyVersion);
1470
+ * HTTP admits on header discipline (absent-or-2025-03-26 MCP-Protocol-Version - the header
1471
+ * itself postdates the batching revision). Members run SEQUENTIALLY in arrival order through
1472
+ * handleMessage - the same ordering guarantee single messages get; a malformed member earns its
1473
+ * own -32600 entry (handleMessage already answers that shape per member).
1474
+ * @returns {Promise<object[]|object|null>} the response array; null when every member was a
1475
+ * notification (transport answers nothing / 202); or the single JSON-RPC 2.0 -32600 object
1476
+ * for an EMPTY batch.
1477
+ */
1478
+ async function handleBatch(build, arr, connKey) {
1479
+ if (!Array.isArray(arr) || arr.length === 0) {
1480
+ return makeError(null, ERR.INVALID_REQUEST, 'Invalid Request: empty batch');
1481
+ }
1482
+ // Enforced HERE so BOTH transports are covered by one rule (0.7.0):
1483
+ // - a MODERN-shaped member is illegal in a batch at all (batching is 2025-03-26 only, which
1484
+ // predates the modern era), and admitting one skipped the modern header contract entirely;
1485
+ // - members are executed SEQUENTIALLY with a gated tool call possible in every slot, so an
1486
+ // uncapped array is unbounded work bought with one message.
1487
+ if (arr.length > MAX_BATCH_MEMBERS) {
1488
+ return makeError(null, ERR.INVALID_REQUEST,
1489
+ `Invalid Request: batch too large (${arr.length} members, max ${MAX_BATCH_MEMBERS})`);
1490
+ }
1491
+ if (arr.some((m) => modern.isModernRequest(m))) {
1492
+ return makeError(null, ERR.INVALID_REQUEST,
1493
+ 'Invalid Request: modern-era messages cannot be batched (JSON-RPC batching is 2025-03-26 only)');
1494
+ }
1495
+ const out = [];
1496
+ for (const member of arr) {
1497
+ const r = await handleMessage(build, member, connKey);
1498
+ if (r != null) out.push(r);
1499
+ }
1500
+ return out.length ? out : null;
1501
+ }
1502
+
1260
1503
  async function handleMessage(build, msg, connKey) {
1261
1504
  // connKey: OPTIONAL stable per-connection identity from the transport. stdio (one client per
1262
1505
  // process) passes a constant; HTTP passes nothing - each POST is a fresh connection, so no
@@ -1286,7 +1529,14 @@ async function handleMessage(build, msg, connKey) {
1286
1529
  const text = method === 'initialize'
1287
1530
  ? 'modern-only gateway (serveLegacy:false): the legacy initialize handshake is disabled - connect with the 2026-07-28 protocol (per-request _meta)'
1288
1531
  : 'modern-only gateway (serveLegacy:false): legacy-era requests are refused - send the 2026-07-28 per-request _meta';
1289
- return makeError(id, -32020, text);
1532
+ // -32601, not -32020: the allocation policy pins -32020 to HeaderMismatch and no
1533
+ // header is involved here. A TRUE modern-only server has no legacy methods, so method-not-
1534
+ // found is the recognised shape a probing legacy client already understands (the spec's own
1535
+ // compatibility matrix row). The message keeps naming the policy + version (versioning
1536
+ // SHOULD); data.policy is the machine marker the HTTP transport's NARROW 404 remap keys on -
1537
+ // this refusal only, never ordinary -32601s. The mint stays transport-agnostic (stdio
1538
+ // reaches this same site and simply has no status to remap).
1539
+ return makeError(id, ERR.METHOD_NOT_FOUND, text, { policy: 'modern-only' });
1290
1540
  }
1291
1541
 
1292
1542
  // A message with no method but with an id is a response/garbage to us - acknowledge nothing.
@@ -1402,7 +1652,7 @@ async function handleMessage(build, msg, connKey) {
1402
1652
  if (!presented || presented.name !== wrapMirrorClientInfo.name ||
1403
1653
  presented.version !== wrapMirrorClientInfo.version) {
1404
1654
  // reconnect() settles any in-flight connect before discarding - never join a
1405
- // connect this path just poisoned.
1655
+ // connect this path just corrupted.
1406
1656
  await aggregator.reconnect(ptId);
1407
1657
  }
1408
1658
  // The (re)connect refreshed the upstream identity - recompute wrapId from it.
@@ -1412,7 +1662,13 @@ async function handleMessage(build, msg, connKey) {
1412
1662
  } catch (_e) { /* mirror is best-effort - wrapId (cached identity) still answers */ }
1413
1663
  }
1414
1664
  }
1415
- return makeResult(id, handleInitialize(msg.params, wrapId));
1665
+ const initResult = handleInitialize(msg.params, wrapId);
1666
+ // STORE what we answered (for the stdio batching gate: batching is legal
1667
+ // only on a negotiated 2025-03-26 session). stdio = one client per process, so one slot
1668
+ // is exact; HTTP is sessionless and must use header discipline instead - it never reads
1669
+ // this. Stored under wrap too: the clamped answer is still what THIS session speaks.
1670
+ if (build && typeof build === 'object') build.negotiatedLegacyVersion = initResult.protocolVersion;
1671
+ return makeResult(id, initResult);
1416
1672
  }
1417
1673
 
1418
1674
  case 'initialized':
@@ -1421,6 +1677,17 @@ async function handleMessage(build, msg, connKey) {
1421
1677
  return null;
1422
1678
 
1423
1679
  case 'tools/list':
1680
+ // 6.1, era-scoped: an unrecognised cursor answers -32602 on every cursor-AWARE revision
1681
+ // (2025-03-26+; our list is single-page so ANY present cursor is unknown). ONLY the
1682
+ // exempt 2024-11-05 keeps the frozen ignore. Scoped to STDIO sessions - the negotiated
1683
+ // version is a true session fact there; HTTP is sessionless and keeps the frozen ignore
1684
+ // for legacy bodies (documented approximation; modern bodies enforce on their own path).
1685
+ if (connKey === 'stdio' && msg.params && msg.params.cursor !== undefined &&
1686
+ build && typeof build.negotiatedLegacyVersion === 'string' &&
1687
+ build.negotiatedLegacyVersion !== PROTOCOL_VERSION) {
1688
+ return makeError(id, ERR.INVALID_PARAMS,
1689
+ `Invalid params: unknown cursor "${String(msg.params.cursor)}" (this tools/list is single-page)`);
1690
+ }
1424
1691
  return makeResult(id, handleToolsList(protocol, aggregator, { registry, toolStatePath }));
1425
1692
 
1426
1693
  case 'tools/call': {
@@ -1428,11 +1695,16 @@ async function handleMessage(build, msg, connKey) {
1428
1695
  // translation, same (connection, client id) contract as forwardWrapped.
1429
1696
  const cancelKey = (connKey !== undefined && hasId) ? `${connKey}:${id}` : null;
1430
1697
  const result = await handleToolsCall(protocol, aggregator,
1431
- { engine, ctx, registry, toolStatePath, cancelKey, modernCaller: false }, msg.params);
1698
+ // clientCapabilities EXPLICITLY null (5.1): a legacy caller declares nothing
1699
+ // per-request and can never receive a backwards request.
1700
+ { engine, ctx, registry, toolStatePath, cancelKey, modernCaller: false, clientCapabilities: null }, msg.params);
1432
1701
  // Observability (in-memory counters; never throws). Count every tools/call by name + whether
1433
1702
  // it errored (an isError envelope = a tool failure OR a PreToolUse denial). Single chokepoint
1434
1703
  // -> covers stdio AND the HTTP transport, which both route through handleMessage.
1435
- metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
1704
+ metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && (result.isError === true || result.__tfRpcError)) });
1705
+ // Relayed upstream error: a LEGACY caller gets the wrapped upstream's own
1706
+ // JSON-RPC error VERBATIM - code, message, data.
1707
+ if (result && result.__tfRpcError) return relayedRpcError(id, result, false);
1436
1708
  return makeResult(id, result);
1437
1709
  }
1438
1710
 
@@ -1532,14 +1804,14 @@ function clientInfoFor(upstreamId) {
1532
1804
  }
1533
1805
 
1534
1806
  // ─────────────────────────────────────────────────────────────────────────────────────────────
1535
- // BRIDGE B - legacy elicitation ↔ modern MRTR (the stateful adapter).
1807
+ // THE ELICITATION BRIDGE - legacy elicitation ↔ modern MRTR (the stateful adapter).
1536
1808
  //
1537
1809
  // A legacy upstream may send a SERVER-INITIATED `elicitation/create` mid-tool-call and hold the
1538
1810
  // call open until it is answered. A modern client cannot receive backwards requests (stateless
1539
1811
  // era): instead its tools/call RETURNS `resultType:"input_required"` with the question(s) and an
1540
1812
  // opaque `requestState` token, and the client RETRIES the call with `inputResponses` + the token.
1541
1813
  // ToolFunnel holds the suspended legacy call in the middle. Design + verified MRTR schema:
1542
- // toolfunnel_060_design.md §BRIDGE B (spec draft/basic/patterns/mrtr).
1814
+ // the 0.6.0 design notes (spec draft/basic/patterns/mrtr).
1543
1815
  //
1544
1816
  // Correlation (the id-translation discipline from day one): a legacy
1545
1817
  // elicit carries NO originating-call marker, so it can only be bound to an in-flight wrapped call
@@ -1654,7 +1926,7 @@ async function raceElicit(upstreamId, promise) {
1654
1926
  /** Resume a suspended elicitation (the MRTR retry): answer the upstream's held question with the
1655
1927
  * client's inputResponse, then await the held call - which may elicit AGAIN (multi-round) or
1656
1928
  * finish. Token misses get a clean, neutral error. NEVER throws. */
1657
- async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
1929
+ async function resumeElicit(params, modernCaller, currentPtId, cancelKey, clientCapabilities) {
1658
1930
  const p = params || {};
1659
1931
  const token = typeof p.requestState === 'string' ? p.requestState : null;
1660
1932
  const entry = token && modernCaller === true ? pendingElicits.get(token) : null;
@@ -1696,7 +1968,19 @@ async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
1696
1968
  if (cancelKey) inflightForwards.delete(cancelKey); // settled (or re-suspended) - retry cancel window over
1697
1969
  }
1698
1970
  if (winner.kind === 'elicit') {
1699
- return suspendForElicit(winner.value, entry.executePromise, entry.upstreamId, entry.calledName, entry.rpcId);
1971
+ // The :1699 edge, DECIDED (5.2): each modern request stands alone - the RETRY completes the
1972
+ // exchange it carries a token for regardless, but a FURTHER question requires the RETRY's
1973
+ // own declared capability. Undeclared -> the same auto-decline as the first-call path.
1974
+ if (callerAcceptsElicit(clientCapabilities, winner.value.params)) {
1975
+ return suspendForElicit(winner.value, entry.executePromise, entry.upstreamId, entry.calledName, entry.rpcId);
1976
+ }
1977
+ logErr(`bridge-b: retry did not declare the elicitation capability - auto-declined the follow-up for "${entry.calledName}"`);
1978
+ try { winner.value.client.respondToServer(winner.value.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
1979
+ try {
1980
+ const settled = await entry.executePromise;
1981
+ if (settled && typeof settled === 'object' && Array.isArray(settled.content)) return settled;
1982
+ } catch (_e) { /* falls through to the failure text */ }
1983
+ return { content: [{ type: 'text', text: `tool "${entry.calledName}" failed` }], isError: true };
1700
1984
  }
1701
1985
  const out = winner.value;
1702
1986
  if (out && typeof out === 'object' && Array.isArray(out.content)) return out; // verbatim envelope
@@ -1706,6 +1990,28 @@ async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
1706
1990
  /** Suspend a wrapped call that hit an elicitation: mint the token, hold the still-running
1707
1991
  * execute promise, extend the held request's timeout to the suspension window (a human answers
1708
1992
  * at human speed), arm the TTL, and shape the modern InputRequiredResult. */
1993
+ /**
1994
+ * callerAcceptsElicit - can THIS caller receive the given elicitation?
1995
+ * The caller's declared clientCapabilities is threaded EXPLICITLY to every decision site - no
1996
+ * silent default: an absent declaration is a caller that cannot receive backwards requests.
1997
+ * FORM mode (mode:'form' or neither mode nor url): the bare presence of `elicitation` suffices
1998
+ * (the schema's "form mode only (implicit)" example is `elicitation: {}`). URL mode
1999
+ * (mode:'url', or a url with no mode - the url IS the mode signal): requires the explicit
2000
+ * `elicitation.url` declaration. NEVER throws.
2001
+ * @param {object|null} clientCapabilities the caller's per-request declared capabilities
2002
+ * @param {object} elicitParams the upstream's elicitation/create params
2003
+ * @returns {boolean}
2004
+ */
2005
+ function callerAcceptsElicit(clientCapabilities, elicitParams) {
2006
+ const caps = clientCapabilities && typeof clientCapabilities === 'object' ? clientCapabilities : null;
2007
+ const el = caps && caps.elicitation;
2008
+ if (!el || typeof el !== 'object' || Array.isArray(el)) return false;
2009
+ const p = elicitParams && typeof elicitParams === 'object' ? elicitParams : {};
2010
+ const isUrlMode = p.mode === 'url' || (p.mode === undefined && typeof p.url === 'string');
2011
+ if (isUrlMode) return !!(el.url && typeof el.url === 'object');
2012
+ return true;
2013
+ }
2014
+
1709
2015
  function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId) {
1710
2016
  const token = require('node:crypto').randomBytes(16).toString('base64url');
1711
2017
  executePromise.catch(() => { /* held in the background - its rejection surfaces on resume */ });
@@ -1733,10 +2039,16 @@ function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId)
1733
2039
  if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
1734
2040
  pendingElicits.set(token, entry);
1735
2041
  // The MRTR result: the upstream's question under a minted key + the resume token. One
1736
- // translation applies: MRTR's ElicitRequest params REQUIRE `mode:"form"`, which pre-MRTR
1737
- // legacy upstreams never send - default-inject it when absent (the caller's own
1738
- // mode, if any, wins). Everything else passes verbatim.
1739
- const params = Object.assign({ mode: 'form' }, elicit.params);
2042
+ // translation applies (5.3, and the OLD comment here was FALSE - form's `mode` is OPTIONAL,
2043
+ // schema: `mode?: "form"`): a bare pre-MRTR question (no mode, no url) keeps the harmless
2044
+ // explicit `mode:"form"` (pinned by wrap-wire W7); a `url`-carrying question with no mode is
2045
+ // URL mode by shape - complete the REQUIRED `mode:"url"` instead of stamping `form` onto a
2046
+ // contradiction; an explicit upstream mode always passes verbatim.
2047
+ const params = (elicit.params && elicit.params.mode !== undefined)
2048
+ ? Object.assign({}, elicit.params)
2049
+ : (elicit.params && typeof elicit.params.url === 'string')
2050
+ ? Object.assign({ mode: 'url' }, elicit.params)
2051
+ : Object.assign({ mode: 'form' }, elicit.params);
1740
2052
  return {
1741
2053
  resultType: 'input_required',
1742
2054
  inputRequests: { r0: { method: 'elicitation/create', params } },
@@ -1748,7 +2060,7 @@ function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId)
1748
2060
  * armWrapChatter - sync the aggregator's wrapChatterUpstream from the on-disk wrap state.
1749
2061
  * handleMessage re-arms per message, but three paths bypass it: startup (notifications before the
1750
2062
  * first client message), a reloadExpose swap, and a listen-first modern client (the listen branch
1751
- * is transport-owned). Each of those calls this so the N3 cross-upstream filter is never unarmed.
2063
+ * is transport-owned). Each of those calls this so the cross-upstream wrap-chatter filter is never unarmed.
1752
2064
  * NEVER throws.
1753
2065
  */
1754
2066
  function armWrapChatter(build) {
@@ -1783,10 +2095,10 @@ function armWrapChatter(build) {
1783
2095
  * notifications/cancelled must be TRANSLATED before relay - relaying its requestId verbatim
1784
2096
  * would cancel the wrong upstream call. Entries live only for the duration
1785
2097
  * of the forward (deleted in the finally). Gated tools/call is NOT tracked here - full
1786
- * id-translation for the tool path arrives with Bridge B's correlation machinery. */
2098
+ * id-translation for the tool path arrives with the elicitation bridge's correlation machinery. */
1787
2099
  const inflightForwards = new Map();
1788
2100
 
1789
- /** In-flight RAW (non-tool) forwards per upstream id - part of Bridge B's binding invariant: a
2101
+ /** In-flight RAW (non-tool) forwards per upstream id - part of the elicitation bridge's binding invariant: a
1790
2102
  * resources/read or prompts/get holds an open upstream call that may legally elicit, so while
1791
2103
  * one is in flight an elicit's origin is ambiguous and must be declined. */
1792
2104
  const inflightRawForwards = new Map();
@@ -1834,10 +2146,13 @@ async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey)
1834
2146
  // Strip ONLY the modern protocol keys from _meta. The rest - progressToken, trace keys, app
1835
2147
  // keys - is legitimate in BOTH eras and a direct connection would deliver it verbatim.
1836
2148
  // (McpClient.request() re-injects the trio, merged, when the upstream itself is modern.)
2149
+ // logLevel is EXEMPT (1.4): the McpClient era-keys it (modern: forwarded; legacy: translated
2150
+ // to logging/setLevel, never leaked) - stripping it here starved wrapped upstreams of the
2151
+ // one field that authorises notifications/message.
1837
2152
  const meta = Object.assign({}, params._meta);
1838
2153
  let changed = false;
1839
2154
  for (const k of Object.keys(meta)) {
1840
- if (k.startsWith('io.modelcontextprotocol/')) { delete meta[k]; changed = true; }
2155
+ if (k.startsWith('io.modelcontextprotocol/') && k !== modern.META_KEYS.LOG_LEVEL) { delete meta[k]; changed = true; }
1841
2156
  }
1842
2157
  if (changed) {
1843
2158
  params = Object.assign({}, params);
@@ -1850,7 +2165,7 @@ async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey)
1850
2165
  // (connection, client id) - raw client ids collide across connections (focused review).
1851
2166
  const mapKey = connKey !== undefined ? `${connKey}:${id}` : null;
1852
2167
  const track = mapKey === null ? undefined : { set rpcId(v) { inflightForwards.set(mapKey, v); } };
1853
- inflightRawForwards.set(ptId, (inflightRawForwards.get(ptId) || 0) + 1); // Bridge B binding invariant
2168
+ inflightRawForwards.set(ptId, (inflightRawForwards.get(ptId) || 0) + 1); // elicitation-bridge binding invariant
1854
2169
  try {
1855
2170
  raw = await aggregator.forwardRaw(ptId, msg.method, params, track);
1856
2171
  } catch (err) {
@@ -1912,6 +2227,12 @@ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKe
1912
2227
  case 'tools/list':
1913
2228
  // A notification (no id) MUST NOT get a reply (JSON-RPC 2.0). Every case guards on hasId.
1914
2229
  if (!hasId) return null;
2230
+ // 6.1: the funnel's own list is SINGLE-PAGE, so any present cursor is unrecognised ->
2231
+ // -32602 (the modern era is cursor-aware; the transport's 4.1 map gives it HTTP 400).
2232
+ if (msg.params && msg.params.cursor !== undefined) {
2233
+ return makeError(id, ERR.INVALID_PARAMS,
2234
+ `Invalid params: unknown cursor "${String(msg.params.cursor)}" (this tools/list is single-page)`);
2235
+ }
1915
2236
  return makeResult(
1916
2237
  id,
1917
2238
  modern.decorateResult(
@@ -1925,14 +2246,20 @@ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKe
1925
2246
  const result = await handleToolsCall(
1926
2247
  protocol, aggregator,
1927
2248
  // cancelKey: same cancel-translation contract as the legacy path.
1928
- // modernCaller: a modern client can receive input_required + retry (Bridge B / MRTR).
2249
+ // modernCaller: a modern client can receive input_required + retry (the elicitation bridge / MRTR).
2250
+ // clientCapabilities (5.1, EXPLICIT - no silent default): whether THIS request may
2251
+ // receive an elicitation is decided by what THIS request declared.
1929
2252
  { engine, ctx, registry, toolStatePath, modernCaller: true,
2253
+ clientCapabilities: (modern.getMeta(msg) || {})[modern.META_KEYS.CLIENT_CAPABILITIES] || null,
1930
2254
  cancelKey: (connKey !== undefined && hasId) ? `${connKey}:${id}` : null },
1931
2255
  msg.params
1932
2256
  );
1933
2257
  // Same observability chokepoint as the legacy path - both eras count in one place.
1934
- metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
2258
+ metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && (result.isError === true || result.__tfRpcError)) });
1935
2259
  if (!hasId) return null; // notification-shaped call: executed (side effects), but never replied to
2260
+ // Relayed upstream error: mint the era-shaped JSON-RPC error BEFORE decoration -
2261
+ // an error is never dressed as a result.
2262
+ if (result && result.__tfRpcError) return relayedRpcError(id, result, true);
1936
2263
  const decorated = modern.decorateResult(result, resultServerInfo);
1937
2264
  // Legacy-shim transparency for MODERN clients: when the call ACTUALLY forwarded to a PINNED
1938
2265
  // legacy upstream, say so in result _meta - metadata, never content. NOT under a wrap: a wrap
@@ -1966,8 +2293,8 @@ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKe
1966
2293
  * emitToolsListChanged(send) - write the MCP `notifications/tools/list_changed` JSON-RPC
1967
2294
  * notification (NO id - it is a notification, not a request) via the provided `send` fn. The
1968
2295
  * server emits this when the curated-direct expose set hot-updates so a CLI that honours
1969
- * listChanged re-fetches tools/list (see the architecture notes §1 "no restart for register
1970
- * changes"; §7 the host runs the server). `send` is injected so this is unit-testable with a spy
2296
+ * listChanged re-fetches tools/list ("no restart for register
2297
+ * changes"; the host owns the server). `send` is injected so this is unit-testable with a spy
1971
2298
  * and reused by both the stdio loop and (later) the host.
1972
2299
  *
1973
2300
  * @param {(obj: object) => void} send the transport's writer (stdio loop's send, or a spy)
@@ -2013,7 +2340,7 @@ async function reloadExpose(build, send) {
2013
2340
  return;
2014
2341
  }
2015
2342
  const next = new Aggregator({
2016
- store, v3Root: ROOT,
2343
+ store, gatewayRoot: ROOT,
2017
2344
  wrapTargetProvider: () => {
2018
2345
  try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state); } catch (_e) { return null; }
2019
2346
  },
@@ -2043,7 +2370,7 @@ async function reloadExpose(build, send) {
2043
2370
  if (prevAgg && typeof prevAgg.onUpstreamNotification === 'function') {
2044
2371
  next.onUpstreamNotification = prevAgg.onUpstreamNotification;
2045
2372
  }
2046
- // Bridge B carryover - same contract as the notification bridge above: the handler closes over
2373
+ // Elicitation-bridge carryover - same contract as the notification bridge above: the handler closes over
2047
2374
  // the stable build object, so it transfers verbatim onto the swapped-in aggregator.
2048
2375
  if (prevAgg && typeof prevAgg.onUpstreamServerRequest === 'function') {
2049
2376
  next.onUpstreamServerRequest = prevAgg.onUpstreamServerRequest;
@@ -2058,6 +2385,7 @@ async function reloadExpose(build, send) {
2058
2385
  // to silently kill every agreed subscription channel).
2059
2386
  if (prevAgg && prevAgg._subscribedUris instanceof Set && prevAgg._subscribedUris.size) {
2060
2387
  next._subscribedUris = new Set(prevAgg._subscribedUris);
2388
+ if (prevAgg._subscribedRefs instanceof Map) next._subscribedRefs = new Map(prevAgg._subscribedRefs); // 5.4
2061
2389
  }
2062
2390
  let res = null;
2063
2391
  try {
@@ -2074,7 +2402,7 @@ async function reloadExpose(build, send) {
2074
2402
  try {
2075
2403
  const prev = build.aggregator;
2076
2404
  build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
2077
- armWrapChatter(build); // the fresh aggregator's N3 filter must not wait for a client message
2405
+ armWrapChatter(build); // the fresh aggregator's wrap-chatter filter must not wait for a client message
2078
2406
  if (prev && typeof prev.closeAll === 'function') {
2079
2407
  try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
2080
2408
  }
@@ -2322,12 +2650,20 @@ function startConfigWatchers(buildArg, send) {
2322
2650
  // stdin framing reader - accepts BOTH Content-Length framing and newline-delimited JSON.
2323
2651
  // We write newline-delimited JSON on stdout.
2324
2652
  //
2325
- // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
2653
+ // `build` is the full build object { protocol, aggregator, engine, ctx } (a bare protocol is
2326
2654
  // accepted too - see normaliseBuild). It is threaded straight into handleMessage so tools/list
2327
2655
  // advertises the curated-direct surface and tools/call gates curated-direct calls.
2328
2656
  // ─────────────────────────────────────────────────────────────────────────────────────────────
2657
+ // Hard ceiling on one buffered stdin message (matches http-transport's MAX_BODY_BYTES and the
2658
+ // client-side cap in mcp-client.js). A declared Content-Length over the cap is refused with the
2659
+ // same clean -32700 the HTTP transport gives an over-cap body, and its bytes are discarded as
2660
+ // they arrive so the pipe stays in frame; a buffer past the cap with NO complete frame has no
2661
+ // resync point, so the session ends instead of buffering toward OOM.
2662
+ const MAX_MESSAGE_BYTES = 4 * 1024 * 1024;
2663
+
2329
2664
  function createStdioLoop(build) {
2330
2665
  let buf = Buffer.alloc(0);
2666
+ let skipRemaining = 0; // bytes of a refused over-cap message still to discard
2331
2667
  // Serialise message handling so responses are written in request order even though handlers
2332
2668
  // are async (avoids interleaving partial writes on stdout).
2333
2669
  let chain = Promise.resolve();
@@ -2402,6 +2738,18 @@ function createStdioLoop(build) {
2402
2738
  // never saw the request. `started` closes the mid-processing race: once the link runs,
2403
2739
  // the cancel falls through to handleMessage, whose in-flight map translates + relays.
2404
2740
  if (msg.params && msg.params.requestId !== undefined) {
2741
+ // 5.4: HONOUR a cancel naming a LISTEN id - terminate that subscription: release its
2742
+ // listen-owned resource URIs and drop the registration. Per the cancellation rules a
2743
+ // cancelled request gets NO further messages, so no closing result is sent.
2744
+ const li = modernSubs.findIndex((sub) => sub.id === msg.params.requestId);
2745
+ if (li !== -1) {
2746
+ const sub = modernSubs.splice(li, 1)[0];
2747
+ if (sub && sub.agreed && Array.isArray(sub.agreed.resourceSubscriptions)) {
2748
+ const agg = build && build.aggregator;
2749
+ if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(sub.agreed.resourceSubscriptions);
2750
+ }
2751
+ return; // the cancel is consumed - it named a listen, not an in-flight call
2752
+ }
2405
2753
  const queued = queuedRequests.get(msg.params.requestId);
2406
2754
  if (queued) {
2407
2755
  // Started or not: a cancelled request receives NO response (spec). For a STARTED
@@ -2430,6 +2778,15 @@ function createStdioLoop(build) {
2430
2778
  if (reg.cancelled) { queuedRequests.delete(msg.id); return; } // pre-cancelled: no reply (spec)
2431
2779
  }
2432
2780
  try {
2781
+ // JSON-RPC BATCH: admitted ONLY on a session that negotiated 2025-03-26 -
2782
+ // the one revision that defines batching. Any other session lets the array fall through
2783
+ // to handleMessage's frozen -32600 rejection below. Ordered on the same chain as every
2784
+ // single message, so batch members can never overtake their neighbours.
2785
+ if (Array.isArray(msg) && build && build.negotiatedLegacyVersion === '2025-03-26') {
2786
+ const batched = await handleBatch(build, msg, 'stdio');
2787
+ if (batched != null) send(batched);
2788
+ return;
2789
+ }
2433
2790
  // subscriptions/listen (modern) is TRANSPORT-owned: the ack and every later notification
2434
2791
  // ride this same stdout pipe, and the final result is written when stdin closes (see the
2435
2792
  // 'end' handler). Everything else routes through handleMessage unchanged.
@@ -2457,8 +2814,18 @@ function createStdioLoop(build) {
2457
2814
  // ack) must not accumulate a duplicate registration -> doubled notifications forever
2458
2815
  //Overwrite the existing entry; otherwise append.
2459
2816
  const existing = modernSubs.findIndex((s) => s.id === msg.id);
2460
- if (existing !== -1) modernSubs[existing] = { id: msg.id, agreed };
2461
- else modernSubs.push({ id: msg.id, agreed });
2817
+ if (existing !== -1) {
2818
+ // 5.4: the REPLACED registration's resource subscriptions were a leak site -
2819
+ // release them before the overwrite (the new agreed set took its own refs above).
2820
+ const old = modernSubs[existing];
2821
+ if (old && old.agreed && Array.isArray(old.agreed.resourceSubscriptions)) {
2822
+ const agg = build && build.aggregator;
2823
+ if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(old.agreed.resourceSubscriptions);
2824
+ }
2825
+ modernSubs[existing] = { id: msg.id, agreed };
2826
+ } else {
2827
+ modernSubs.push({ id: msg.id, agreed });
2828
+ }
2462
2829
  send(modern.listenAck(msg.id, agreed));
2463
2830
  return;
2464
2831
  }
@@ -2475,17 +2842,37 @@ function createStdioLoop(build) {
2475
2842
  });
2476
2843
  }
2477
2844
 
2845
+ // True when `buf` BEGINS with a framing header whose block terminator hasn't arrived yet.
2846
+ function headerInFlight() {
2847
+ return /^content-(length|type):/i.test(buf.slice(0, 16).toString('utf8'));
2848
+ }
2849
+
2478
2850
  // Try to pull one Content-Length-framed message off the front of `buf`. Returns true if it
2479
- // consumed one (and enqueued it), false if there isn't a complete header-framed message yet.
2851
+ // consumed one (and enqueued it), false if this isn't header framing, or 'wait' if a header
2852
+ // frame is only PARTLY here - the drain must hold rather than let line framing eat the header
2853
+ // (the eaten header orphaned the body: the frame was lost and the client saw only a timeout).
2480
2854
  function tryHeaderFramed() {
2481
2855
  const headerEnd = buf.indexOf('\r\n\r\n');
2482
- if (headerEnd === -1) return false;
2856
+ if (headerEnd === -1) {
2857
+ // No complete header block - but a buffer that STARTS with a header line is a header
2858
+ // frame whose terminator is still in flight, not line-framed junk.
2859
+ return headerInFlight() ? 'wait' : false;
2860
+ }
2483
2861
  const header = buf.slice(0, headerEnd).toString('utf8');
2484
2862
  const m = /Content-Length:\s*(\d+)/i.exec(header);
2485
2863
  if (!m) return false; // header block without a Content-Length -> not this framing
2486
2864
  const len = parseInt(m[1], 10);
2487
2865
  const bodyStart = headerEnd + 4;
2488
- if (buf.length < bodyStart + len) return false; // body not all here yet
2866
+ if (len > MAX_MESSAGE_BYTES) {
2867
+ // Refuse the message, keep the session: answer the same clean -32700 the HTTP transport
2868
+ // gives an over-cap body, consume the header, and let drain() discard exactly the
2869
+ // declared bytes as they arrive so the pipe stays in frame.
2870
+ send(makeError(null, ERR.PARSE, 'Parse error: message too large'));
2871
+ buf = buf.slice(bodyStart);
2872
+ skipRemaining = len;
2873
+ return true;
2874
+ }
2875
+ if (buf.length < bodyStart + len) return 'wait'; // body still in flight - hold the drain
2489
2876
  const body = buf.slice(bodyStart, bodyStart + len).toString('utf8');
2490
2877
  buf = buf.slice(bodyStart + len);
2491
2878
  let obj = null;
@@ -2507,9 +2894,9 @@ function createStdioLoop(build) {
2507
2894
  const line = buf.slice(0, nl).toString('utf8').trim();
2508
2895
  buf = buf.slice(nl + 1);
2509
2896
  if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
2510
- // A leftover header line ("Content-Length: ...") that wasn't consumed by tryHeaderFramed means
2511
- // the body hasn't arrived; but since we always try header framing FIRST, a stray header line
2512
- // here is junk - ignore non-JSON lines rather than erroring on framing artifacts.
2897
+ // A header line at the FRONT of the buffer never reaches this path (header framing holds the
2898
+ // drain with 'wait'); any non-JSON line here is a framing artifact - ignore it rather than
2899
+ // erroring.
2513
2900
  if (line[0] !== '{' && line[0] !== '[') return true;
2514
2901
  let obj = null;
2515
2902
  try {
@@ -2524,9 +2911,20 @@ function createStdioLoop(build) {
2524
2911
 
2525
2912
  function drain() {
2526
2913
  // Header framing first (it counts exact bytes and is unambiguous); fall back to line framing.
2527
- // Loop until neither can make progress.
2914
+ // Loop until neither can make progress. 'wait' stops the drain outright: a header frame is
2915
+ // mid-flight and line framing must not be allowed to eat its header.
2528
2916
  for (;;) {
2529
- if (tryHeaderFramed()) continue;
2917
+ if (skipRemaining > 0) {
2918
+ // A refused over-cap message: discard exactly its declared bytes as they arrive so the
2919
+ // pipe stays in frame for everything after it.
2920
+ const eat = Math.min(skipRemaining, buf.length);
2921
+ buf = buf.slice(eat);
2922
+ skipRemaining -= eat;
2923
+ if (skipRemaining > 0) return; // the rest of the refused body is still in flight
2924
+ }
2925
+ const h = tryHeaderFramed();
2926
+ if (h === true) continue;
2927
+ if (h === 'wait') break; // an incomplete header frame - wait for more bytes
2530
2928
  if (tryLineFramed()) continue;
2531
2929
  break;
2532
2930
  }
@@ -2535,12 +2933,35 @@ function createStdioLoop(build) {
2535
2933
  process.stdin.on('data', (chunk) => {
2536
2934
  buf = Buffer.concat([buf, chunk]);
2537
2935
  drain();
2936
+ if (buf.length > MAX_MESSAGE_BYTES) {
2937
+ // Past the cap with no complete frame there is no resync point. Flush a clean parse
2938
+ // error, then end the session (the stdio session IS the process) instead of buffering
2939
+ // toward OOM. The exit waits for the error to flush - a hard exit can drop unflushed
2940
+ // pipe bytes - with a timer backstop in case the pipe never drains.
2941
+ logErr(`stdin buffered ${buf.length} bytes with no complete message (cap ${MAX_MESSAGE_BYTES}); ending session`);
2942
+ buf = Buffer.alloc(0);
2943
+ let line;
2944
+ try {
2945
+ line = JSON.stringify(makeError(null, ERR.PARSE, 'Parse error: message too large'));
2946
+ } catch (_e) {
2947
+ line = '{"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error: message too large"}}';
2948
+ }
2949
+ try { process.stdin.destroy(); } catch (_e) { /* ignore */ }
2950
+ setTimeout(() => process.exit(1), 500);
2951
+ process.stdout.write(line + '\n', () => process.exit(1));
2952
+ }
2538
2953
  });
2539
2954
  process.stdin.on('end', () => {
2540
2955
  // Flush any complete trailing message, then gracefully close each modern listen subscription
2541
2956
  // (the spec ends a subscription with a final RESULT to the listen id) before the process exits.
2542
2957
  drain();
2543
2958
  for (const sub of modernSubs.splice(0)) {
2959
+ // 5.4: release the subscription's listen-owned resource URIs (the wholesale close was a
2960
+ // leak site - the upstream kept emitting updates nobody could receive).
2961
+ if (sub && sub.agreed && Array.isArray(sub.agreed.resourceSubscriptions)) {
2962
+ const agg = build && build.aggregator;
2963
+ if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(sub.agreed.resourceSubscriptions);
2964
+ }
2544
2965
  try { send(modern.listenClose(sub.id)); } catch (_e) { /* pipe may already be gone */ }
2545
2966
  }
2546
2967
  });
@@ -2561,7 +2982,7 @@ function createStdioLoop(build) {
2561
2982
  // Entry point.
2562
2983
  // ─────────────────────────────────────────────────────────────────────────────────────────────
2563
2984
  async function main() {
2564
- // The Phase-2 build object { protocol, aggregator, engine, ctx }.
2985
+ // The full build object { protocol, aggregator, engine, ctx }.
2565
2986
  let build;
2566
2987
  try {
2567
2988
  build = buildProtocol();
@@ -2596,7 +3017,7 @@ async function main() {
2596
3017
  logErr('aggregator.connectAll threw (ignored):', (err && err.message) || String(err));
2597
3018
  }
2598
3019
  }
2599
- armWrapChatter(build); // N3 filter armed from boot - not only from the first client message
3020
+ armWrapChatter(build); // wrap-chatter filter armed from boot - not only from the first client message
2600
3021
 
2601
3022
  // Keep the process alive on the stdio loop; never let an unhandled rejection kill it.
2602
3023
  process.on('uncaughtException', (err) => logErr('uncaughtException:', (err && err.stack) || String(err)));
@@ -2622,7 +3043,7 @@ async function main() {
2622
3043
  if (wrapOnly && uid !== wrapOnly) return;
2623
3044
  loop.notify(n);
2624
3045
  };
2625
- // Bridge B: server-initiated upstream requests (elicitation/create) route to the bridge; it
3046
+ // Elicitation bridge: server-initiated upstream requests (elicitation/create) route to the bridge; it
2626
3047
  // binds them to the one in-flight wrapped call or declines. `build` is the stable stdio object.
2627
3048
  if (build.aggregator) build.aggregator.onUpstreamServerRequest =
2628
3049
  (uid, m, c) => handleUpstreamServerRequest(build, uid, m, c);
@@ -2650,24 +3071,27 @@ module.exports = {
2650
3071
  buildProtocol,
2651
3072
  makeRegistryAdapter,
2652
3073
  handleMessage,
3074
+ // 2.3: the batch processor - the TRANSPORT owns the admission gate (stdio: negotiated
3075
+ // 2025-03-26 session; HTTP: absent-or-2025-03-26 header).
3076
+ handleBatch,
2653
3077
  handleInitialize,
2654
3078
  // The stdio entry point - an external bin/ can require this module and call main() to start the
2655
3079
  // server (build -> connect upstreams -> run the stdio loop). main() is async and takes no args.
2656
3080
  main,
2657
- // Phase-2 + MATRIX handlers: handleToolsList(protocol, aggregator, { registry?, toolStatePath? })
3081
+ // Full-build + MATRIX handlers: handleToolsList(protocol, aggregator, { registry?, toolStatePath? })
2658
3082
  // and handleToolsCall(protocol, aggregator, { engine, ctx, registry?, toolStatePath? }, params).
2659
3083
  handleToolsList,
2660
3084
  handleToolsCall,
2661
- // N3 filter arming - the HTTP transport calls this at start/reload and in its listen branch.
3085
+ // wrap-chatter filter arming - the HTTP transport calls this at start/reload and in its listen branch.
2662
3086
  armWrapChatter,
2663
- // Bridge B: the HTTP transport wires this as the aggregator's onUpstreamServerRequest handler.
3087
+ // Elicitation bridge: the HTTP transport wires this as the aggregator's onUpstreamServerRequest handler.
2664
3088
  handleUpstreamServerRequest,
2665
3089
  // Matrix helpers (exported for unit tests / reuse).
2666
3090
  localHotDefinitions,
2667
3091
  localToolDefinition,
2668
3092
  isPromotedLocal,
2669
3093
  isPromotedUpstream,
2670
- // Phase-2: emit the hot-update notification (no-id notifications/tools/list_changed) via send.
3094
+ // Emit the hot-update notification (no-id notifications/tools/list_changed) via send.
2671
3095
  emitToolsListChanged,
2672
3096
  // Hot-reload: re-read on-disk config live (no restart). Wired to fs.watch in main().
2673
3097
  reloadExpose,