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.
- package/README.md +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- 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
|
|
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.
|
|
9
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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) -
|
|
833
|
-
//
|
|
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
|
-
|
|
836
|
-
|
|
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
|
};
|