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,12 +1,12 @@
1
1
  'use strict';
2
2
 
3
3
  /**
4
- * http-transport.js - the HTTP/SSE transport (the design doc §7).
4
+ * http-transport.js - the HTTP/SSE transport.
5
5
  *
6
6
  * The SAME hand-rolled MCP protocol as the stdio `server.js`, served over HTTP/SSE so a
7
7
  * LONG-LIVED host process can HOST the server and any CLI client connects to it over
8
- * `localhost:<port>` - instead of the CLI spawning a short-lived stdio child. This is the
9
- * north-star rung: the host owns the server, not a console.
8
+ * `localhost:<port>` - instead of the CLI spawning a short-lived stdio child. The point:
9
+ * the host owns the server, not a console.
10
10
  *
11
11
  * It is THIN. It reuses `server.js` for ALL JSON-RPC routing:
12
12
  * - the 4 lean meta-tools (toolfunnel_list_tools / toolfunnel_tool_instructions / toolfunnel_run_tool / toolfunnel_howto),
@@ -15,7 +15,8 @@
15
15
  * shape the HTTP response. The protocol logic is identical to stdio precisely because
16
16
  * protocol.js + the server's handleMessage are transport-free.
17
17
  *
18
- * Endpoints (bind 127.0.0.1 only; non-loopback Host headers are rejected defensively):
18
+ * Endpoints (bind 127.0.0.1 only; with auth off, non-loopback Host headers AND present
19
+ * non-loopback Origin headers are rejected defensively - see isLoopbackOrigin for the policy):
19
20
  * - POST /mcp : one JSON-RPC request -> 200 application/json (a result) | 202 no body
20
21
  * (a notification, handleMessage returned null) | a -32700 parse-error
21
22
  * object at HTTP 200 for bad JSON. An oversized body is rejected with the
@@ -45,7 +46,7 @@
45
46
  const http = require('node:http');
46
47
 
47
48
  const serverModule = require('./server');
48
- const { handleMessage } = serverModule;
49
+ const { handleMessage, handleBatch } = serverModule;
49
50
  // The 2026-07-28 ("modern") era shapes - era detection, header validation, subscriptions/listen.
50
51
  // This transport is DUAL-ERA: modern requests get strict header enforcement + the listen stream;
51
52
  // legacy requests (initialize-handshake clients, the GET /mcp SSE stream) are byte-for-byte 0.5.0.
@@ -71,6 +72,11 @@ const SSE_KEEPALIVE_MS = 25000;
71
72
  // Hard cap on a POSTed JSON-RPC body so a pathological/huge request can't exhaust memory. A
72
73
  // JSON-RPC tools/call payload is tiny; 4 MiB is generous headroom. Over the cap -> -32700.
73
74
  const MAX_BODY_BYTES = 4 * 1024 * 1024;
75
+ // Hard cap on JSON-RPC batch members (0.7.0). MAX_BODY_BYTES alone is no bound on WORK: a
76
+ // tools/call member is ~100 bytes, so 4 MiB buys tens of thousands of gated invocations - each
77
+ // spawning hook children and a tool child - executed serially while the socket is held. 64 is well
78
+ // past any real client's batching and far short of a denial of service.
79
+ const MAX_BATCH_MEMBERS = 64;
74
80
  // RFC 9728 Protected Resource Metadata path - served UNAUTHENTICATED (it is the discovery document
75
81
  // that tells a client which authorization server to use), and ONLY when auth is enabled.
76
82
  const WELL_KNOWN_PRM = '/.well-known/oauth-protected-resource';
@@ -126,7 +132,7 @@ function makeLog(logger) {
126
132
  };
127
133
  }
128
134
 
129
- /** Default build factory - the real Phase-2 build over the on-disk (EMPTY by default) expose.json. */
135
+ /** Default build factory - the real full build over the on-disk (EMPTY by default) expose.json. */
130
136
  function defaultBuildFactory() {
131
137
  return require('./server').buildProtocol();
132
138
  }
@@ -157,6 +163,40 @@ function isLoopbackHost(hostHeader) {
157
163
  return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
158
164
  }
159
165
 
166
+ /**
167
+ * Is the Origin header acceptable? OUR validation policy (an engineering call for a loopback
168
+ * gateway, not a spec quote), era-independent - it gates the HTTP request before any JSON-RPC
169
+ * body is read, so legacy and modern callers are treated identically:
170
+ *
171
+ * - ABSENT -> pass. Non-browser clients (stdio bridges, SDKs, curl) send no Origin; requiring
172
+ * one would break every legitimate client we serve.
173
+ * - PRESENT + loopback host part -> pass (a local web UI is a first-class client).
174
+ * - PRESENT + anything else -> reject. This is the DNS-rebinding / cross-origin-CSRF guard: a
175
+ * browser page on disallowed.example can script POSTs at 127.0.0.1 and the browser attaches its
176
+ * Origin - the one header the page cannot forge. Includes the opaque `Origin: null`
177
+ * (sandboxed iframes / file:// pages): present-and-not-loopback is the rejection rule.
178
+ *
179
+ * The caller applies this only when auth is DISABLED - with auth ON the bearer token is the
180
+ * boundary (same rationale as the Host guard) and remote browser clients legitimately present
181
+ * non-loopback Origins.
182
+ * @param {string|undefined} originHeader the raw `Origin` request header
183
+ * @returns {boolean}
184
+ */
185
+ function isLoopbackOrigin(originHeader) {
186
+ if (originHeader == null || originHeader === '') return true; // absent -> not a browser context
187
+ const raw = String(originHeader).trim();
188
+ // An Origin is scheme://host[:port] - no path. Anything without "://" (including the literal
189
+ // opaque token "null") is not a loopback web origin: reject.
190
+ const sep = raw.indexOf('://');
191
+ if (sep === -1) return false;
192
+ // An EMPTY host part ("http://") must reject HERE: isLoopbackHost passes '' because a request
193
+ // with no Host header at all is legitimate (HTTP/1.0), but this Origin IS present - a hostless
194
+ // origin is not a loopback web origin (origin.test.js 4b).
195
+ const hostPart = raw.slice(sep + 3);
196
+ if (hostPart === '') return false;
197
+ return isLoopbackHost(hostPart);
198
+ }
199
+
160
200
  /**
161
201
  * Read a request body up to MAX_BODY_BYTES. Resolves the buffered string, or rejects 'too large'.
162
202
  * On over-cap it does NOT destroy the request - destroying here tears down the shared req/res
@@ -301,7 +341,7 @@ function createHttpMcpServer(opts = {}) {
301
341
  const log = makeLog(o.logger);
302
342
 
303
343
  // ── Mutable runtime state ─────────────────────────────────────────────────────────────────
304
- /** @type {object|null} the current Phase-2 build { protocol, aggregator, engine, ctx }. */
344
+ /** @type {object|null} the current full build { protocol, aggregator, engine, ctx }. */
305
345
  let build = null;
306
346
  /** @type {import('node:http').Server|null} */
307
347
  let httpServer = null;
@@ -323,6 +363,8 @@ function createHttpMcpServer(opts = {}) {
323
363
  /** @type {boolean} latch so stop() is idempotent and start() can't double-bind. */
324
364
  let started = false;
325
365
  let stopping = false;
366
+ // 4.4: modern tools/call sockets closed before the response was written (per host lifetime).
367
+ let disconnectCancels = 0;
326
368
  /** @type {(() => void)|null} teardown for the config hot-reload watchers (armed in start()). */
327
369
  let configWatchersStop = null;
328
370
  /**
@@ -438,9 +480,22 @@ function createHttpMcpServer(opts = {}) {
438
480
  */
439
481
  function openListenStream(req, res, msg) {
440
482
  const subId = msg.id;
441
- // A listen-FIRST modern client bypasses handleMessage - arm the N3 filter here too.
483
+ // A listen-FIRST modern client bypasses handleMessage - arm the wrap-chatter filter here too.
442
484
  if (typeof serverModule.armWrapChatter === 'function') serverModule.armWrapChatter(build);
443
485
  const { agreed } = modern.normaliseListenFilter(msg.params);
486
+
487
+ // At capacity -> refuse the NEW stream with a clear error BEFORE any SSE bytes are written;
488
+ // existing streams are never evicted (they may carry subscriptions the client relies on).
489
+ //
490
+ // ORDER MATTERS (0.7.0): this check used to sit AFTER subscribeResources below, and the 503
491
+ // return path registers no cleanup - so a refused listen took refcounts it never released.
492
+ // Each leaked ref pinned its URI above zero (no resources/unsubscribe ever sent) and kept it
493
+ // in the replay set, re-subscribed on every future upstream reconnect, unboundedly.
494
+ if (modernListeners.size >= MODERN_LISTENERS_MAX) {
495
+ return sendJson(res, 503, makeErrorWithId(subId, ERR.INTERNAL,
496
+ 'listen stream limit reached (' + MODERN_LISTENERS_MAX + ') - close an existing subscriptions/listen stream first'));
497
+ }
498
+
444
499
  // resourceSubscriptions is only agreed when some upstream can DELIVER per-URI updates (modern
445
500
  // era emits spontaneously; a subscribe-capable legacy upstream gets the subscribes forwarded).
446
501
  // An ack must never promise a dead channel.
@@ -453,13 +508,6 @@ function createHttpMcpServer(opts = {}) {
453
508
  }
454
509
  }
455
510
 
456
- // At capacity -> refuse the NEW stream with a clear error BEFORE any SSE bytes are written;
457
- // existing streams are never evicted (they may carry subscriptions the client relies on).
458
- if (modernListeners.size >= MODERN_LISTENERS_MAX) {
459
- return sendJson(res, 503, makeErrorWithId(subId, ERR.INTERNAL,
460
- 'listen stream limit reached (' + MODERN_LISTENERS_MAX + ') - close an existing subscriptions/listen stream first'));
461
- }
462
-
463
511
  res.writeHead(200, {
464
512
  'Content-Type': 'text/event-stream',
465
513
  'Cache-Control': 'no-store',
@@ -490,6 +538,14 @@ function createHttpMcpServer(opts = {}) {
490
538
  const cleanup = () => {
491
539
  clearInterval(keepAlive);
492
540
  modernListeners.delete(entry);
541
+ // 5.4: the stream WAS the subscription - closing it releases the listen-owned resource
542
+ // URIs (this cleanup previously released nothing: leak site #2). _released guards the
543
+ // up-to-four close/error events that all route here.
544
+ if (entry._released !== true && entry.agreed && Array.isArray(entry.agreed.resourceSubscriptions)) {
545
+ entry._released = true;
546
+ const agg = build && build.aggregator;
547
+ if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(entry.agreed.resourceSubscriptions);
548
+ }
493
549
  };
494
550
  req.on('close', cleanup);
495
551
  req.on('error', cleanup);
@@ -548,7 +604,7 @@ function createHttpMcpServer(opts = {}) {
548
604
  */
549
605
  function wireAggregatorNotify() {
550
606
  if (build && build.aggregator) {
551
- // Arm the N3 cross-upstream filter from wiring time - bridged notifications can arrive
607
+ // Arm the cross-upstream wrap-chatter filter from wiring time - bridged notifications can arrive
552
608
  // before the first client message computes the wrap state.
553
609
  if (typeof serverModule.armWrapChatter === 'function') serverModule.armWrapChatter(build);
554
610
  build.aggregator.onToolsChanged = () => broadcastToolsListChanged();
@@ -569,7 +625,7 @@ function createHttpMcpServer(opts = {}) {
569
625
  try { pushToSse(n); } catch (_e) { /* legacy SSE best-effort */ }
570
626
  try { pushToListeners(n); } catch (_e) { /* modern listeners best-effort */ }
571
627
  };
572
- // Bridge B: elicitations from the wrapped upstream bind to the one in-flight wrapped call
628
+ // Elicitation bridge: elicitations from the wrapped upstream bind to the one in-flight wrapped call
573
629
  // (or decline). The handler reads `build` live via this closure, same as the bridge above.
574
630
  if (typeof serverModule.handleUpstreamServerRequest === 'function') {
575
631
  build.aggregator.onUpstreamServerRequest = (uid, m, c) =>
@@ -697,6 +753,48 @@ function createHttpMcpServer(opts = {}) {
697
753
  return sendJson(res, 200, makeError(ERR.PARSE, 'Parse error: invalid JSON body'));
698
754
  }
699
755
 
756
+ // ── JSON-RPC batch ─────────────────────────────────────────────────────────────
757
+ // HTTP is sessionless, so the header is the only era evidence. ABSENT passes - 2025-03-26
758
+ // (the one revision that defines batching) PREDATES the MCP-Protocol-Version header, which
759
+ // 2025-06-18 introduced; an explicit 2025-03-26 passes; any other value lets the array fall
760
+ // through to handleMessage's frozen -32600. Mechanics per the 2025-03-26 Transports spec:
761
+ // solely-notifications -> 202 no body; any requests -> one application/json array; an empty
762
+ // batch -> 400 + the id-less -32600 ("server cannot accept the input").
763
+ if (Array.isArray(msg)) {
764
+ const hv = req.headers && req.headers['mcp-protocol-version'];
765
+ if (hv === undefined || hv === '2025-03-26') {
766
+ // A MODERN-shaped member cannot legally travel in a batch: batching exists ONLY in
767
+ // 2025-03-26, which predates the modern era entirely. Refusing here closes an era-mixing
768
+ // hole (0.7.0): this branch runs BEFORE bodyIsModern below, so a modern body wrapped in a
769
+ // one-element array used to skip validateModernHeaders, the Mcp-Param cross-check and the
770
+ // disconnect-cancel accounting, and still be SERVED with full modern semantics because
771
+ // handleMessage re-detects the era from the body.
772
+ if (msg.some((m) => modern.isModernRequest(m))) {
773
+ return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
774
+ 'modern-era messages cannot be batched - JSON-RPC batching is 2025-03-26 only; send each modern request as its own POST'));
775
+ }
776
+ // A batch is executed member-by-member with a gated tool call possible in every slot, so an
777
+ // uncapped array is one POST buying unbounded sequential execution (0.7.0).
778
+ if (msg.length > MAX_BATCH_MEMBERS) {
779
+ return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
780
+ `batch too large (${msg.length} members, max ${MAX_BATCH_MEMBERS}) - split it`));
781
+ }
782
+ let batched;
783
+ try {
784
+ batched = await handleBatch(build, msg);
785
+ } catch (err) {
786
+ log('handleBatch threw (should not):', (err && err.stack) || String(err));
787
+ return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error'));
788
+ }
789
+ if (batched == null) {
790
+ res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
791
+ return res.end();
792
+ }
793
+ if (!Array.isArray(batched)) return sendJson(res, 400, batched); // empty batch
794
+ return sendJson(res, 200, batched);
795
+ }
796
+ }
797
+
700
798
  // ── Modern era (2026-07-28): strict header enforcement + the listen stream ────────────────
701
799
  // Era detection is by BODY (per-request _meta - the spec's dual-era rule); initialize always
702
800
  // selects legacy. Legacy requests skip ALL of this and flow byte-for-byte as 0.5.0 (their
@@ -717,6 +815,16 @@ function createHttpMcpServer(opts = {}) {
717
815
  if (vErr) {
718
816
  return sendJson(res, vErr.httpStatus, makeErrorWithId(msg.id, vErr.code, vErr.message, vErr.data));
719
817
  }
818
+ // 4.3: x-mcp-header parameter mirroring - the 2026-07-28 Streamable HTTP spec's Server
819
+ // Validation MUST: a PRESENT Mcp-Param-{name} header must equal the body argument at the
820
+ // advertised annotation's path (split-source-of-truth guard: a proxy routing on the
821
+ // header while we execute on the body). Values ride the 4.2 sentinel. Absent headers
822
+ // pass (mirroring is the CLIENT's obligation); unknown Mcp-Param names are ignored.
823
+ // The numeric 42.0==42 note is a SHOULD - scoped out; primitives compare canonically.
824
+ if (msg.method === 'tools/call' && msg.params && typeof msg.params.name === 'string') {
825
+ const pErr = validateMcpParamHeaders(build, req.headers, msg.params);
826
+ if (pErr) return sendJson(res, 400, makeErrorWithId(msg.id, modern.ERR_MODERN.HEADER_MISMATCH, pErr));
827
+ }
720
828
  // subscriptions/listen is transport-owned: the POST's response IS the SSE stream.
721
829
  if (msg.method === 'subscriptions/listen') {
722
830
  if (msg.id === undefined || msg.id === null) {
@@ -738,6 +846,47 @@ function createHttpMcpServer(opts = {}) {
738
846
  : 'Header mismatch: MCP-Protocol-Version announces ' + modern.MODERN_PROTOCOL_VERSION +
739
847
  ' but the body carries no matching _meta protocolVersion';
740
848
  return sendJson(res, 400, makeErrorWithId(msg && msg.id, modern.ERR_MODERN.HEADER_MISMATCH, message));
849
+ } else if (headerVersion !== undefined && !modern.supportedVersions().includes(headerVersion)) {
850
+ // The 2025-06-18 MUST: "a request with an invalid or unsupported
851
+ // MCP-Protocol-Version" gets 400. Only PRESENT-and-unknown values land here: absent stays
852
+ // the frozen lenient pass (the spec's own default-assumption case + every pre-2025-06-18
853
+ // client), supported legacy values pass, and the modern value is owned by the
854
+ // HeaderMismatch arm above (it demands matching modern _meta). Composition with 2.3:
855
+ // an array with a SUPPORTED non-batching header keeps the frozen -32600 (the batch gate
856
+ // declined, this arm passes); an array with an UNKNOWN header lands here and 400s - it is
857
+ // still a request with an invalid header, exactly what the MUST covers.
858
+ // -32022 (UnsupportedProtocolVersion), NOT the generic -32600. Same condition, same code,
859
+ // wherever it is detected: the _meta path already answers -32022 with supported/requested,
860
+ // and a client should not have to know WHERE the server noticed to parse the reply. The
861
+ // data payload is the useful part - it names the versions to retry with, so a client can
862
+ // renegotiate without a human reading the message string. Unlike the _meta path (whose one
863
+ // lawful value is the modern version) the HEADER may carry any era we speak, so the full
864
+ // supported set is advertised here.
865
+ return sendJson(res, 400, makeErrorWithId(msg && msg.id, modern.ERR_MODERN.UNSUPPORTED_PROTOCOL_VERSION,
866
+ `Unsupported protocol version: MCP-Protocol-Version header '${headerVersion}' (supported: ` +
867
+ modern.supportedVersions().join(', ') + ')',
868
+ { supported: modern.supportedVersions(), requested: headerVersion }));
869
+ }
870
+
871
+ // 4.4 - disconnect-as-cancellation (DECIDED; the trade STATED): the modern spec's
872
+ // stream-close-MUST is written for per-request SSE streams; we answer plain JSON, so the
873
+ // only close signal is the bare POST socket - ambiguous (proxy timeout, keep-alive reset,
874
+ // real client abort). Decided: treat a premature close as cancellation anyway, gated on
875
+ // modern tools/call. ACCEPTED CONSEQUENCE, documented: a proxy-killed socket spuriously
876
+ // cancels the RESPONSE of a live gated call (the upstream work stops only where a cancel
877
+ // path exists - sessionless HTTP has no id translation to relay one). Riders honoured:
878
+ // the cancellation is audit-logged and counted on /health for a client that comes back.
879
+ let modernDisconnected = false;
880
+ if (bodyIsModern && msg.method === 'tools/call') {
881
+ res.once('close', () => {
882
+ if (res.writableEnded) return; // normal completion - not a cancellation
883
+ modernDisconnected = true;
884
+ disconnectCancels += 1;
885
+ logger.log({
886
+ type: 'client', event: 'disconnect-cancel', method: msg.method,
887
+ tool: msg.params && msg.params.name, id: msg.id,
888
+ });
889
+ });
741
890
  }
742
891
 
743
892
  let response;
@@ -750,6 +899,10 @@ function createHttpMcpServer(opts = {}) {
750
899
  return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err)));
751
900
  }
752
901
 
902
+ // 4.4: a cancelled request gets NO further messages (MUST) - the socket is gone anyway;
903
+ // returning here keeps the contract explicit and the counter honest.
904
+ if (modernDisconnected) return;
905
+
753
906
  // A notification (no reply) -> 202 Accepted, no body. A request -> 200 with the JSON-RPC result.
754
907
  if (response == null) {
755
908
  res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
@@ -761,9 +914,76 @@ function createHttpMcpServer(opts = {}) {
761
914
  if (modern.isModernRequest(msg) && response && response.error && response.error.code === -32601) {
762
915
  return sendJson(res, 404, response);
763
916
  }
917
+ // 4.1 - the modern-error status map: in-handler mints inherit their spec-mandated status by
918
+ // CODE. -32602 is a MUST-400 (2026-07-28 base protocol, Error Handling); the three modern codes
919
+ // (-32020/-32021/-32022) are 400 wherever they arise (the seam validators already 400 their
920
+ // own). Legacy bodies keep the JSON-RPC-over-HTTP 200-with-error convention untouched.
921
+ if (modern.isModernRequest(msg) && response && response.error &&
922
+ (response.error.code === -32602 || response.error.code === -32020 ||
923
+ response.error.code === -32021 || response.error.code === -32022)) {
924
+ return sendJson(res, 400, response);
925
+ }
926
+ // NARROW remap for the modern-only refusal ONLY: a legacy body refused under
927
+ // serveLegacy:false carries the mint's data.policy marker - give it the same 404 a real
928
+ // modern-only server presents a probing legacy client. Gated on the exact marker so every
929
+ // ORDINARY legacy -32601 keeps its frozen 200-with-error (the golden diff enforces that).
930
+ if (response && response.error && response.error.code === -32601 &&
931
+ response.error.data && response.error.data.policy === 'modern-only') {
932
+ return sendJson(res, 404, response);
933
+ }
764
934
  return sendJson(res, 200, response);
765
935
  }
766
936
 
937
+ /**
938
+ * validateMcpParamHeaders - the 4.3 Server-Validation check. Resolves the ADVERTISED def for
939
+ * params.name off the live surface (handleToolsList - the same assembly the client saw, so
940
+ * the annotation map can never disagree with the advertisement; the ingest hygiene in the
941
+ * aggregator has already stripped invalid annotations). Walks the pure-`properties` chains
942
+ * collecting x-mcp-header names, then compares each PRESENT `mcp-param-<name>` header
943
+ * (sentinel-decoded) against the body argument at that path. Returns a mismatch message, or
944
+ * null when valid. NEVER throws - a lookup failure validates nothing (fail open here: the
945
+ * body is still the single execution source of truth downstream).
946
+ */
947
+ function validateMcpParamHeaders(build, headers, params) {
948
+ try {
949
+ const list = serverModule.handleToolsList(build.protocol, build.aggregator,
950
+ { registry: build.registry, toolStatePath: build.toolStatePath });
951
+ const def = ((list && list.tools) || []).find((t) => t && t.name === params.name);
952
+ const schema = def && def.inputSchema;
953
+ if (!schema || typeof schema !== 'object') return null;
954
+ const annotations = []; // { header: lowercased header name, path: [prop, ...] }
955
+ (function walk(node, pathSoFar) {
956
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
957
+ const props = node.properties;
958
+ if (!props || typeof props !== 'object' || Array.isArray(props)) return;
959
+ for (const [key, sub] of Object.entries(props)) {
960
+ if (!sub || typeof sub !== 'object' || Array.isArray(sub)) continue;
961
+ const p = pathSoFar.concat(key);
962
+ if (typeof sub['x-mcp-header'] === 'string') {
963
+ annotations.push({ header: 'mcp-param-' + sub['x-mcp-header'].toLowerCase(), path: p });
964
+ }
965
+ walk(sub, p); // nested objects stay on the chain via their own `properties`
966
+ }
967
+ })(schema, []);
968
+ const args = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
969
+ for (const a of annotations) {
970
+ const raw = headers && headers[a.header];
971
+ if (raw === undefined) continue; // absent -> the client mirrored nothing; nothing to cross-check
972
+ const headerValue = modern.decodeHeaderSentinel(String(raw));
973
+ let bodyValue = args;
974
+ for (const k of a.path) {
975
+ bodyValue = bodyValue && typeof bodyValue === 'object' ? bodyValue[k] : undefined;
976
+ }
977
+ if (bodyValue === undefined || String(bodyValue) !== headerValue) {
978
+ return `Header mismatch: ${a.header} value '${String(raw)}' does not match the body argument at '${a.path.join('.')}'`;
979
+ }
980
+ }
981
+ return null;
982
+ } catch (_e) {
983
+ return null; // never let validation break the request path
984
+ }
985
+ }
986
+
767
987
  /**
768
988
  * The single node:http request listener. Dispatches by method + path. async (it may await the
769
989
  * OAuth gate). NEVER throws - start() wraps it so a rejection can't escape into the http server.
@@ -787,6 +1007,16 @@ function createHttpMcpServer(opts = {}) {
787
1007
  return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Host'));
788
1008
  }
789
1009
 
1010
+ // Origin guard: with auth OFF, a PRESENT non-loopback Origin is refused - the
1011
+ // browser-attached Origin is the one header a third-party page cannot forge, so this closes the
1012
+ // DNS-rebinding / cross-origin-CSRF hole the bind address alone cannot (a browser on ANY site
1013
+ // can reach 127.0.0.1). Absent Origin passes (non-browser clients). Policy + parsing rules
1014
+ // live on isLoopbackOrigin(). With auth ON the token is the boundary, exactly like the Host
1015
+ // guard above.
1016
+ if (!authEnabled && !isLoopbackOrigin(req.headers && req.headers.origin)) {
1017
+ return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Origin'));
1018
+ }
1019
+
790
1020
  // Parse the path only (ignore query) without pulling in node:url's WHATWG parser overhead.
791
1021
  const rawUrl = req.url || '/';
792
1022
  const qIndex = rawUrl.indexOf('?');
@@ -828,16 +1058,27 @@ function createHttpMcpServer(opts = {}) {
828
1058
  // alias (the older HTTP+SSE shape, deprecated but still supported). Both routes
829
1059
  // hand off to the same openSse() - the loopback/auth guard above already gated the request.
830
1060
  if (method === 'GET' && (pathName === '/mcp' || pathName === '/mcp/sse')) {
831
- // MODERN-ONLY POLICY: the GET-SSE stream is a LEGACY-era channel (modern uses
832
- // subscriptions/listen on POST) - refuse it with the policy named, like every other
833
- // legacy-shaped request (era-policy switches, 2026-07-18).
1061
+ // MODERN-ONLY POLICY (0.7.0): the GET-SSE stream is a LEGACY-era channel (modern uses
1062
+ // subscriptions/listen on POST) - answer the spec's own compatibility shape, 405 Method
1063
+ // Not Allowed + Allow: POST (the Streamable HTTP spec's rule; a MUST across three revisions).
1064
+ // Replaces the old 400/-32020 - HeaderMismatch was an allocation-policy misuse here too.
834
1065
  if (build && build.serveLegacy === false) {
835
- return sendJson(res, 400, makeError(-32020,
836
- 'modern-only gateway (serveLegacy:false): the legacy SSE channel is disabled - use subscriptions/listen'));
1066
+ res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1067
+ return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
1068
+ 'modern-only gateway (serveLegacy:false): the legacy SSE channel is disabled - POST subscriptions/listen instead')));
837
1069
  }
838
1070
  return openSse(req, res);
839
1071
  }
840
1072
 
1073
+ // 4.5: modern-only mode answers DELETE on the MCP endpoint with 405 too (older-transport
1074
+ // session teardown - the spec's compatibility table). Dual-era keeps the frozen 404.
1075
+ if (method === 'DELETE' && (pathName === '/mcp' || pathName === '/mcp/sse') &&
1076
+ build && build.serveLegacy === false) {
1077
+ res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1078
+ return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
1079
+ 'modern-only gateway (serveLegacy:false): sessions are not a modern mechanism - POST requests instead')));
1080
+ }
1081
+
841
1082
  if (method === 'GET' && pathName === '/health') {
842
1083
  return sendJson(res, 200, health());
843
1084
  }
@@ -922,6 +1163,9 @@ function createHttpMcpServer(opts = {}) {
922
1163
  protocolVersions: modern.supportedVersions(),
923
1164
  // Open modern subscriptions/listen streams (legacy SSE clients are sseClientCount).
924
1165
  listenStreams: modernListeners.size,
1166
+ // 4.4 rider: modern POST tools/call sockets that closed before the response was written -
1167
+ // treated as cancellation (audit-logged); a returning client discovers the count here.
1168
+ disconnectCancels,
925
1169
  // The MCP endpoint the CLI points at (includes the /mcp path) - matches the .mcp.json
926
1170
  // target and what the status tooltip should show, not the bare origin.
927
1171
  url: boundPort == null ? null : currentUrl() + '/mcp',
@@ -1252,6 +1496,13 @@ function createHttpMcpServer(opts = {}) {
1252
1496
  if (next && next.aggregator && prevAggForSubs &&
1253
1497
  prevAggForSubs._subscribedUris instanceof Set && prevAggForSubs._subscribedUris.size) {
1254
1498
  next.aggregator._subscribedUris = new Set(prevAggForSubs._subscribedUris);
1499
+ // Carry the REFCOUNTS too, exactly as reloadExpose does (server.js). Without them the new
1500
+ // aggregator held the URIs with an empty refs Map, and releaseResources treats refcount 0 as
1501
+ // "last holder" - so the first listener to close unsubscribed upstream and killed the
1502
+ // channel for every other listener still holding that URI (0.7.0).
1503
+ if (prevAggForSubs._subscribedRefs instanceof Map) {
1504
+ next.aggregator._subscribedRefs = new Map(prevAggForSubs._subscribedRefs);
1505
+ }
1255
1506
  }
1256
1507
  if (next && next.aggregator && typeof next.aggregator.connectAll === 'function') {
1257
1508
  try {
@@ -1306,6 +1557,7 @@ module.exports = {
1306
1557
  createHttpMcpServer,
1307
1558
  // Exported for unit tests / reuse - the small pure seams.
1308
1559
  isLoopbackHost,
1560
+ isLoopbackOrigin,
1309
1561
  PROTOCOL_VERSION,
1310
1562
  SERVER_INFO,
1311
1563
  };