toolfunnel 0.7.1 → 0.8.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 +14 -4
- package/bin/toolfunnel.js +103 -24
- package/docs/KNOWN_BUGS.md +48 -28
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +1 -1
- package/docs/packaging.md +2 -1
- package/mcp/servers/mock-modern/server.js +1 -1
- package/mcp/servers/mock-upstream/server.js +3 -4
- package/package.json +1 -1
- package/src/auth/config.js +2 -4
- package/src/auth/resource-server.js +6 -1
- package/src/core/atomic-write.js +97 -0
- package/src/core/config-home.js +100 -8
- package/src/core/hook-engine.js +29 -54
- package/src/core/hook-loader.js +3 -61
- package/src/core/hook-runner.js +92 -43
- package/src/core/logger.js +5 -7
- package/src/core/metrics.js +17 -2
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +36 -21
- package/src/extend/add-hook.md +4 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/package.md +1 -1
- package/src/mcp/aggregator.js +583 -54
- package/src/mcp/expose-store.js +246 -68
- package/src/mcp/http-transport.js +111 -17
- package/src/mcp/mcp-client.js +130 -26
- package/src/mcp/modern.js +9 -6
- package/src/mcp/protocol.js +12 -7
- package/src/mcp/server.js +268 -63
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +33 -87
- package/src/tools/tool-state.js +1 -1
- package/src/ui/public/app.js +60 -1
- package/src/ui/server.js +142 -16
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-mcp-add.js +28 -15
- package/tools/tools.register.json +16 -5
|
@@ -72,7 +72,7 @@ const SSE_KEEPALIVE_MS = 25000;
|
|
|
72
72
|
// Hard cap on a POSTed JSON-RPC body so a pathological/huge request can't exhaust memory. A
|
|
73
73
|
// JSON-RPC tools/call payload is tiny; 4 MiB is generous headroom. Over the cap -> -32700.
|
|
74
74
|
const MAX_BODY_BYTES = 4 * 1024 * 1024;
|
|
75
|
-
// Hard cap on JSON-RPC batch members
|
|
75
|
+
// Hard cap on JSON-RPC batch members. MAX_BODY_BYTES alone is no bound on WORK: a
|
|
76
76
|
// tools/call member is ~100 bytes, so 4 MiB buys tens of thousands of gated invocations - each
|
|
77
77
|
// spawning hook children and a tool child - executed serially while the socket is held. 64 is well
|
|
78
78
|
// past any real client's batching and far short of a denial of service.
|
|
@@ -137,6 +137,12 @@ function defaultBuildFactory() {
|
|
|
137
137
|
return require('./server').buildProtocol();
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/** A host as it appears in a URL: a literal IPv6 address is bracketed (`[::1]`), anything else verbatim. */
|
|
141
|
+
function urlHost(h) {
|
|
142
|
+
const v = String(h == null ? '' : h);
|
|
143
|
+
return v.includes(':') && !v.startsWith('[') ? '[' + v + ']' : v;
|
|
144
|
+
}
|
|
145
|
+
|
|
140
146
|
/**
|
|
141
147
|
* Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound request could still
|
|
142
148
|
* arrive with a non-loopback Host. Defence-in-depth: reject anything whose host part is not a
|
|
@@ -393,7 +399,7 @@ function createHttpMcpServer(opts = {}) {
|
|
|
393
399
|
|
|
394
400
|
/** The advertised base URL once bound (null before start / after stop). */
|
|
395
401
|
function currentUrl() {
|
|
396
|
-
return boundPort == null ? null : `http://${host}:${boundPort}`;
|
|
402
|
+
return boundPort == null ? null : `http://${urlHost(host)}:${boundPort}`;
|
|
397
403
|
}
|
|
398
404
|
|
|
399
405
|
// ── SSE plumbing ──────────────────────────────────────────────────────────────────────────
|
|
@@ -405,6 +411,13 @@ function createHttpMcpServer(opts = {}) {
|
|
|
405
411
|
* server stop) the client is deregistered and its timer cleared.
|
|
406
412
|
*/
|
|
407
413
|
function openSse(req, res) {
|
|
414
|
+
// Same ceiling as the modern listen streams: every open stream is a registered response plus
|
|
415
|
+
// a keep-alive timer for the life of the socket, and nothing bounded the legacy channel - a
|
|
416
|
+
// client opening streams it never closed grew the host without limit. A GET has no JSON-RPC
|
|
417
|
+
// id, so the refusal is a plain JSON body, not an error envelope.
|
|
418
|
+
if (sseClients.size >= MODERN_LISTENERS_MAX) {
|
|
419
|
+
return sendJson(res, 503, { error: 'sse stream limit reached (' + MODERN_LISTENERS_MAX + ') - close an existing stream first' });
|
|
420
|
+
}
|
|
408
421
|
res.writeHead(200, {
|
|
409
422
|
'Content-Type': 'text/event-stream',
|
|
410
423
|
'Cache-Control': 'no-store',
|
|
@@ -487,9 +500,9 @@ function createHttpMcpServer(opts = {}) {
|
|
|
487
500
|
// At capacity -> refuse the NEW stream with a clear error BEFORE any SSE bytes are written;
|
|
488
501
|
// existing streams are never evicted (they may carry subscriptions the client relies on).
|
|
489
502
|
//
|
|
490
|
-
// ORDER MATTERS
|
|
491
|
-
// return path registers no cleanup -
|
|
492
|
-
// Each leaked ref
|
|
503
|
+
// ORDER MATTERS: this check must sit BEFORE subscribeResources below, because the 503
|
|
504
|
+
// return path registers no cleanup - a refused listen would take refcounts it never releases.
|
|
505
|
+
// Each leaked ref would pin its URI above zero (no resources/unsubscribe ever sent) and keep it
|
|
493
506
|
// in the replay set, re-subscribed on every future upstream reconnect, unboundedly.
|
|
494
507
|
if (modernListeners.size >= MODERN_LISTENERS_MAX) {
|
|
495
508
|
return sendJson(res, 503, makeErrorWithId(subId, ERR.INTERNAL,
|
|
@@ -539,7 +552,7 @@ function createHttpMcpServer(opts = {}) {
|
|
|
539
552
|
clearInterval(keepAlive);
|
|
540
553
|
modernListeners.delete(entry);
|
|
541
554
|
// 5.4: the stream WAS the subscription - closing it releases the listen-owned resource
|
|
542
|
-
// URIs
|
|
555
|
+
// URIs. _released guards the
|
|
543
556
|
// up-to-four close/error events that all route here.
|
|
544
557
|
if (entry._released !== true && entry.agreed && Array.isArray(entry.agreed.resourceSubscriptions)) {
|
|
545
558
|
entry._released = true;
|
|
@@ -739,7 +752,7 @@ function createHttpMcpServer(opts = {}) {
|
|
|
739
752
|
raw = await readBody(req);
|
|
740
753
|
} catch (err) {
|
|
741
754
|
// Oversized / aborted body -> the SAME flush-then-close -32700 as the Content-Length pre-check
|
|
742
|
-
// (readBody
|
|
755
|
+
// (readBody does not destroy the request, so the reply can actually reach the client; a
|
|
743
756
|
// genuinely-dead socket is fine too - sendJsonAndClose's writes are defensively wrapped).
|
|
744
757
|
log('POST /mcp body read failed:', (err && err.message) || String(err));
|
|
745
758
|
return sendJsonAndClose(req, res, 200, makeError(ERR.PARSE, 'Parse error: ' + ((err && err.message) || 'bad body')));
|
|
@@ -765,16 +778,16 @@ function createHttpMcpServer(opts = {}) {
|
|
|
765
778
|
if (hv === undefined || hv === '2025-03-26') {
|
|
766
779
|
// A MODERN-shaped member cannot legally travel in a batch: batching exists ONLY in
|
|
767
780
|
// 2025-03-26, which predates the modern era entirely. Refusing here closes an era-mixing
|
|
768
|
-
// hole
|
|
769
|
-
// one-element array
|
|
770
|
-
// disconnect-cancel accounting, and still be SERVED with full modern semantics because
|
|
781
|
+
// hole: this branch runs BEFORE bodyIsModern below, so a modern body wrapped in a
|
|
782
|
+
// one-element array would otherwise skip validateModernHeaders, the Mcp-Param cross-check and
|
|
783
|
+
// the disconnect-cancel accounting, and still be SERVED with full modern semantics because
|
|
771
784
|
// handleMessage re-detects the era from the body.
|
|
772
785
|
if (msg.some((m) => modern.isModernRequest(m))) {
|
|
773
786
|
return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
|
|
774
787
|
'modern-era messages cannot be batched - JSON-RPC batching is 2025-03-26 only; send each modern request as its own POST'));
|
|
775
788
|
}
|
|
776
789
|
// 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
|
|
790
|
+
// uncapped array is one POST buying unbounded sequential execution.
|
|
778
791
|
if (msg.length > MAX_BATCH_MEMBERS) {
|
|
779
792
|
return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
|
|
780
793
|
`batch too large (${msg.length} members, max ${MAX_BATCH_MEMBERS}) - split it`));
|
|
@@ -889,13 +902,43 @@ function createHttpMcpServer(opts = {}) {
|
|
|
889
902
|
});
|
|
890
903
|
}
|
|
891
904
|
|
|
905
|
+
// Per-request progress. A single request that carries a progressToken and accepts
|
|
906
|
+
// text/event-stream gets an SSE answer the moment its FIRST beat arrives: its own progress (its
|
|
907
|
+
// own token - the aggregator scopes tokens per call), then its result, then the end. Streamable
|
|
908
|
+
// HTTP allows exactly this ("MAY send ... notifications before sending the JSON-RPC response.
|
|
909
|
+
// These messages SHOULD relate to the originating client request"), and it is the only place
|
|
910
|
+
// progress may go: the shared GET stream would hand one caller's beats to every listener. No beat
|
|
911
|
+
// arrives -> the same JSON answer (status mapping included) as before.
|
|
912
|
+
const ptok = msg && msg.params && msg.params._meta && msg.params._meta.progressToken;
|
|
913
|
+
const wantsStream = !!(msg && !Array.isArray(msg) && msg.id !== undefined && msg.id !== null &&
|
|
914
|
+
((typeof ptok === 'string' && ptok.length) || Number.isInteger(ptok)) &&
|
|
915
|
+
/text\/event-stream/i.test(String(req.headers.accept || '')));
|
|
916
|
+
let streaming = false;
|
|
917
|
+
const progress = wantsStream ? (params) => {
|
|
918
|
+
if (modernDisconnected || res.writableEnded || res.destroyed) return;
|
|
919
|
+
try {
|
|
920
|
+
if (!streaming) {
|
|
921
|
+
streaming = true;
|
|
922
|
+
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-store', Connection: 'keep-alive' });
|
|
923
|
+
}
|
|
924
|
+
res.write('data: ' + JSON.stringify({ jsonrpc: JSONRPC, method: 'notifications/progress', params }) + '\n\n');
|
|
925
|
+
} catch (_e) { /* a dead socket: the result path below meets it too */ }
|
|
926
|
+
} : undefined;
|
|
927
|
+
|
|
892
928
|
let response;
|
|
893
929
|
try {
|
|
894
|
-
response = await handleMessage(build, msg);
|
|
930
|
+
response = await handleMessage(build, msg, undefined, { progress });
|
|
895
931
|
} catch (err) {
|
|
896
932
|
// handleMessage is contracted never to throw, but the transport must survive even if a
|
|
897
933
|
// wiring bug breaks that contract - a single bad request can never crash the host.
|
|
898
934
|
log('handleMessage threw (should not):', (err && err.stack) || String(err));
|
|
935
|
+
if (streaming) {
|
|
936
|
+
try {
|
|
937
|
+
res.write('data: ' + JSON.stringify(makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err))) + '\n\n');
|
|
938
|
+
res.end();
|
|
939
|
+
} catch (_e) { /* ignore */ }
|
|
940
|
+
return;
|
|
941
|
+
}
|
|
899
942
|
return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err)));
|
|
900
943
|
}
|
|
901
944
|
|
|
@@ -903,6 +946,15 @@ function createHttpMcpServer(opts = {}) {
|
|
|
903
946
|
// returning here keeps the contract explicit and the counter honest.
|
|
904
947
|
if (modernDisconnected) return;
|
|
905
948
|
|
|
949
|
+
if (streaming) {
|
|
950
|
+
// Streaming began (status 200 already sent): the result - or the JSON-RPC error - is the
|
|
951
|
+
// stream's last event, then the stream ends ("After the JSON-RPC response has been sent, the
|
|
952
|
+
// server SHOULD terminate the SSE stream").
|
|
953
|
+
try { if (response != null) res.write('data: ' + JSON.stringify(response) + '\n\n'); } catch (_e) { /* ignore */ }
|
|
954
|
+
try { res.end(); } catch (_e) { /* ignore */ }
|
|
955
|
+
return;
|
|
956
|
+
}
|
|
957
|
+
|
|
906
958
|
// A notification (no reply) -> 202 Accepted, no body. A request -> 200 with the JSON-RPC result.
|
|
907
959
|
if (response == null) {
|
|
908
960
|
res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
|
|
@@ -1058,10 +1110,9 @@ function createHttpMcpServer(opts = {}) {
|
|
|
1058
1110
|
// alias (the older HTTP+SSE shape, deprecated but still supported). Both routes
|
|
1059
1111
|
// hand off to the same openSse() - the loopback/auth guard above already gated the request.
|
|
1060
1112
|
if (method === 'GET' && (pathName === '/mcp' || pathName === '/mcp/sse')) {
|
|
1061
|
-
// MODERN-ONLY POLICY
|
|
1113
|
+
// MODERN-ONLY POLICY: the GET-SSE stream is a LEGACY-era channel (modern uses
|
|
1062
1114
|
// subscriptions/listen on POST) - answer the spec's own compatibility shape, 405 Method
|
|
1063
1115
|
// 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.
|
|
1065
1116
|
if (build && build.serveLegacy === false) {
|
|
1066
1117
|
res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
|
|
1067
1118
|
return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
|
|
@@ -1112,6 +1163,42 @@ function createHttpMcpServer(opts = {}) {
|
|
|
1112
1163
|
* upstreamsConnected:number, upstreams:number, toolsExposed:number,
|
|
1113
1164
|
* connected:string[], failed:Array<{id:string, error:string}> }}
|
|
1114
1165
|
*/
|
|
1166
|
+
/** The aggregator's stopped upstreams, defensively (a degraded or older build reports none). */
|
|
1167
|
+
function stoppedNow() {
|
|
1168
|
+
try {
|
|
1169
|
+
const agg = build && build.aggregator;
|
|
1170
|
+
const s = agg && typeof agg.stoppedUpstreams === 'function' ? agg.stoppedUpstreams() : [];
|
|
1171
|
+
return Array.isArray(s) ? s.map((x) => ({ id: x.id, error: x.error, attempts: x.attempts })) : [];
|
|
1172
|
+
} catch (_e) {
|
|
1173
|
+
return [];
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
|
|
1177
|
+
/** Tools the aggregator dropped for a broken descriptor, defensively (none on a degraded build). */
|
|
1178
|
+
function invalidToolsNow() {
|
|
1179
|
+
try {
|
|
1180
|
+
const agg = build && build.aggregator;
|
|
1181
|
+
const v = agg && typeof agg.invalidTools === 'function' ? agg.invalidTools() : [];
|
|
1182
|
+
return Array.isArray(v) ? v : [];
|
|
1183
|
+
} catch (_e) {
|
|
1184
|
+
return [];
|
|
1185
|
+
}
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
/** The aggregator's tool descriptor changes, defensively (a degraded or older build: none). */
|
|
1189
|
+
function toolChangesNow() {
|
|
1190
|
+
try {
|
|
1191
|
+
const agg = build && build.aggregator;
|
|
1192
|
+
const c = agg && typeof agg.toolChanges === 'function' ? agg.toolChanges() : null;
|
|
1193
|
+
return {
|
|
1194
|
+
pending: c && Array.isArray(c.pending) ? c.pending : [],
|
|
1195
|
+
upgraded: c && Array.isArray(c.upgraded) ? c.upgraded : [],
|
|
1196
|
+
};
|
|
1197
|
+
} catch (_e) {
|
|
1198
|
+
return { pending: [], upgraded: [] };
|
|
1199
|
+
}
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1115
1202
|
function health() {
|
|
1116
1203
|
let toolsExposed = 0;
|
|
1117
1204
|
let upstreamsConnected = 0;
|
|
@@ -1184,6 +1271,13 @@ function createHttpMcpServer(opts = {}) {
|
|
|
1184
1271
|
// visible. Fresh copies so the caller can never mutate the internal snapshot.
|
|
1185
1272
|
connected: lastConnect.connected.slice(),
|
|
1186
1273
|
failed: lastConnect.failed.map((f) => ({ id: f.id, error: f.error })),
|
|
1274
|
+
// LIVE (not the startup snapshot): upstreams that hit the permanent-failure ceiling and
|
|
1275
|
+
// stopped retrying - they stay here until a config reload or a reconnect.
|
|
1276
|
+
stopped: stoppedNow(),
|
|
1277
|
+
// Tool descriptor approval: changes waiting for acceptance + recent upgrades, live.
|
|
1278
|
+
toolChanges: toolChangesNow(),
|
|
1279
|
+
// Tools dropped because their descriptor is structurally broken, live.
|
|
1280
|
+
invalidTools: invalidToolsNow(),
|
|
1187
1281
|
// OAuth resource-server status (no secrets - issuer/audience live in the well-known doc):
|
|
1188
1282
|
// whether auth is enabled, whether the optional jose dependency is installed, and whether the
|
|
1189
1283
|
// current config is coherent enough to enforce (misconfigured: configError() reason present).
|
|
@@ -1497,9 +1591,9 @@ function createHttpMcpServer(opts = {}) {
|
|
|
1497
1591
|
prevAggForSubs._subscribedUris instanceof Set && prevAggForSubs._subscribedUris.size) {
|
|
1498
1592
|
next.aggregator._subscribedUris = new Set(prevAggForSubs._subscribedUris);
|
|
1499
1593
|
// Carry the REFCOUNTS too, exactly as reloadExpose does (server.js). Without them the new
|
|
1500
|
-
// aggregator
|
|
1501
|
-
// "last holder" - so the first listener to close
|
|
1502
|
-
// channel for every other listener still holding that URI
|
|
1594
|
+
// aggregator holds the URIs with an empty refs Map, and releaseResources treats refcount 0 as
|
|
1595
|
+
// "last holder" - so the first listener to close would unsubscribe upstream and kill the
|
|
1596
|
+
// channel for every other listener still holding that URI.
|
|
1503
1597
|
if (prevAggForSubs._subscribedRefs instanceof Map) {
|
|
1504
1598
|
next.aggregator._subscribedRefs = new Map(prevAggForSubs._subscribedRefs);
|
|
1505
1599
|
}
|
package/src/mcp/mcp-client.js
CHANGED
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
*/
|
|
36
36
|
|
|
37
37
|
const childProcess = require('node:child_process');
|
|
38
|
+
const fs = require('node:fs');
|
|
38
39
|
const path = require('node:path');
|
|
39
40
|
|
|
40
41
|
const JSONRPC = '2.0';
|
|
@@ -42,8 +43,8 @@ const PROTOCOL_VERSION = '2024-11-05';
|
|
|
42
43
|
// The 2026-07-28 ("modern") protocol version this client can SPEAK to a modern upstream. Over
|
|
43
44
|
// stdio the modern era needs only per-request `_meta` - the Mcp-Method/Mcp-Name/MCP-Protocol-Version
|
|
44
45
|
// headers are HTTP-transport-only, so a stdio client is modern by adding `_meta` + skipping the
|
|
45
|
-
// initialize handshake.
|
|
46
|
-
const MODERN_PROTOCOL_VERSION = '
|
|
46
|
+
// initialize handshake.
|
|
47
|
+
const { MODERN_PROTOCOL_VERSION } = require('./modern'); // the ONE definition (version-literal.test.js pins it)
|
|
47
48
|
const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client' });
|
|
48
49
|
|
|
49
50
|
/**
|
|
@@ -72,11 +73,65 @@ const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client'
|
|
|
72
73
|
function winLaunch(command, args) {
|
|
73
74
|
const argv = Array.isArray(args) ? args : [];
|
|
74
75
|
if (process.platform !== 'win32') return { command, args: argv };
|
|
75
|
-
// A concrete executable can be spawned directly - no shim resolution needed.
|
|
76
|
-
|
|
77
|
-
|
|
76
|
+
// A concrete executable can be spawned directly - no shim resolution needed. A relative one
|
|
77
|
+
// (`../../x/node.exe`, the drive-independent config) resolves against the spawn's cwd.
|
|
78
|
+
const pathShaped = path.isAbsolute(command) || /[\\/]/.test(command);
|
|
79
|
+
if (pathShaped && /\.(exe|com)$/i.test(command)) return { command, args: argv };
|
|
80
|
+
// Otherwise hand it to cmd.exe, which resolves the .cmd/.bat shim via PATHEXT. cmd reads a '/'
|
|
81
|
+
// as a switch, so a path reaches it with backslashes.
|
|
78
82
|
const comspec = process.env.ComSpec || 'cmd.exe';
|
|
79
|
-
return { command: comspec, args: ['/c', command, ...argv] };
|
|
83
|
+
return { command: comspec, args: ['/c', pathShaped ? command.replace(/\//g, '\\') : command, ...argv] };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Windows only: is `command` definitely absent from everywhere cmd.exe would look - the working
|
|
88
|
+
* directory, then each PATH directory, trying each PATHEXT extension?
|
|
89
|
+
*
|
|
90
|
+
* winLaunch routes every non-.exe command through cmd.exe, so a missing command never fails the
|
|
91
|
+
* spawn with ENOENT: cmd prints a LOCALISED "is not recognized" line and exits 1, exactly like a
|
|
92
|
+
* server that crashed on start. The owner classifies permanent failures by error code (the
|
|
93
|
+
* aggregator's reconnect ceiling), so connect() asks this AFTER a failed connect and stamps ENOENT.
|
|
94
|
+
* It never blocks a spawn. Conservative by construction - it answers true only when every
|
|
95
|
+
* candidate is definitely absent: a stat error other than ENOENT/ENOTDIR (an App Execution Alias
|
|
96
|
+
* can refuse stat) counts as found. A PATH entry is split quote-aware (cmd.exe accepts
|
|
97
|
+
* "C:\a;b" as one directory), and a %VAR% the env cannot expand stays literal, as it does for cmd.exe.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} command
|
|
100
|
+
* @param {string} [cwd] the child's working directory
|
|
101
|
+
* @param {object} [env] the child's merged environment
|
|
102
|
+
* @returns {boolean}
|
|
103
|
+
*/
|
|
104
|
+
function commandMissing(command, cwd, env) {
|
|
105
|
+
if (process.platform !== 'win32' || typeof command !== 'string' || command.length === 0) return false;
|
|
106
|
+
const e = env || process.env;
|
|
107
|
+
// Env names are case-insensitive on Windows and a merged env can hold both `Path` and `PATH`:
|
|
108
|
+
// take every spelling (more candidates can only make the answer more conservative).
|
|
109
|
+
const all = (name) => Object.keys(e).filter((k) => k.toUpperCase() === name).map((k) => String(e[k] || ''));
|
|
110
|
+
const exts = all('PATHEXT').join(';').split(';').filter(Boolean);
|
|
111
|
+
const candidates = [command, ...(exts.length ? exts : ['.COM', '.EXE', '.BAT', '.CMD']).map((x) => command + x)];
|
|
112
|
+
const base = typeof cwd === 'string' && cwd.length ? path.resolve(cwd) : process.cwd();
|
|
113
|
+
const isPath = /[\\/]/.test(command) || path.isAbsolute(command);
|
|
114
|
+
const dirs = [base];
|
|
115
|
+
if (!isPath) {
|
|
116
|
+
for (const d of all('PATH').join(';').match(/(?:"[^"]*"|[^;])+/g) || []) {
|
|
117
|
+
const dir = d.replace(/"/g, '').trim();
|
|
118
|
+
if (!dir) continue;
|
|
119
|
+
// Expand %VAR% from the child's env where we can; an unknown one stays literal. Either way the
|
|
120
|
+
// extra candidate can only turn up a match (the safe direction), never hide one.
|
|
121
|
+
dirs.push(dir.replace(/%([^%]+)%/g, (m, v) => { const hit = all(v.toUpperCase())[0]; return hit !== undefined ? hit : m; }));
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const dir of dirs) {
|
|
125
|
+
for (const name of candidates) {
|
|
126
|
+
try {
|
|
127
|
+
fs.statSync(path.resolve(dir, name));
|
|
128
|
+
return false;
|
|
129
|
+
} catch (err) {
|
|
130
|
+
if (!err || (err.code !== 'ENOENT' && err.code !== 'ENOTDIR')) return false;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return true;
|
|
80
135
|
}
|
|
81
136
|
|
|
82
137
|
/**
|
|
@@ -161,7 +216,7 @@ class McpClient {
|
|
|
161
216
|
this._forceLegacy = forceLegacy === true;
|
|
162
217
|
// modernOnly: the MIRROR of legacyPin - refuse the legacy fallback entirely. A modernOnly
|
|
163
218
|
// upstream that does not answer server/discover with the modern protocol FAILS the connect
|
|
164
|
-
// with a clear error instead of silently negotiating down
|
|
219
|
+
// with a clear error instead of silently negotiating down.
|
|
165
220
|
this._modernOnly = modernOnly === true;
|
|
166
221
|
// eraHint ('legacy' is the only meaningful value): the OWNER's memo of the era this upstream
|
|
167
222
|
// negotiated LAST time - an observation, not a policy. It skips the server/discover probe the
|
|
@@ -413,6 +468,9 @@ class McpClient {
|
|
|
413
468
|
// with an error that names the policy - never a silent downgrade.
|
|
414
469
|
if (!discovered && this._modernOnly) {
|
|
415
470
|
this.close();
|
|
471
|
+
// A command that was never found cannot answer discover either - say THAT, not the policy.
|
|
472
|
+
const missing = this._missingCommandError('McpClient: upstream could not start');
|
|
473
|
+
if (missing) throw missing;
|
|
416
474
|
throw new Error(`McpClient: upstream "${this.id}" is modernOnly but did not answer server/discover with the 2026-07-28 protocol - refusing the legacy fallback`);
|
|
417
475
|
}
|
|
418
476
|
|
|
@@ -428,10 +486,10 @@ class McpClient {
|
|
|
428
486
|
protocolVersion: PROTOCOL_VERSION,
|
|
429
487
|
clientInfo: this._clientInfo,
|
|
430
488
|
// elicitation: real SDK-based servers GATE ctx.elicit() on the client declaring this
|
|
431
|
-
// capability - without it they answer "Method not found" and never elicit at all
|
|
432
|
-
//
|
|
433
|
-
//
|
|
434
|
-
// BOTH modes declared
|
|
489
|
+
// capability - without it they answer "Method not found" and never elicit at all.
|
|
490
|
+
// Every elicitation is handled: the elicitation bridge's MRTR path for modern callers,
|
|
491
|
+
// a clean decline otherwise.
|
|
492
|
+
// BOTH modes declared: the 2025-11-25 back-compat rule reads a bare
|
|
435
493
|
// `{}` as FORM ONLY, and "Servers MUST NOT send elicitation requests with modes that
|
|
436
494
|
// are not supported by the client" - so without `url` here a conformant upstream is
|
|
437
495
|
// FORBIDDEN from url mode and the relay is structurally dead. Every elicit of either
|
|
@@ -441,7 +499,14 @@ class McpClient {
|
|
|
441
499
|
this._initializeResult = result;
|
|
442
500
|
} catch (err) {
|
|
443
501
|
this.close();
|
|
444
|
-
|
|
502
|
+
const message = `McpClient: initialize failed: ${(err && err.message) || String(err)}`;
|
|
503
|
+
// Name a missing working directory or command first (a spawn ENOENT names only the
|
|
504
|
+
// executable, and the Windows launch shim hides a missing command behind "exited (code=1)"),
|
|
505
|
+
// else keep the cause's code - the owner classifies by it.
|
|
506
|
+
const missing = this._missingCommandError(message);
|
|
507
|
+
if (missing) throw missing;
|
|
508
|
+
if (err && typeof err.code === 'string') throw Object.assign(new Error(message), { code: err.code });
|
|
509
|
+
throw new Error(message);
|
|
445
510
|
}
|
|
446
511
|
// Tell the server the handshake is complete (notification - no id, no reply).
|
|
447
512
|
this._notify('notifications/initialized', {});
|
|
@@ -480,7 +545,7 @@ class McpClient {
|
|
|
480
545
|
}
|
|
481
546
|
|
|
482
547
|
/**
|
|
483
|
-
* Era-keyed logLevel handling
|
|
548
|
+
* Era-keyed logLevel handling. A caller's per-request
|
|
484
549
|
* `io.modelcontextprotocol/logLevel` must reach the upstream in the upstream's own vocabulary:
|
|
485
550
|
* MODERN upstream -> left in `_meta` (the trio merge in request()/callTool() preserves it - the
|
|
486
551
|
* trio does not define logLevel), because the modern severity clause obliges the upstream to
|
|
@@ -506,7 +571,10 @@ class McpClient {
|
|
|
506
571
|
const caps = this._initializeResult && this._initializeResult.capabilities;
|
|
507
572
|
if (typeof level === 'string' && caps && caps.logging && level !== this._lastSetLogLevel) {
|
|
508
573
|
this._lastSetLogLevel = level;
|
|
509
|
-
|
|
574
|
+
// Fire-and-forget, ordered by the pipe (issued BEFORE the main request). A refusal or timeout
|
|
575
|
+
// reverts the memo so the next call at this level asks again - memoising it as done left the
|
|
576
|
+
// upstream at whatever level it had for the rest of the session.
|
|
577
|
+
this._request('logging/setLevel', { level }).catch(() => { if (this._lastSetLogLevel === level) this._lastSetLogLevel = undefined; });
|
|
510
578
|
}
|
|
511
579
|
return out;
|
|
512
580
|
}
|
|
@@ -539,6 +607,15 @@ class McpClient {
|
|
|
539
607
|
* caught and rethrown clean. Extracted from connect() so probeDiscover() can share it.
|
|
540
608
|
*/
|
|
541
609
|
_spawnChild() {
|
|
610
|
+
// Per-child read state starts CLEAN. On an in-place respawn (connect/probeDiscover after the
|
|
611
|
+
// previous child died) a half-arrived frame, a refused over-cap skip count, or the old child's
|
|
612
|
+
// stderr would otherwise be prepended to the new child's first bytes: the drain then swallows
|
|
613
|
+
// its first reply as the rest of a dead body, and every error quotes the wrong stderr. Same
|
|
614
|
+
// values as the constructor. The log-level memo goes too - a new process knows no level.
|
|
615
|
+
this._buf = Buffer.alloc(0);
|
|
616
|
+
this._skipRemaining = 0;
|
|
617
|
+
this._stderr = '';
|
|
618
|
+
this._lastSetLogLevel = undefined;
|
|
542
619
|
try {
|
|
543
620
|
// Cross-platform: POSIX passes through; Windows routes a .cmd/.bat shim through cmd.exe.
|
|
544
621
|
const launch = winLaunch(this._command, this._args);
|
|
@@ -550,7 +627,9 @@ class McpClient {
|
|
|
550
627
|
});
|
|
551
628
|
} catch (err) {
|
|
552
629
|
this._child = null;
|
|
553
|
-
|
|
630
|
+
const failed = new Error(`McpClient: failed to spawn "${this._command}": ${(err && err.message) || String(err)}`);
|
|
631
|
+
if (err && typeof err.code === 'string') failed.code = err.code; // the owner classifies by code
|
|
632
|
+
throw failed;
|
|
554
633
|
}
|
|
555
634
|
|
|
556
635
|
if (!this._child || !this._child.stdin || !this._child.stdout) {
|
|
@@ -562,6 +641,21 @@ class McpClient {
|
|
|
562
641
|
this._wireChild();
|
|
563
642
|
}
|
|
564
643
|
|
|
644
|
+
/** A connect-failure Error carrying code ENOENT when the working directory or the command is
|
|
645
|
+
* definitely absent (commandMissing - Windows, where the launch shim hides it), else null. */
|
|
646
|
+
_missingCommandError(message) {
|
|
647
|
+
if (typeof this._cwd === 'string' && this._cwd.length && !fs.existsSync(this._cwd)) {
|
|
648
|
+
return Object.assign(new Error(`McpClient: working directory "${this._cwd}" does not exist - ${message}`), { code: 'ENOENT' });
|
|
649
|
+
}
|
|
650
|
+
if (!commandMissing(this._command, this._cwd, Object.assign({}, process.env, this._env))) return null;
|
|
651
|
+
// The cause goes FIRST: the message continues with the upstream's stderr, which a reader
|
|
652
|
+
// (or the aggregator's bounded stop line) may cut short.
|
|
653
|
+
return Object.assign(
|
|
654
|
+
new Error(`McpClient: command "${this._command}" was not found (looked in the working directory and on PATH) - ${message}`),
|
|
655
|
+
{ code: 'ENOENT' }
|
|
656
|
+
);
|
|
657
|
+
}
|
|
658
|
+
|
|
565
659
|
/**
|
|
566
660
|
* Era probe - the 2026-07-28 spec's dual-era client rule for stdio: try `server/discover`
|
|
567
661
|
* BEFORE any legacy `initialize`. A modern or dual-era server answers with its
|
|
@@ -578,7 +672,7 @@ class McpClient {
|
|
|
578
672
|
try {
|
|
579
673
|
return await this._request('server/discover', {
|
|
580
674
|
_meta: {
|
|
581
|
-
'io.modelcontextprotocol/protocolVersion':
|
|
675
|
+
'io.modelcontextprotocol/protocolVersion': MODERN_PROTOCOL_VERSION,
|
|
582
676
|
'io.modelcontextprotocol/clientCapabilities': {},
|
|
583
677
|
'io.modelcontextprotocol/clientInfo': this._clientInfo,
|
|
584
678
|
},
|
|
@@ -593,8 +687,8 @@ class McpClient {
|
|
|
593
687
|
|
|
594
688
|
/**
|
|
595
689
|
* tools/list -> the upstream's COMPLETE tool definitions, following `nextCursor` pagination
|
|
596
|
-
* (
|
|
597
|
-
*
|
|
690
|
+
* (a single-shot list would silently truncate a paginating upstream to page 1, leaving its
|
|
691
|
+
* later tools undiscoverable AND unrunnable). Returns the raw concatenated array
|
|
598
692
|
* [{name, description, inputSchema, ...}] from every page (empty array if the server returned
|
|
599
693
|
* no tools). Rejects on timeout / transport failure of ANY page.
|
|
600
694
|
*
|
|
@@ -736,11 +830,16 @@ class McpClient {
|
|
|
736
830
|
_wireChild() {
|
|
737
831
|
const child = this._child;
|
|
738
832
|
|
|
739
|
-
child.
|
|
833
|
+
// Every handler below answers only while `child` is STILL this._child. A child that has
|
|
834
|
+
// exited (or been replaced by an in-place respawn) can still drain buffered stdout/stderr and
|
|
835
|
+
// fire 'exit'/'error' afterwards - Node emits 'exit' before the stdio streams are done - and
|
|
836
|
+
// those late events would otherwise land in the NEW child's read state or reject its waiters.
|
|
837
|
+
child.stdout.on('data', (chunk) => { if (this._child === child) this._onData(chunk); });
|
|
740
838
|
child.stdout.on('error', (err) => this._logErr('stdout error:', (err && err.message) || String(err)));
|
|
741
839
|
|
|
742
840
|
if (child.stderr) {
|
|
743
841
|
child.stderr.on('data', (d) => {
|
|
842
|
+
if (this._child !== child) return;
|
|
744
843
|
this._stderr += d.toString('utf8');
|
|
745
844
|
// Keep the captured stderr bounded so a chatty child can't grow memory unbounded.
|
|
746
845
|
if (this._stderr.length > 64 * 1024) {
|
|
@@ -756,8 +855,9 @@ class McpClient {
|
|
|
756
855
|
// server that dies on the unknown method would otherwise corrupt a follow-up connect()). Only
|
|
757
856
|
// null it if it is still THIS child (a fresh respawn may have replaced it).
|
|
758
857
|
child.on('exit', (code, signal) => {
|
|
858
|
+
if (this._child !== child) return; // a stale child's exit: already handled or superseded
|
|
759
859
|
this._connected = false;
|
|
760
|
-
|
|
860
|
+
this._child = null;
|
|
761
861
|
this._rejectAllWaiters(
|
|
762
862
|
new Error(`McpClient: upstream exited (code=${code}, signal=${signal}). stderr:\n${this._stderr}`)
|
|
763
863
|
);
|
|
@@ -765,11 +865,14 @@ class McpClient {
|
|
|
765
865
|
});
|
|
766
866
|
child.on('error', (err) => {
|
|
767
867
|
// Spawn-level failure (e.g. ENOENT) - surfaces here, not as a throw from spawn().
|
|
868
|
+
if (this._child !== child) return; // a stale child's error: already handled or superseded
|
|
768
869
|
this._connected = false;
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
)
|
|
870
|
+
this._child = null;
|
|
871
|
+
const failed = new Error(`McpClient: child process error: ${(err && err.message) || String(err)}`);
|
|
872
|
+
// Keep the code: the aggregator's reconnect ceiling classifies permanent failures by it
|
|
873
|
+
// (a re-wrap without it made a missing command look like any other death).
|
|
874
|
+
if (err && typeof err.code === 'string') failed.code = err.code;
|
|
875
|
+
this._rejectAllWaiters(failed);
|
|
773
876
|
this._fireOnClose(`error: ${(err && err.message) || String(err)}`);
|
|
774
877
|
});
|
|
775
878
|
}
|
|
@@ -1014,8 +1117,8 @@ class McpClient {
|
|
|
1014
1117
|
|
|
1015
1118
|
if (typeof msg.method === 'string') {
|
|
1016
1119
|
// SERVER->CLIENT REQUEST (id + method): the upstream is asking ITS client something -
|
|
1017
|
-
// elicitation/create, sampling/createMessage, roots/list.
|
|
1018
|
-
//
|
|
1120
|
+
// elicitation/create, sampling/createMessage, roots/list. Dropping one would leave a
|
|
1121
|
+
// conformant upstream holding an open request forever. Surface to the owner;
|
|
1019
1122
|
// unhandled -> answer -32601 (the honest "this client cannot do that").
|
|
1020
1123
|
if (typeof this.onServerRequest === 'function') {
|
|
1021
1124
|
try { this.onServerRequest(msg); return; } catch (_e) { this._logErr('onServerRequest threw (ignored)'); }
|
|
@@ -1065,6 +1168,7 @@ module.exports = {
|
|
|
1065
1168
|
// exported for unit tests / reuse
|
|
1066
1169
|
frameRequest,
|
|
1067
1170
|
winLaunch,
|
|
1171
|
+
commandMissing,
|
|
1068
1172
|
PROTOCOL_VERSION,
|
|
1069
1173
|
CLIENT_INFO,
|
|
1070
1174
|
};
|
package/src/mcp/modern.js
CHANGED
|
@@ -17,7 +17,7 @@
|
|
|
17
17
|
* `server/discover`, the `subscriptions/listen` message shapes and the modern error codes - and
|
|
18
18
|
* executes nothing. `server.js` (stdio) and `http-transport.js` bind it to their wires.
|
|
19
19
|
*
|
|
20
|
-
* Spec sources
|
|
20
|
+
* Spec sources:
|
|
21
21
|
* - modelcontextprotocol.io/specification/draft/changelog
|
|
22
22
|
* - .../draft/basic/transports/streamable-http (headers, validation, error examples)
|
|
23
23
|
* - .../draft/basic/versioning (era model, compatibility matrix)
|
|
@@ -64,12 +64,15 @@ const CACHE_HINTS = Object.freeze({
|
|
|
64
64
|
discover: Object.freeze({ ttlMs: 3600000, cacheScope: 'private' }),
|
|
65
65
|
});
|
|
66
66
|
|
|
67
|
-
/** The methods whose Mcp-Name header must mirror params.name / params.uri.
|
|
68
|
-
|
|
67
|
+
/** The methods whose Mcp-Name header must mirror params.name / params.uri. Null-prototype: the
|
|
68
|
+
* lookup is keyed by the CLIENT's method string, and a plain literal answered 'toString' /
|
|
69
|
+
* 'constructor' / 'valueOf' with an inherited function (truthy), demanding an Mcp-Name header for
|
|
70
|
+
* a method the table does not name. */
|
|
71
|
+
const NAMED_METHODS = Object.freeze(Object.assign(Object.create(null), {
|
|
69
72
|
'tools/call': 'name',
|
|
70
73
|
'resources/read': 'uri',
|
|
71
74
|
'prompts/get': 'name',
|
|
72
|
-
});
|
|
75
|
+
}));
|
|
73
76
|
|
|
74
77
|
/** Safely pull params._meta from a parsed JSON-RPC message. Returns null when absent/malformed. */
|
|
75
78
|
function getMeta(msg) {
|
|
@@ -216,8 +219,8 @@ function validateModernHeaders(headers, msg) {
|
|
|
216
219
|
|
|
217
220
|
/** The protocol versions this dual-era server speaks (modern first; legacy negotiated via
|
|
218
221
|
* 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()
|
|
220
|
-
* (a single string is
|
|
222
|
+
* duplicated here. server.js seeds it via setLegacyVersion() with a LIST
|
|
223
|
+
* (a single string is also accepted). */
|
|
221
224
|
let _legacyVersions = [];
|
|
222
225
|
function setLegacyVersion(v) {
|
|
223
226
|
if (typeof v === 'string' && v.length > 0) _legacyVersions = [v];
|
package/src/mcp/protocol.js
CHANGED
|
@@ -160,7 +160,8 @@ function makeProtocol(deps) {
|
|
|
160
160
|
try {
|
|
161
161
|
const docs = await registry.instructions(name);
|
|
162
162
|
if (docs === undefined || docs === null) {
|
|
163
|
-
return errorResult(`toolfunnel_tool_instructions: no tool named "${name}"`
|
|
163
|
+
return errorResult(`toolfunnel_tool_instructions: no tool named "${name}" - call toolfunnel_list_tools ` +
|
|
164
|
+
'(a filter narrows it) to find the right name.');
|
|
164
165
|
}
|
|
165
166
|
return okResult(docs);
|
|
166
167
|
} catch (err) {
|
|
@@ -173,7 +174,7 @@ function makeProtocol(deps) {
|
|
|
173
174
|
// ONLY through gatedRun (PreToolUse gate -> execute -> PostToolUse). This function
|
|
174
175
|
// must NEVER call `execute` itself - that would bypass the gate and break the entire
|
|
175
176
|
// safety case.
|
|
176
|
-
async function runTool(args) {
|
|
177
|
+
async function runTool(args, callOpts) {
|
|
177
178
|
const a = args || {};
|
|
178
179
|
const name = a.name;
|
|
179
180
|
if (typeof name !== 'string' || name.length === 0) {
|
|
@@ -193,7 +194,9 @@ function makeProtocol(deps) {
|
|
|
193
194
|
// executor. A null/undefined resolution means "no such tool" - a clean error, not a throw.
|
|
194
195
|
let resolution;
|
|
195
196
|
try {
|
|
196
|
-
|
|
197
|
+
// callOpts { meta, progress } reach an upstream tool's forward; a local register
|
|
198
|
+
// tool ignores them (a script has no progress channel).
|
|
199
|
+
resolution = await registry.resolveExecution(name, toolArgs, callOpts);
|
|
197
200
|
} catch (err) {
|
|
198
201
|
return errorResult(`toolfunnel_run_tool: could not resolve "${name}": ${(err && err.message) || err}`);
|
|
199
202
|
}
|
|
@@ -262,7 +265,8 @@ function makeProtocol(deps) {
|
|
|
262
265
|
|
|
263
266
|
if (!resolution || typeof resolution.execute !== 'function') {
|
|
264
267
|
return errorResult(
|
|
265
|
-
`toolfunnel_run_tool: tool "${name}" is not runnable (no executable resolution from the register)`
|
|
268
|
+
`toolfunnel_run_tool: tool "${name}" is not runnable (no executable resolution from the register) - ` +
|
|
269
|
+
'check the name with toolfunnel_list_tools; an upstream tool is missing while its server is disconnected or disabled.'
|
|
266
270
|
);
|
|
267
271
|
}
|
|
268
272
|
|
|
@@ -343,7 +347,7 @@ function makeProtocol(deps) {
|
|
|
343
347
|
// ---- dispatch -----------------------------------------------------------
|
|
344
348
|
// The server's single entry point. Pure routing; never throws - an unknown tool or any
|
|
345
349
|
// internal failure becomes a clear error result (defensive by construction).
|
|
346
|
-
async function dispatch(toolName, args) {
|
|
350
|
+
async function dispatch(toolName, args, callOpts) {
|
|
347
351
|
try {
|
|
348
352
|
switch (toolName) {
|
|
349
353
|
case META_TOOLS.LIST:
|
|
@@ -351,13 +355,14 @@ function makeProtocol(deps) {
|
|
|
351
355
|
case META_TOOLS.INSTRUCTIONS:
|
|
352
356
|
return await toolInstructions(args);
|
|
353
357
|
case META_TOOLS.RUN:
|
|
354
|
-
return await runTool(args);
|
|
358
|
+
return await runTool(args, callOpts);
|
|
355
359
|
case META_TOOLS.HOWTO:
|
|
356
360
|
return await howtoTool(args);
|
|
357
361
|
default: {
|
|
358
362
|
const known = Object.values(META_TOOLS).join(', ');
|
|
359
363
|
return errorResult(
|
|
360
|
-
`Unknown
|
|
364
|
+
`Unknown tool "${toolName}". This MCP's own tools are: ${known}. Register and upstream tools are ` +
|
|
365
|
+
'listed by toolfunnel_list_tools and run with toolfunnel_run_tool.'
|
|
361
366
|
);
|
|
362
367
|
}
|
|
363
368
|
}
|