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