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
package/src/mcp/aggregator.js
CHANGED
|
@@ -50,6 +50,8 @@ const path = require('node:path');
|
|
|
50
50
|
|
|
51
51
|
const { McpClient, PROTOCOL_VERSION: LEGACY_UPSTREAM_VERSION } = require('./mcp-client');
|
|
52
52
|
const logger = require('../core/logger');
|
|
53
|
+
const approvals = require('./tool-approvals');
|
|
54
|
+
const crypto = require('node:crypto');
|
|
53
55
|
|
|
54
56
|
// The sandbox root = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package
|
|
55
57
|
// root - see src/core/config-home.js). Used as the isolation boundary the default clientFactory
|
|
@@ -58,6 +60,17 @@ const logger = require('../core/logger');
|
|
|
58
60
|
const { resolveConfigHome } = require('../core/config-home');
|
|
59
61
|
const DEFAULT_ROOT = resolveConfigHome();
|
|
60
62
|
|
|
63
|
+
// The aggregators open in THIS process, by writer id (a config reload replaces one).
|
|
64
|
+
const LIVE_WRITERS = new Set();
|
|
65
|
+
|
|
66
|
+
/** Is the gateway that wrote a pending entry still running? In this process: that aggregator is
|
|
67
|
+
* still open. Elsewhere: its process still exists. An entry with no writer is kept. */
|
|
68
|
+
function writerAlive(by) {
|
|
69
|
+
if (!by || typeof by.pid !== 'number') return true;
|
|
70
|
+
if (by.pid === process.pid) return LIVE_WRITERS.has(by.id);
|
|
71
|
+
try { process.kill(by.pid, 0); return true; } catch (err) { return !!err && err.code === 'EPERM'; }
|
|
72
|
+
}
|
|
73
|
+
|
|
61
74
|
/** True for a non-empty string. */
|
|
62
75
|
function isNonEmptyString(v) {
|
|
63
76
|
return typeof v === 'string' && v.length > 0;
|
|
@@ -113,10 +126,10 @@ function isDriveRelative(t) {
|
|
|
113
126
|
return /^[A-Za-z]:(?![\\/])/.test(t);
|
|
114
127
|
}
|
|
115
128
|
|
|
116
|
-
// Environment variables that make a child load code, whatever its args say
|
|
129
|
+
// Environment variables that make a child load code, whatever its args say. Two classes with two rules. All platforms' vars are checked on all platforms because
|
|
117
130
|
// a config is portable and gets run somewhere else, and keys are compared CASE-INSENSITIVELY
|
|
118
131
|
// everywhere because Windows env lookup is case-insensitive - `node_options` reaches the child
|
|
119
|
-
// exactly as `NODE_OPTIONS` does, so an exact-case allowlist
|
|
132
|
+
// exactly as `NODE_OPTIONS` does, so an exact-case allowlist would be a one-character bypass there.
|
|
120
133
|
//
|
|
121
134
|
// OPTION-STRING vars are a mini command line in the runtime's OWN grammar: quoting, `=` forms,
|
|
122
135
|
// `file:`/`data:` URLs, inspector flags. A path-shaped guard cannot verify them (quoted
|
|
@@ -199,6 +212,78 @@ function isInside(root, target) {
|
|
|
199
212
|
* @param {string} gatewayRoot the sandbox root the upstream's path-shaped ARGS must stay inside
|
|
200
213
|
* @returns {McpClient}
|
|
201
214
|
*/
|
|
215
|
+
// ── permanent connect failures ───────────────────────────────────────────────────────────────
|
|
216
|
+
//
|
|
217
|
+
// Every failed connect arms the background reconnect (1s,2s,4s,8s,16s, then a 30 s keepalive).
|
|
218
|
+
// That is right for a slow-booting server and wrong for one that can NEVER start: a PERMANENT
|
|
219
|
+
// failure runs the same ladder for PERMANENT_RETRY_LIMIT retries, then stops with one stderr line
|
|
220
|
+
// and is listed by stoppedUpstreams() (and /health) until a config reload builds a fresh aggregator
|
|
221
|
+
// or an operator reconnects it. Classified by the error CODE stamped where it is raised - never
|
|
222
|
+
// by message text:
|
|
223
|
+
// ENOENT the command does not exist (the client keeps the spawn error's code, and
|
|
224
|
+
// on Windows names a command the cmd.exe launch shim hid - commandMissing)
|
|
225
|
+
// TF_ISOLATION_REFUSED the isolation guard refused the config (deterministic for a given config)
|
|
226
|
+
// Not permanent: a modernOnly refusal - a slow modern server can miss the discover window and
|
|
227
|
+
// look legacy once, so it keeps retrying. Store validation needs no class here: a wrong-typed
|
|
228
|
+
// config is refused at load (fail-loud config) and never reaches a connect.
|
|
229
|
+
const ISOLATION_REFUSED = 'TF_ISOLATION_REFUSED';
|
|
230
|
+
const PERMANENT_ERROR_CODES = new Set(['ENOENT', ISOLATION_REFUSED]);
|
|
231
|
+
// Retries after the failed first attempt: 1+2+4+8+16+30 s, then 8 keepalives at 30 s = 301 s, so
|
|
232
|
+
// about five minutes and 15 attempts in all. Consecutive: a transient failure in between restarts
|
|
233
|
+
// the count (the environment is changing), and any successful connect clears it.
|
|
234
|
+
const PERMANENT_RETRY_LIMIT = 14;
|
|
235
|
+
// The stop line and stoppedUpstreams()[].error keep at most this many characters of the cause.
|
|
236
|
+
const STOPPED_ERROR_MAX = 400;
|
|
237
|
+
|
|
238
|
+
// ── tool schema validation ──────────────────────────────────────────────────────────────────────
|
|
239
|
+
//
|
|
240
|
+
// One broken tool descriptor must not take its upstream down: validate each listed tool BEFORE it
|
|
241
|
+
// is served and drop only that tool, loudly - degraded, not dead. It runs first at the listing
|
|
242
|
+
// chokepoint, ahead of descriptor approval, so a pathological schema (nesting deep enough to exhaust
|
|
243
|
+
// the stack of every recursive walk after it) is dropped as one tool instead of failing the approval
|
|
244
|
+
// hash for the whole upstream. Structural checks only (zero-dep; not a JSON Schema meta-validator).
|
|
245
|
+
// A MISSING (or null) field stays tolerated - a missing inputSchema is served as { type:"object" },
|
|
246
|
+
// as before; real servers omit it and it is harmless. Present-but-wrong is dropped.
|
|
247
|
+
const MAX_SCHEMA_DEPTH = 64;
|
|
248
|
+
|
|
249
|
+
/** Does `value` nest deeper than `limit` levels? ITERATIVE on purpose: a hostile schema must not be
|
|
250
|
+
* able to exhaust the stack of the very check meant to stop it. */
|
|
251
|
+
function nestingExceeds(value, limit) {
|
|
252
|
+
const stack = [[value, 1]];
|
|
253
|
+
while (stack.length) {
|
|
254
|
+
const [v, depth] = stack.pop();
|
|
255
|
+
if (v === null || typeof v !== 'object') continue;
|
|
256
|
+
if (depth > limit) return true;
|
|
257
|
+
for (const k of Object.keys(v)) stack.push([v[k], depth + 1]);
|
|
258
|
+
}
|
|
259
|
+
return false;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** @returns {string|null} the first structural defect of a listed tool, or null when it may be served. */
|
|
263
|
+
function toolSchemaProblem(t) {
|
|
264
|
+
const isObj = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
|
|
265
|
+
const given = (v) => v !== undefined && v !== null;
|
|
266
|
+
if (given(t.description) && typeof t.description !== 'string') return '"description" is not a string';
|
|
267
|
+
if (given(t.title) && typeof t.title !== 'string') return '"title" is not a string';
|
|
268
|
+
if (given(t.annotations) && !isObj(t.annotations)) return '"annotations" is not an object';
|
|
269
|
+
for (const f of ['inputSchema', 'outputSchema']) {
|
|
270
|
+
const s = t[f];
|
|
271
|
+
if (!given(s)) continue;
|
|
272
|
+
if (!isObj(s)) return `${f} is not a JSON Schema object`;
|
|
273
|
+
if (s.type !== undefined && s.type !== 'object') return `${f} "type" must be "object", got ${String(JSON.stringify(s.type)).slice(0, 40)}`;
|
|
274
|
+
if (s.properties !== undefined && !isObj(s.properties)) return `${f} "properties" is not an object`;
|
|
275
|
+
if (s.required !== undefined && !(Array.isArray(s.required) && s.required.every((r) => typeof r === 'string'))) {
|
|
276
|
+
return `${f} "required" is not an array of strings`;
|
|
277
|
+
}
|
|
278
|
+
if (nestingExceeds(s, MAX_SCHEMA_DEPTH)) return `${f} is nested deeper than ${MAX_SCHEMA_DEPTH} levels`;
|
|
279
|
+
}
|
|
280
|
+
return null;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function isPermanentConnectError(err) {
|
|
284
|
+
return !!(err && typeof err.code === 'string' && PERMANENT_ERROR_CODES.has(err.code));
|
|
285
|
+
}
|
|
286
|
+
|
|
202
287
|
function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths, clientInfo, eraHint) {
|
|
203
288
|
const id = isNonEmptyString(upstream && upstream.id) ? upstream.id : '(unknown)';
|
|
204
289
|
const args = Array.isArray(upstream && upstream.args) ? upstream.args : [];
|
|
@@ -236,7 +321,7 @@ function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths,
|
|
|
236
321
|
}
|
|
237
322
|
return false; // permitted under the wrap, or by this upstream's own opt-out
|
|
238
323
|
}
|
|
239
|
-
throw new Error(`isolation: upstream ${id} references a path outside the gateway root`);
|
|
324
|
+
throw Object.assign(new Error(`isolation: upstream ${id} references a path outside the gateway root`), { code: ISOLATION_REFUSED });
|
|
240
325
|
};
|
|
241
326
|
|
|
242
327
|
for (const arg of args) {
|
|
@@ -250,10 +335,9 @@ function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths,
|
|
|
250
335
|
}
|
|
251
336
|
}
|
|
252
337
|
|
|
253
|
-
// cwd - the SPAWN BASE
|
|
338
|
+
// cwd - the SPAWN BASE. Guarded because it decides where a bare, separator-free arg like
|
|
254
339
|
// 'server.js' actually resolves: looksLikePath() is false for such a token so the loop above
|
|
255
|
-
// never sees it, while the child resolves it against cwd.
|
|
256
|
-
// guard-base and spawn-base "agree by construction" - true only while cwd was unset.
|
|
340
|
+
// never sees it, while the child resolves it against cwd.
|
|
257
341
|
// Drive-relative gets the same outright refusal as everywhere else: 'C:denied' resolves
|
|
258
342
|
// against the child's per-drive current directory, and isInside() judging it against the root
|
|
259
343
|
// is optimistic-and-wrong whenever the root shares the drive.
|
|
@@ -262,7 +346,7 @@ function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths,
|
|
|
262
346
|
refuse(`cwd "${upstream.cwd}"`);
|
|
263
347
|
}
|
|
264
348
|
|
|
265
|
-
// env - CODE-LOADING variables
|
|
349
|
+
// env - CODE-LOADING variables. A value in any of these loads code
|
|
266
350
|
// into the child however clean the args are (reachable in-band: tf_mcp_add forwards env
|
|
267
351
|
// verbatim). Keys are matched CASE-INSENSITIVELY on every platform - Windows env lookup is
|
|
268
352
|
// case-insensitive, so `node_options` is `NODE_OPTIONS` to the child - and every platform's
|
|
@@ -304,7 +388,7 @@ function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths,
|
|
|
304
388
|
'(path-list env is within that flag\'s scope). Remove the flag to restore the guard.\n');
|
|
305
389
|
return false;
|
|
306
390
|
}
|
|
307
|
-
throw new Error(`isolation: upstream ${id} sets code-loading env (${what})`);
|
|
391
|
+
throw Object.assign(new Error(`isolation: upstream ${id} sets code-loading env (${what})`), { code: ISOLATION_REFUSED });
|
|
308
392
|
};
|
|
309
393
|
// Enumerate EXACTLY as child_process does: `for...in`, which walks own AND INHERITED keys
|
|
310
394
|
// (node's normalizeSpawnArguments: "Prototype values are intentionally included"). An
|
|
@@ -370,7 +454,7 @@ function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths,
|
|
|
370
454
|
// resolve them against the SAME base or a guard-passing relative path fails to spawn
|
|
371
455
|
// whenever --config-dir differs from the launch directory (guard-base and spawn-base agree
|
|
372
456
|
// by construction).
|
|
373
|
-
cwd: isNonEmptyString(upstream && upstream.cwd) ? upstream.cwd : gatewayRoot,
|
|
457
|
+
cwd: isNonEmptyString(upstream && upstream.cwd) ? path.resolve(gatewayRoot, upstream.cwd) : gatewayRoot,
|
|
374
458
|
// Per-upstream `timeoutMs` config -> the client's PAYLOAD window (tools/call | prompts/get |
|
|
375
459
|
// resources/read; default 120 s).
|
|
376
460
|
toolTimeoutMs: Number.isFinite(upstream && upstream.timeoutMs) && upstream.timeoutMs > 0
|
|
@@ -430,6 +514,35 @@ class Aggregator {
|
|
|
430
514
|
}
|
|
431
515
|
this._store = store;
|
|
432
516
|
this._gatewayRoot = isNonEmptyString(gatewayRoot) ? gatewayRoot : DEFAULT_ROOT;
|
|
517
|
+
// Tool descriptor approval (tool-approvals.js). _tools holds what may be SERVED
|
|
518
|
+
// (approved descriptors only); _rawTools the upstream's own listing, so an accept re-applies
|
|
519
|
+
// without a re-list; _pending the changes that make a call refuse.
|
|
520
|
+
this._approvalsPath = path.join(this._gatewayRoot, 'mcp', 'tool-approvals.json');
|
|
521
|
+
/** @type {Map<string, Set<string>>} pending entries ("tool@hash") THIS process wrote, per upstream */
|
|
522
|
+
this._ownPending = new Map();
|
|
523
|
+
this._writer = { pid: process.pid, id: crypto.randomBytes(6).toString('hex') };
|
|
524
|
+
LIVE_WRITERS.add(this._writer.id);
|
|
525
|
+
/** @type {Map<string, object>} a record whose save FAILED, per upstream: evaluated against until a
|
|
526
|
+
* save succeeds, so a file that cannot be written never turns each re-list into a first use */
|
|
527
|
+
this._unsaved = new Map();
|
|
528
|
+
/** @type {Set<string>} upstreams whose tools are WITHHELD (approvals unreadable or unhashable): a
|
|
529
|
+
* call resolved earlier must refuse, not find an empty pending map and run */
|
|
530
|
+
this._withheld = new Set();
|
|
531
|
+
/** @type {Map<string, Array>} upstreamId -> the RAW tools/list result */
|
|
532
|
+
this._rawTools = new Map();
|
|
533
|
+
/** @type {Map<string, Map<string, object>>} upstreamId -> toolName -> pending entry */
|
|
534
|
+
this._pending = new Map();
|
|
535
|
+
/** @type {Array<object>} recent upgrade / server-changed events for /health (bounded) */
|
|
536
|
+
this._approvalEvents = [];
|
|
537
|
+
/** @type {Set<string>} once-per-condition stderr notices already written */
|
|
538
|
+
this._approvalNoticed = new Set();
|
|
539
|
+
/** @type {Map<string, Array<{tool:string, problem:string}>>} upstreamId -> tools dropped by schema validation */
|
|
540
|
+
this._invalid = new Map();
|
|
541
|
+
// Progress scoping: gateway token -> { upstreamId, originalToken, sink }. The
|
|
542
|
+
// random prefix means a caller's own token can never be mistaken for a gateway one.
|
|
543
|
+
this._progressRoutes = new Map();
|
|
544
|
+
this._progressPrefix = 'tfp-' + crypto.randomBytes(4).toString('hex') + '-';
|
|
545
|
+
this._progressSeq = 0;
|
|
433
546
|
// Called when the LIVE tool set changes OUT OF BAND (a background reconnect recovers or finally
|
|
434
547
|
// loses an upstream) so the transport can emit notifications/tools/list_changed. Default no-op;
|
|
435
548
|
// the server wires it to emitToolsListChanged(send). Settable so reloadExpose can re-wire the
|
|
@@ -487,6 +600,12 @@ class Aggregator {
|
|
|
487
600
|
* catch owns the next backoff step. Without this, a death DURING an attempt's tools/list re-seeds
|
|
488
601
|
* the 1s floor and the one-timer guard then swallows the escalation - a permanent 1s respawn loop. */
|
|
489
602
|
this._reconnecting = new Set();
|
|
603
|
+
/** @type {Map<string, number>} upstreamId -> CONSECUTIVE permanent-class connect failures
|
|
604
|
+
* (PERMANENT_ERROR_CODES). Cleared by a transient failure or any successful connect. */
|
|
605
|
+
this._permanentFailures = new Map();
|
|
606
|
+
/** @type {Map<string, {error:string, attempts:number}>} upstreams that hit
|
|
607
|
+
* PERMANENT_RETRY_LIMIT and stopped retrying - stoppedUpstreams() / /health. */
|
|
608
|
+
this._stopped = new Map();
|
|
490
609
|
/** @type {Map<string, 'modern'|'legacy'>} upstreamId -> era negotiated at the LAST successful
|
|
491
610
|
* connect. A death-driven reconnect passes it to the client as eraHint so a legacy upstream
|
|
492
611
|
* skips the server/discover probe (worst case ~3s against a server that silently drops unknown
|
|
@@ -508,12 +627,11 @@ class Aggregator {
|
|
|
508
627
|
* failure, handshake timeout, listTools failure) is collected into failed[]. An
|
|
509
628
|
* upstream already connected (cached) is treated as connected without reconnecting.
|
|
510
629
|
*
|
|
511
|
-
*
|
|
512
|
-
*
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
516
|
-
* and failed[] still reports the startup outcome to the caller as before.
|
|
630
|
+
* A startup failure is not a one-off failed[] entry: every failed upstream is handed to
|
|
631
|
+
* _scheduleReconnect - exponential 1s..16s, then the 30s keepalive, the same treatment a
|
|
632
|
+
* mid-session death gets; reconnect_slow is logged once when the slow phase begins, and
|
|
633
|
+
* failed[] still reports the startup outcome to the caller. A PERMANENT failure (missing
|
|
634
|
+
* command, isolation refusal) counts toward PERMANENT_RETRY_LIMIT and then stops.
|
|
517
635
|
*
|
|
518
636
|
* @returns {Promise<{ connected: string[], failed: Array<{id:string, error:string}> }>}
|
|
519
637
|
*/
|
|
@@ -552,16 +670,19 @@ class Aggregator {
|
|
|
552
670
|
// the moment its connect fails, instead of lingering for the rest of the batch. The rethrow
|
|
553
671
|
// hands the original error to allSettled; a throwing _discard must never mask it.
|
|
554
672
|
//
|
|
555
|
-
//
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
|
|
561
|
-
|
|
673
|
+
// Registered in _connecting for the life of the task, exactly like ensureConnected's own
|
|
674
|
+
// connect: _connectOne caches the client BEFORE its handshake completes, so a concurrent
|
|
675
|
+
// ensureConnected(id) that inspected the cache instead of joining would read it as stale,
|
|
676
|
+
// discard it mid-handshake and connect a second time. Joiners get the client (same shape as
|
|
677
|
+
// ensureConnected's promise); the entry clears on settle either way.
|
|
678
|
+
const promise = (async () => {
|
|
679
|
+
await this._connectOne(upstream);
|
|
680
|
+
return this._clients.get(id);
|
|
681
|
+
})().catch((err) => {
|
|
562
682
|
try { this._discard(id); } catch (_e) { /* cleanup is best-effort; report the real error */ }
|
|
563
683
|
throw err;
|
|
564
|
-
});
|
|
684
|
+
}).finally(() => { this._connecting.delete(id); });
|
|
685
|
+
this._connecting.set(id, promise);
|
|
565
686
|
tasks.push({ id, cached: false, promise });
|
|
566
687
|
}
|
|
567
688
|
|
|
@@ -579,6 +700,7 @@ class Aggregator {
|
|
|
579
700
|
const error = errMsg(settled[i].reason);
|
|
580
701
|
logger.log({ type: 'mcp', event: 'connect_failed', upstream: id, error });
|
|
581
702
|
failed.push({ id, error });
|
|
703
|
+
if (this._countConnectFailure(id, settled[i].reason)) continue; // (a limit of 0 would stop here)
|
|
582
704
|
// A STARTUP failure is usually transient - a slow-booting upstream that missed its
|
|
583
705
|
// handshake window while eleven siblings were spawning next to it. Arm the same
|
|
584
706
|
// self-healing backoff a mid-session death gets: without it a startup failure stranded
|
|
@@ -609,10 +731,10 @@ class Aggregator {
|
|
|
609
731
|
}
|
|
610
732
|
|
|
611
733
|
// Route through ensureConnected: it JOINS an in-flight connect via _connecting instead of
|
|
612
|
-
// racing it
|
|
613
|
-
// loser
|
|
614
|
-
// allowDisabled: discover is the ONE caller permitted to connect a disabled upstream
|
|
615
|
-
//
|
|
734
|
+
// racing it, so two concurrent discover() calls cannot double-spawn the child and leak the
|
|
735
|
+
// loser. ensureConnected also reaps its own failed connects.
|
|
736
|
+
// allowDisabled: discover is the ONE caller permitted to connect a disabled upstream, so a
|
|
737
|
+
// disabled upstream's tools can be inspected before enabling it.
|
|
616
738
|
try {
|
|
617
739
|
await this.ensureConnected(upstreamId, { allowDisabled: true });
|
|
618
740
|
} catch (err) {
|
|
@@ -721,13 +843,13 @@ class Aggregator {
|
|
|
721
843
|
* @param {any} args structured arguments forwarded verbatim to callTool
|
|
722
844
|
* @returns {{ execute: Function, toolName: string, upstream: string } | null}
|
|
723
845
|
*/
|
|
724
|
-
resolveExposedExecution(name, args, meta) {
|
|
846
|
+
resolveExposedExecution(name, args, meta, progress) {
|
|
725
847
|
const hit = this._findExposedExecution(name);
|
|
726
848
|
if (!hit) return null;
|
|
727
849
|
const { client, realTool, upstream, exposedName } = hit;
|
|
728
850
|
return {
|
|
729
851
|
// The execute thunk is the seam the server calls AFTER the PreToolUse gate allows it.
|
|
730
|
-
execute: this._forwardExecutor(client, realTool, args, meta),
|
|
852
|
+
execute: this._forwardExecutor(client, realTool, args, meta, upstream, progress),
|
|
731
853
|
toolName: exposedName, // downstream name - the gate's matcher target
|
|
732
854
|
upstream,
|
|
733
855
|
};
|
|
@@ -752,10 +874,39 @@ class Aggregator {
|
|
|
752
874
|
* @param {any} args
|
|
753
875
|
* @returns {() => Promise<any>}
|
|
754
876
|
*/
|
|
755
|
-
_forwardExecutor(client, realTool, args, meta) {
|
|
877
|
+
_forwardExecutor(client, realTool, args, meta, upstreamId, progress) {
|
|
756
878
|
return () => {
|
|
879
|
+
// Checked when the call RUNS, not when it resolved: the tool can change while a permission
|
|
880
|
+
// prompt waits on the user.
|
|
881
|
+
const refusal = this._approvalRefusal(upstreamId || client.id, realTool, 'curated');
|
|
882
|
+
if (refusal) return Promise.resolve(refusal.envelope);
|
|
757
883
|
this._warnIfPinned(client.id, realTool);
|
|
758
|
-
|
|
884
|
+
const scoped = this._scopeProgress(upstreamId || client.id, meta, progress);
|
|
885
|
+
return Promise.resolve()
|
|
886
|
+
.then(() => client.callTool(realTool, args, scoped.meta))
|
|
887
|
+
.finally(scoped.release);
|
|
888
|
+
};
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/**
|
|
892
|
+
* Scope a caller's progressToken to this one call. Client tokens are client-chosen,
|
|
893
|
+
* so two callers can use the same one over a shared upstream - and the spec says a token "MUST be
|
|
894
|
+
* unique across all active requests" from its SENDER, which toward the upstream is the gateway.
|
|
895
|
+
* With a `progress` sink the token is rewritten to a gateway token and that token's beats are
|
|
896
|
+
* routed back to the sink with the caller's token restored (_forwardUpstreamNotification). Without
|
|
897
|
+
* a sink the meta goes through unchanged (the token still keeps the upstream's timeout alive).
|
|
898
|
+
* @returns {{ meta: object|undefined, release: () => void }} release() when the call settles
|
|
899
|
+
*/
|
|
900
|
+
_scopeProgress(upstreamId, meta, progress) {
|
|
901
|
+
const tok = meta && typeof meta === 'object' ? meta.progressToken : undefined;
|
|
902
|
+
if (typeof progress !== 'function' || !((typeof tok === 'string' && tok.length) || Number.isInteger(tok))) {
|
|
903
|
+
return { meta, release: () => {} };
|
|
904
|
+
}
|
|
905
|
+
const gatewayToken = this._progressPrefix + (++this._progressSeq);
|
|
906
|
+
this._progressRoutes.set(gatewayToken, { upstreamId, originalToken: tok, sink: progress });
|
|
907
|
+
return {
|
|
908
|
+
meta: Object.assign({}, meta, { progressToken: gatewayToken }),
|
|
909
|
+
release: () => { this._progressRoutes.delete(gatewayToken); },
|
|
759
910
|
};
|
|
760
911
|
}
|
|
761
912
|
|
|
@@ -819,9 +970,8 @@ class Aggregator {
|
|
|
819
970
|
if (!init || typeof init !== 'object') return null;
|
|
820
971
|
const si = init.serverInfo && typeof init.serverInfo === 'object' ? init.serverInfo : {};
|
|
821
972
|
// serverInfo passes VERBATIM minus the two fields some servers wrongly nest inside it
|
|
822
|
-
// (capabilities/instructions - extracted separately below). A
|
|
823
|
-
//
|
|
824
|
-
// server-everything (found wire-testing the wrap, 2026-07-17). Blocklist, so future identity fields survive.
|
|
973
|
+
// (capabilities/instructions - extracted separately below). A blocklist, not a whitelist,
|
|
974
|
+
// so the spec's serverInfo.title (2025-06-18) and future identity fields survive the wrap.
|
|
825
975
|
const serverInfo = {};
|
|
826
976
|
for (const k of Object.keys(si)) {
|
|
827
977
|
if (k === 'capabilities' || k === 'instructions') continue;
|
|
@@ -890,6 +1040,16 @@ class Aggregator {
|
|
|
890
1040
|
setWrapChatterUpstream(v) {
|
|
891
1041
|
const next = v == null ? null : v;
|
|
892
1042
|
if (next !== this.wrapChatterUpstream && this._subscribedUris instanceof Set) {
|
|
1043
|
+
// Tell the OUTGOING target to drop them first - it was told resources/subscribe for every
|
|
1044
|
+
// one and, forgotten only locally, kept emitting resources/updated for URIs nothing consumed
|
|
1045
|
+
// for the life of its process. releaseResources fans out to the CURRENT wrapChatterUpstream,
|
|
1046
|
+
// so it runs BEFORE the reassignment; the refs are dropped wholesale first because a context
|
|
1047
|
+
// switch releases everything, whatever the listen refcounts said.
|
|
1048
|
+
if (this._subscribedUris.size) {
|
|
1049
|
+
const uris = [...this._subscribedUris];
|
|
1050
|
+
if (this._subscribedRefs instanceof Map) this._subscribedRefs.clear();
|
|
1051
|
+
this.releaseResources(uris);
|
|
1052
|
+
}
|
|
893
1053
|
this._subscribedUris.clear();
|
|
894
1054
|
if (this._subscribedRefs instanceof Map) this._subscribedRefs.clear(); // 5.4: refs go with the context
|
|
895
1055
|
}
|
|
@@ -1022,8 +1182,21 @@ class Aggregator {
|
|
|
1022
1182
|
*/
|
|
1023
1183
|
_forwardUpstreamNotification(upstreamId, notification) {
|
|
1024
1184
|
try {
|
|
1025
|
-
if (this._closed) return; // a swapped-out/torn-down aggregator must never fan out
|
|
1185
|
+
if (this._closed) return; // a swapped-out/torn-down aggregator must never fan out
|
|
1026
1186
|
if (!notification || typeof notification.method !== 'string') return;
|
|
1187
|
+
// Scoped progress: a beat carrying one of OUR tokens goes to that call's sink,
|
|
1188
|
+
// caller's token restored - only from the upstream the token was issued to - and NOWHERE else:
|
|
1189
|
+
// not a settled call, not another upstream's, never on to a broadcast.
|
|
1190
|
+
if (notification.method === 'notifications/progress') {
|
|
1191
|
+
const tok = notification.params && notification.params.progressToken;
|
|
1192
|
+
if (typeof tok === 'string' && tok.startsWith(this._progressPrefix)) {
|
|
1193
|
+
const route = this._progressRoutes.get(tok);
|
|
1194
|
+
if (route && route.upstreamId === upstreamId) {
|
|
1195
|
+
try { route.sink(Object.assign({}, notification.params, { progressToken: route.originalToken })); } catch (_e) { /* a sink never breaks the read loop */ }
|
|
1196
|
+
}
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1027
1200
|
// tasks/status rides the wrap-chatter lane too: the wrap advertises the
|
|
1028
1201
|
// upstream's `tasks` capability verbatim, so a task started through the wrap owes its
|
|
1029
1202
|
// status notifications exactly as a direct connection would deliver them (2025-11-25
|
|
@@ -1033,7 +1206,7 @@ class Aggregator {
|
|
|
1033
1206
|
notification.method === 'notifications/tasks/status');
|
|
1034
1207
|
if (!Aggregator.BRIDGED_NOTIFICATIONS.has(notification.method) && !isWrapChatter) return;
|
|
1035
1208
|
// Forward a CLEAN notification only - a misbehaving upstream's stray `id: null` field would
|
|
1036
|
-
// read as a response to downstream parsers
|
|
1209
|
+
// read as a response to downstream parsers.
|
|
1037
1210
|
const clean = { jsonrpc: '2.0', method: notification.method };
|
|
1038
1211
|
if (notification.params !== undefined) clean.params = notification.params;
|
|
1039
1212
|
const emit = () => {
|
|
@@ -1045,7 +1218,7 @@ class Aggregator {
|
|
|
1045
1218
|
};
|
|
1046
1219
|
if (notification.method === 'notifications/tools/list_changed') {
|
|
1047
1220
|
// A relayed "tools changed" must not point the client at a STALE surface - refresh the
|
|
1048
|
-
// cache FIRST, then relay, so the re-fetch sees the new list
|
|
1221
|
+
// cache FIRST, then relay, so the re-fetch sees the new list. onToolsChanged
|
|
1049
1222
|
// fires too so ToolFunnel's own funnel-mode signal stays coherent.
|
|
1050
1223
|
this._listAndCache(upstreamId)
|
|
1051
1224
|
.then(() => { try { if (typeof this.onToolsChanged === 'function') this.onToolsChanged(); } catch (_e) { /* ignore */ } })
|
|
@@ -1116,7 +1289,17 @@ class Aggregator {
|
|
|
1116
1289
|
// (allowConnect:false) still never joins - joining would block the stdio chain.
|
|
1117
1290
|
if (this._connecting.has(upstreamId)) {
|
|
1118
1291
|
if (!(opts && opts.allowConnect === false)) return this._connecting.get(upstreamId);
|
|
1119
|
-
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background)`);
|
|
1292
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background) - retry in a few seconds`);
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
// STOPPED (permanent failure past the ceiling): nothing is reconnecting, so the RUN path must
|
|
1296
|
+
// not say it is - name the cause and the way back. The background path (an operator's
|
|
1297
|
+
// discover) still makes its one attempt below.
|
|
1298
|
+
const stopped = this._stopped.get(upstreamId);
|
|
1299
|
+
if (stopped && !allowConnect) {
|
|
1300
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected - stopped retrying after ` +
|
|
1301
|
+
`${stopped.attempts} attempts: ${stopped.error}. Fix the cause, then save expose.json (any change reloads it) ` +
|
|
1302
|
+
'or restart the gateway.');
|
|
1120
1303
|
}
|
|
1121
1304
|
|
|
1122
1305
|
// Fast path: a live cached client serves BOTH modes immediately.
|
|
@@ -1127,14 +1310,14 @@ class Aggregator {
|
|
|
1127
1310
|
// Dead cached client. The RUN path fails clean (never (re)connects); the background path drops
|
|
1128
1311
|
// it and reconnects below.
|
|
1129
1312
|
if (!allowConnect) {
|
|
1130
|
-
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" disconnected (reconnecting in background)`);
|
|
1313
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" disconnected (reconnecting in background) - retry in a few seconds`);
|
|
1131
1314
|
}
|
|
1132
1315
|
logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'stale' });
|
|
1133
1316
|
this._discard(upstreamId);
|
|
1134
1317
|
} else if (!allowConnect) {
|
|
1135
1318
|
// Not connected. The RUN path neither connects NOR joins an in-flight connect - joining would
|
|
1136
1319
|
// block the stdio chain for the connect+list latency (the head-of-line freeze this mode avoids).
|
|
1137
|
-
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background)`);
|
|
1320
|
+
throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background) - retry in a few seconds`);
|
|
1138
1321
|
}
|
|
1139
1322
|
|
|
1140
1323
|
// Background path only (allowConnect): connect from scratch. In-flight connects were joined
|
|
@@ -1175,6 +1358,10 @@ class Aggregator {
|
|
|
1175
1358
|
// A MANUAL kick deliberately re-runs the full era negotiation - the operator may have just
|
|
1176
1359
|
// upgraded the server, and "reconnect" should observe reality, not replay a memo of it.
|
|
1177
1360
|
this._eraMemo.delete(upstreamId);
|
|
1361
|
+
// ...and it restarts a STOPPED upstream with a fresh retry budget: the operator is saying the
|
|
1362
|
+
// cause may be fixed (the command installed, the path moved inside the root).
|
|
1363
|
+
this._stopped.delete(upstreamId);
|
|
1364
|
+
this._permanentFailures.delete(upstreamId);
|
|
1178
1365
|
try {
|
|
1179
1366
|
return await this.ensureConnected(upstreamId);
|
|
1180
1367
|
} catch (err) {
|
|
@@ -1187,7 +1374,7 @@ class Aggregator {
|
|
|
1187
1374
|
// retry this path promises.
|
|
1188
1375
|
const stale = this._reconnectTimers.get(upstreamId);
|
|
1189
1376
|
if (stale) { clearTimeout(stale); this._reconnectTimers.delete(upstreamId); }
|
|
1190
|
-
this._scheduleReconnect(upstreamId, 0);
|
|
1377
|
+
if (!this._countConnectFailure(upstreamId, err)) this._scheduleReconnect(upstreamId, 0);
|
|
1191
1378
|
throw err;
|
|
1192
1379
|
}
|
|
1193
1380
|
}
|
|
@@ -1244,10 +1431,50 @@ class Aggregator {
|
|
|
1244
1431
|
try { this.onToolsChanged(); } catch (_e) { /* never throw out of an event handler */ }
|
|
1245
1432
|
}
|
|
1246
1433
|
|
|
1434
|
+
/**
|
|
1435
|
+
* Count one failed connect toward the permanent-failure ceiling. A PERMANENT-class error adds to
|
|
1436
|
+
* this upstream's consecutive count; any other error resets it. Past PERMANENT_RETRY_LIMIT retries
|
|
1437
|
+
* the upstream is STOPPED: one stderr line, a log event, and stoppedUpstreams() lists it. NEVER throws.
|
|
1438
|
+
* @returns {boolean} true when the upstream is now stopped (the caller must not schedule a retry)
|
|
1439
|
+
*/
|
|
1440
|
+
_countConnectFailure(upstreamId, err) {
|
|
1441
|
+
try {
|
|
1442
|
+
if (!isPermanentConnectError(err)) {
|
|
1443
|
+
this._permanentFailures.delete(upstreamId);
|
|
1444
|
+
return false;
|
|
1445
|
+
}
|
|
1446
|
+
const attempts = (this._permanentFailures.get(upstreamId) || 0) + 1;
|
|
1447
|
+
this._permanentFailures.set(upstreamId, attempts);
|
|
1448
|
+
if (attempts <= PERMANENT_RETRY_LIMIT) return false;
|
|
1449
|
+
// ONE bounded line: a real failure carries the upstream's stderr (newlines, possibly pages
|
|
1450
|
+
// of it). The client puts the cause first, so the head is the part worth keeping.
|
|
1451
|
+
const flat = errMsg(err).replace(/\s+/g, ' ').trim();
|
|
1452
|
+
const error = flat.length > STOPPED_ERROR_MAX ? flat.slice(0, STOPPED_ERROR_MAX) + '...' : flat;
|
|
1453
|
+
this._stopped.set(upstreamId, { error, attempts });
|
|
1454
|
+
logger.log({ type: 'mcp', event: 'reconnect_stopped', upstream: upstreamId, attempts, error });
|
|
1455
|
+
try {
|
|
1456
|
+
process.stderr.write(`[toolfunnel] upstream "${upstreamId}" stopped retrying after ${attempts} attempts ` +
|
|
1457
|
+
`(a permanent failure: ${error}). Fix the cause, then save expose.json (any change reloads it) or restart the gateway.\n`);
|
|
1458
|
+
} catch (_e) { /* never let the notice sink the caller */ }
|
|
1459
|
+
return true;
|
|
1460
|
+
} catch (_e) {
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
/** Upstreams that hit the permanent-failure ceiling and stopped retrying: [{id, error, attempts}]
|
|
1466
|
+
* (fresh copies). The /health source. Never throws. */
|
|
1467
|
+
stoppedUpstreams() {
|
|
1468
|
+
const out = [];
|
|
1469
|
+
for (const [id, s] of this._stopped) out.push({ id, error: s.error, attempts: s.attempts });
|
|
1470
|
+
return out;
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1247
1473
|
/**
|
|
1248
1474
|
* Schedule ONE background reconnect attempt with exponential backoff: 1s,2s,4s,8s,16s, then capped
|
|
1249
|
-
* at 30s. After the fast phase it does NOT give up - it falls back to a slow
|
|
1250
|
-
* transient outage still self-heals
|
|
1475
|
+
* at 30s. After the fast phase it does NOT give up on a transient failure - it falls back to a slow
|
|
1476
|
+
* 30s keepalive so a long transient outage still self-heals; a PERMANENT failure stops at the
|
|
1477
|
+
* ceiling instead (_countConnectFailure) (a detached/disabled upstream stops it via getUpstream; closeAll
|
|
1251
1478
|
* stops it via _closed). On success the tools are re-listed and a top-level change is signalled. One
|
|
1252
1479
|
* timer per upstream (no pile-up); the timer is unref'd so it never keeps the process alive. NEVER throws.
|
|
1253
1480
|
* @param {string} upstreamId
|
|
@@ -1256,6 +1483,7 @@ class Aggregator {
|
|
|
1256
1483
|
_scheduleReconnect(upstreamId, attempt) {
|
|
1257
1484
|
if (this._closed) return;
|
|
1258
1485
|
if (this._reconnectTimers.has(upstreamId)) return; // a reconnect is already pending for this id
|
|
1486
|
+
if (this._stopped.has(upstreamId)) return; // past the permanent-failure ceiling - see _countConnectFailure
|
|
1259
1487
|
const upstream = this._store.getUpstream(upstreamId);
|
|
1260
1488
|
if (!upstream || upstream.enabled !== true) return; // detached/disabled - stop trying
|
|
1261
1489
|
const MAX_FAST_ATTEMPTS = 6; // exponential phase; beyond it, a slow keepalive at the 30s cap
|
|
@@ -1280,6 +1508,8 @@ class Aggregator {
|
|
|
1280
1508
|
if (attempt + 1 === MAX_FAST_ATTEMPTS) {
|
|
1281
1509
|
logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: MAX_FAST_ATTEMPTS });
|
|
1282
1510
|
}
|
|
1511
|
+
// A PERMANENT failure past the ceiling stops here (one stderr line; stoppedUpstreams()).
|
|
1512
|
+
if (this._countConnectFailure(upstreamId, err)) return;
|
|
1283
1513
|
// Keep escalating to the cap, then keep retrying slowly (cap the counter so the delay stays
|
|
1284
1514
|
// at 30s) - never permanently give up while the upstream stays enabled. The escalation is
|
|
1285
1515
|
// scheduled while still in flight (the guard below checks _reconnectTimers, which is empty
|
|
@@ -1351,8 +1581,12 @@ class Aggregator {
|
|
|
1351
1581
|
* @param {any} args
|
|
1352
1582
|
* @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
|
|
1353
1583
|
*/
|
|
1354
|
-
resolveLeanExecution(name, args) {
|
|
1584
|
+
resolveLeanExecution(name, args, callOpts) {
|
|
1355
1585
|
if (!isNonEmptyString(name)) return null;
|
|
1586
|
+
// callOpts: { meta, progress } - the caller's request _meta rides the lean forward as it does
|
|
1587
|
+
// every other route, and progress comes back through the sink.
|
|
1588
|
+
const leanMeta = callOpts && callOpts.meta && typeof callOpts.meta === 'object' ? callOpts.meta : undefined;
|
|
1589
|
+
const leanProgress = callOpts && typeof callOpts.progress === 'function' ? callOpts.progress : undefined;
|
|
1356
1590
|
for (const [upstreamId, tools] of this._tools) {
|
|
1357
1591
|
if (!this._clients.has(upstreamId)) continue;
|
|
1358
1592
|
const upstream = this._store.getUpstream(upstreamId);
|
|
@@ -1364,12 +1598,21 @@ class Aggregator {
|
|
|
1364
1598
|
const realTool = t.name;
|
|
1365
1599
|
const self = this;
|
|
1366
1600
|
const execute = async () => {
|
|
1601
|
+
// A changed descriptor waiting for acceptance refuses here, before any forward.
|
|
1602
|
+
const refusal = self._approvalRefusal(upstreamId, realTool, 'lean');
|
|
1603
|
+
if (refusal) throw Object.assign(new Error(refusal.text), { withheld: refusal.envelope.structuredContent });
|
|
1367
1604
|
// allowConnect:false - the run path NEVER synchronously (re)connects (that would freeze the
|
|
1368
1605
|
// stdio loop, starving pings). It uses a live client or joins an in-flight background
|
|
1369
1606
|
// reconnect, else fails clean; the onClose-driven background reconnect owns recovery.
|
|
1370
1607
|
const client = await self.ensureConnected(upstreamId, { allowConnect: false });
|
|
1371
1608
|
self._warnIfPinned(upstreamId, realTool);
|
|
1372
|
-
const
|
|
1609
|
+
const scoped = self._scopeProgress(upstreamId, leanMeta, leanProgress);
|
|
1610
|
+
let envelope;
|
|
1611
|
+
try {
|
|
1612
|
+
envelope = await client.callTool(realTool, args, scoped.meta);
|
|
1613
|
+
} finally {
|
|
1614
|
+
scoped.release();
|
|
1615
|
+
}
|
|
1373
1616
|
// Preserve the upstream's failure signal. callTool RESOLVES even on isError:true, so a
|
|
1374
1617
|
// failed upstream call must become a THROW -> gatedRun returns { ok:false } -> run_tool
|
|
1375
1618
|
// surfaces isError:true. Otherwise a failed call would be reported as success - and the
|
|
@@ -1400,7 +1643,7 @@ class Aggregator {
|
|
|
1400
1643
|
* @param {any} args
|
|
1401
1644
|
* @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
|
|
1402
1645
|
*/
|
|
1403
|
-
resolveRawExecution(name, args, meta, out) {
|
|
1646
|
+
resolveRawExecution(name, args, meta, out, progress) {
|
|
1404
1647
|
if (!isNonEmptyString(name)) return null;
|
|
1405
1648
|
for (const [upstreamId, tools] of this._tools) {
|
|
1406
1649
|
if (!this._clients.has(upstreamId)) continue;
|
|
@@ -1413,17 +1656,27 @@ class Aggregator {
|
|
|
1413
1656
|
const realTool = t.name;
|
|
1414
1657
|
const self = this;
|
|
1415
1658
|
const execute = async () => {
|
|
1659
|
+
// A changed descriptor waiting for acceptance refuses here (the wrap has no meta-tools, so
|
|
1660
|
+
// the result names the UI and CLI routes).
|
|
1661
|
+
const refusal = self._approvalRefusal(upstreamId, realTool, 'raw');
|
|
1662
|
+
if (refusal) return refusal.envelope;
|
|
1416
1663
|
// allowConnect:false - same freeze-avoidance rule as the lean path.
|
|
1417
1664
|
const client = await self.ensureConnected(upstreamId, { allowConnect: false });
|
|
1418
1665
|
self._warnIfPinned(upstreamId, realTool);
|
|
1419
1666
|
const params = { name: realTool, arguments: args == null ? {} : args };
|
|
1420
1667
|
// The caller's request-scoped _meta (progressToken, trace keys - already stripped of
|
|
1421
1668
|
// protocol keys) rides the forward: a direct server would receive it, so the wrapped
|
|
1422
|
-
// one must too - without it the upstream never emits progress for the call.
|
|
1423
|
-
|
|
1669
|
+
// one must too - without it the upstream never emits progress for the call. With a
|
|
1670
|
+
// progress sink the token is scoped to this call (_scopeProgress).
|
|
1671
|
+
const scoped = self._scopeProgress(upstreamId, meta, progress);
|
|
1672
|
+
if (scoped.meta && typeof scoped.meta === 'object' && Object.keys(scoped.meta).length) params._meta = scoped.meta;
|
|
1424
1673
|
// `out.rpcId` is set synchronously at issue time - the caller's cancel-translation
|
|
1425
1674
|
// hook (the same contract as forwardRaw's).
|
|
1426
|
-
|
|
1675
|
+
try {
|
|
1676
|
+
return await client.request('tools/call', params, out);
|
|
1677
|
+
} finally {
|
|
1678
|
+
scoped.release();
|
|
1679
|
+
}
|
|
1427
1680
|
};
|
|
1428
1681
|
return { execute, toolName: name, upstream: upstreamId };
|
|
1429
1682
|
}
|
|
@@ -1438,6 +1691,7 @@ class Aggregator {
|
|
|
1438
1691
|
*/
|
|
1439
1692
|
async closeAll() {
|
|
1440
1693
|
this._closed = true; // latch BEFORE teardown so any in-flight execute thunk refuses to re-spawn
|
|
1694
|
+
LIVE_WRITERS.delete(this._writer.id);
|
|
1441
1695
|
for (const [, timer] of this._reconnectTimers) {
|
|
1442
1696
|
try { clearTimeout(timer); } catch (_e) { /* ignore */ }
|
|
1443
1697
|
}
|
|
@@ -1455,6 +1709,11 @@ class Aggregator {
|
|
|
1455
1709
|
}
|
|
1456
1710
|
this._clients.clear();
|
|
1457
1711
|
this._tools.clear();
|
|
1712
|
+
this._rawTools.clear();
|
|
1713
|
+
this._pending.clear();
|
|
1714
|
+
this._withheld.clear();
|
|
1715
|
+
this._invalid.clear();
|
|
1716
|
+
this._progressRoutes.clear();
|
|
1458
1717
|
}
|
|
1459
1718
|
|
|
1460
1719
|
// ── internals ──────────────────────────────────────────────────────────────
|
|
@@ -1524,6 +1783,9 @@ class Aggregator {
|
|
|
1524
1783
|
command: upstream.command,
|
|
1525
1784
|
tools: (this._tools.get(upstream.id) || []).length,
|
|
1526
1785
|
});
|
|
1786
|
+
// A connect that worked ends any permanent-failure count or stop (every connect path runs here).
|
|
1787
|
+
this._permanentFailures.delete(upstream.id);
|
|
1788
|
+
this._stopped.delete(upstream.id);
|
|
1527
1789
|
// Legacy shim STARTUP warning (the pin's contract: warn at startup AND per call, naming the
|
|
1528
1790
|
// version). stderr, so the operator sees it without the client's content ever growing.
|
|
1529
1791
|
if (upstream.legacyPin === true) {
|
|
@@ -1537,14 +1799,270 @@ class Aggregator {
|
|
|
1537
1799
|
}
|
|
1538
1800
|
}
|
|
1539
1801
|
|
|
1540
|
-
/** tools/list one connected upstream and cache the result; returns a clone. */
|
|
1802
|
+
/** tools/list one connected upstream and cache the result; returns a clone of what is SERVED. */
|
|
1541
1803
|
async _listAndCache(upstreamId) {
|
|
1542
1804
|
const client = this._clients.get(upstreamId);
|
|
1543
1805
|
if (!client) throw new Error(`no connected client for "${upstreamId}"`);
|
|
1544
1806
|
const tools = await client.listTools();
|
|
1545
1807
|
const safe = Array.isArray(tools) ? tools.filter((t) => t && isNonEmptyString(t.name)) : [];
|
|
1546
|
-
|
|
1547
|
-
|
|
1808
|
+
// Every descriptor enters here (connect, discover, list_changed). Drop structurally broken tools
|
|
1809
|
+
// FIRST (one bad tool never takes the upstream down), then keep the RAW listing and SERVE only
|
|
1810
|
+
// approved descriptors: every listing reads _tools, so none can show unapproved text.
|
|
1811
|
+
this._rawTools.set(upstreamId, this._dropInvalidTools(upstreamId, safe));
|
|
1812
|
+
this._applyApprovals(upstreamId, { fresh: true });
|
|
1813
|
+
return cloneTools(this._tools.get(upstreamId) || []);
|
|
1814
|
+
}
|
|
1815
|
+
|
|
1816
|
+
/** Split a listing into servable tools and dropped ones (toolSchemaProblem); record the dropped
|
|
1817
|
+
* for invalidTools() and name each once on stderr. NEVER throws. */
|
|
1818
|
+
_dropInvalidTools(upstreamId, tools) {
|
|
1819
|
+
const keep = [];
|
|
1820
|
+
const dropped = [];
|
|
1821
|
+
for (const t of tools) {
|
|
1822
|
+
let problem;
|
|
1823
|
+
try { problem = toolSchemaProblem(t); } catch (err) { problem = `cannot be checked (${errMsg(err)})`; }
|
|
1824
|
+
if (!problem) { keep.push(t); continue; }
|
|
1825
|
+
dropped.push({ tool: t.name, problem });
|
|
1826
|
+
this._approvalNotice(`invalid:${upstreamId}:${t.name}:${problem}`,
|
|
1827
|
+
`[toolfunnel] upstream "${upstreamId}": dropped tool ${JSON.stringify(t.name)} - ${problem}. ` +
|
|
1828
|
+
'Its other tools are unaffected; the tool returns when the server fixes its descriptor.');
|
|
1829
|
+
}
|
|
1830
|
+
this._invalid.set(upstreamId, dropped);
|
|
1831
|
+
if (dropped.length) logger.log({ type: 'mcp', event: 'tool_dropped', upstream: upstreamId, tools: dropped });
|
|
1832
|
+
return keep;
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/** Tools dropped by schema validation: [{upstream, tool, problem}] (fresh copies; the /health source). */
|
|
1836
|
+
invalidTools() {
|
|
1837
|
+
const out = [];
|
|
1838
|
+
for (const [upstream, list] of this._invalid) for (const d of list) out.push({ upstream, tool: d.tool, problem: d.problem });
|
|
1839
|
+
return out;
|
|
1840
|
+
}
|
|
1841
|
+
|
|
1842
|
+
// ── tool descriptor approval ────────────────────────────────────────────────────────────────
|
|
1843
|
+
|
|
1844
|
+
/**
|
|
1845
|
+
* Apply the approval rules (tool-approvals.js evaluate) to one upstream's RAW listing: fill _tools
|
|
1846
|
+
* with what may be served and _pending with what must refuse, persist a changed record, report on
|
|
1847
|
+
* stderr. An unreadable approvals file WITHHOLDS the upstream's tools (fail closed): treating it as
|
|
1848
|
+
* empty would approve everything as first use. NEVER throws.
|
|
1849
|
+
*
|
|
1850
|
+
* Only a FRESH listing ({fresh: true}, from _listAndCache) - or a first use, with no record yet -
|
|
1851
|
+
* WRITES the file and reports events. Every other call (the file watcher, a refusal's re-read, an
|
|
1852
|
+
* accept) only re-derives what this process serves: gateways sharing a home each run their own
|
|
1853
|
+
* child of the upstream, and a write from one's cached listing would erase another's pending
|
|
1854
|
+
* entry or reverse its upgrade, each write waking the other's watcher.
|
|
1855
|
+
* A write keeps other processes' pending entries (tool-approvals keepForeignPending).
|
|
1856
|
+
*/
|
|
1857
|
+
_applyApprovals(upstreamId, opts) {
|
|
1858
|
+
const fresh = !!(opts && opts.fresh);
|
|
1859
|
+
const raw = this._rawTools.get(upstreamId) || [];
|
|
1860
|
+
const withhold = (key, line) => {
|
|
1861
|
+
this._withheld.add(upstreamId);
|
|
1862
|
+
this._tools.set(upstreamId, []);
|
|
1863
|
+
this._pending.set(upstreamId, new Map());
|
|
1864
|
+
this._approvalNotice(key, line);
|
|
1865
|
+
};
|
|
1866
|
+
let doc;
|
|
1867
|
+
try {
|
|
1868
|
+
doc = approvals.loadApprovals(this._approvalsPath);
|
|
1869
|
+
} catch (err) {
|
|
1870
|
+
withhold(`unreadable:${upstreamId}`, `[toolfunnel] ${errMsg(err)} - the tools of upstream "${upstreamId}" are withheld ` +
|
|
1871
|
+
'until mcp/tool-approvals.json is repaired (or that upstream\'s entry removed from it - removing an entry approves its current tools as first use).');
|
|
1872
|
+
return;
|
|
1873
|
+
}
|
|
1874
|
+
this._approvalNoticed.delete(`unreadable:${upstreamId}`); // readable again: a later breakage reports anew
|
|
1875
|
+
const client = this._clients.get(upstreamId);
|
|
1876
|
+
const serverInfo = (client && client.initializeResult && client.initializeResult.serverInfo) || {};
|
|
1877
|
+
const upstream = this._store.getUpstream(upstreamId);
|
|
1878
|
+
const stored = approvals.getRecord(doc, upstreamId) || this._unsaved.get(upstreamId);
|
|
1879
|
+
const authoritative = fresh || !stored;
|
|
1880
|
+
let r;
|
|
1881
|
+
try {
|
|
1882
|
+
r = approvals.evaluate({
|
|
1883
|
+
record: stored,
|
|
1884
|
+
serverInfo,
|
|
1885
|
+
tools: raw,
|
|
1886
|
+
strict: !!(upstream && upstream.strictApproval === true),
|
|
1887
|
+
writer: this._writer,
|
|
1888
|
+
});
|
|
1889
|
+
} catch (err) {
|
|
1890
|
+
// A descriptor the hash cannot walk (nesting deep enough to exhaust the stack) is not served.
|
|
1891
|
+
withhold(`unhashable:${upstreamId}`, `[toolfunnel] cannot check the tool descriptors of upstream "${upstreamId}" ` +
|
|
1892
|
+
`(${errMsg(err)}) - its tools are withheld.`);
|
|
1893
|
+
return;
|
|
1894
|
+
}
|
|
1895
|
+
const before = this._pending.get(upstreamId);
|
|
1896
|
+
this._withheld.delete(upstreamId);
|
|
1897
|
+
this._tools.set(upstreamId, r.effective);
|
|
1898
|
+
this._pending.set(upstreamId, new Map(r.pending.map((p) => [p.tool, p])));
|
|
1899
|
+
if ((r.changed || this._unsaved.has(upstreamId)) && authoritative) { // an unsaved record retries
|
|
1900
|
+
try {
|
|
1901
|
+
// Read-modify-write the CURRENT file: the UI, the CLI and other gateways write it too.
|
|
1902
|
+
const latest = approvals.loadApprovals(this._approvalsPath);
|
|
1903
|
+
const current = approvals.getRecord(latest, upstreamId);
|
|
1904
|
+
const mine = this._ownPending.get(upstreamId) || new Set();
|
|
1905
|
+
const merged = approvals.keepForeignPending(current, r.record, (n, h) => mine.has(`${n}@${h}`), writerAlive);
|
|
1906
|
+
if (!current || approvals.canonical(current) !== approvals.canonical(merged)) {
|
|
1907
|
+
approvals.setRecord(latest, upstreamId, merged);
|
|
1908
|
+
try {
|
|
1909
|
+
approvals.saveApprovals(this._approvalsPath, latest);
|
|
1910
|
+
} catch (err) {
|
|
1911
|
+
this._unsaved.set(upstreamId, merged);
|
|
1912
|
+
throw err;
|
|
1913
|
+
}
|
|
1914
|
+
}
|
|
1915
|
+
this._unsaved.delete(upstreamId);
|
|
1916
|
+
this._approvalNoticed.delete(`save:${upstreamId}`); // saved again: a later failure reports anew
|
|
1917
|
+
this._ownPending.set(upstreamId, new Set(Object.keys(r.record.pending || {}).map((n) => `${n}@${r.record.pending[n].hash}`)));
|
|
1918
|
+
} catch (err) {
|
|
1919
|
+
if (!this._unsaved.has(upstreamId)) this._unsaved.set(upstreamId, r.record);
|
|
1920
|
+
this._approvalNotice(`save:${upstreamId}`, `[toolfunnel] could not save tool approvals for upstream "${upstreamId}": ` +
|
|
1921
|
+
`${errMsg(err)} - approvals are kept in memory until a save succeeds (changes still wait for acceptance).`);
|
|
1922
|
+
}
|
|
1923
|
+
}
|
|
1924
|
+
// Events (first use, upgrade, server change) are decisions, reported once, when they are made.
|
|
1925
|
+
this._reportApprovals(upstreamId, authoritative ? r : { ...r, events: [] }, before, serverInfo);
|
|
1926
|
+
}
|
|
1927
|
+
|
|
1928
|
+
/** stderr + activity log for what an evaluation did. Pending changes are reported when the SET changes. */
|
|
1929
|
+
_reportApprovals(upstreamId, r, before, serverInfo) {
|
|
1930
|
+
const label = (s) => `${s.name || 'unnamed server'}${s.version ? ' ' + s.version : ''}`;
|
|
1931
|
+
const say = (line) => { try { process.stderr.write(approvals.printable(line) + '\n'); } catch (_e) { /* never sink the caller */ } };
|
|
1932
|
+
for (const e of r.events) {
|
|
1933
|
+
logger.log({ type: 'mcp', event: 'tool_approval', kind: e.kind, upstream: upstreamId, from: e.from, to: e.to, tools: e.tools });
|
|
1934
|
+
if (e.kind === 'first-use') {
|
|
1935
|
+
say(`[toolfunnel] upstream "${upstreamId}": approved ${e.tools.length} tool descriptor(s) on first use (${label(serverInfo)}). ` +
|
|
1936
|
+
'A later change at the same server version will wait for your acceptance.');
|
|
1937
|
+
} else if (e.kind === 'server-changed') {
|
|
1938
|
+
say(`[toolfunnel] upstream "${upstreamId}" now reports a DIFFERENT server ("${e.from}" -> "${e.to}"): approved the new ` +
|
|
1939
|
+
'descriptor(s) of ' + e.tools.map((t) => `${t.tool} (${t.reason})`).join(', ') +
|
|
1940
|
+
'. If you did not change this upstream\'s command, check it (strictApproval holds such changes for acceptance).');
|
|
1941
|
+
this._recordApprovalEvent({ upstream: upstreamId, kind: 'server-changed', from: e.from, to: e.to, tools: e.tools });
|
|
1942
|
+
} else if (e.kind === 'upgrade') {
|
|
1943
|
+
say(`[toolfunnel] upstream "${upstreamId}" upgraded from ${e.from} to ${e.to}: approved the new descriptor(s) of ` +
|
|
1944
|
+
e.tools.map((t) => `${t.tool} (${t.reason})`).join(', ') + '.');
|
|
1945
|
+
this._recordApprovalEvent({ upstream: upstreamId, kind: 'upgrade', from: e.from, to: e.to, tools: e.tools });
|
|
1946
|
+
}
|
|
1947
|
+
}
|
|
1948
|
+
const sig = (list) => list.map((p) => `${p.tool}@${p.hash}`).sort().join('|');
|
|
1949
|
+
const now = sig(r.pending);
|
|
1950
|
+
if (r.pending.length && now !== sig(before ? [...before.values()] : [])) {
|
|
1951
|
+
const version = serverInfo && serverInfo.version;
|
|
1952
|
+
const stayed = !version || (r.record && r.record.server && r.record.server.version === version);
|
|
1953
|
+
say(`[toolfunnel] upstream "${upstreamId}": ${r.pending.length} tool(s) changed ` +
|
|
1954
|
+
(stayed ? `while the server version stayed ${version || '(not reported)'}` : `with the server upgrade to ${version} (strictApproval)`) +
|
|
1955
|
+
' - withheld until accepted: ' + r.pending.map((p) => `${p.tool} (${p.reason})`).join(', ') +
|
|
1956
|
+
`. Review, then accept with the tf_accept_change tool, the UI, or: toolfunnel accept ${upstreamId}`);
|
|
1957
|
+
logger.log({ type: 'mcp', event: 'tool_approval', kind: 'pending', upstream: upstreamId, tools: r.pending.map((p) => ({ tool: p.tool, reason: p.reason })) });
|
|
1958
|
+
}
|
|
1959
|
+
}
|
|
1960
|
+
|
|
1961
|
+
_recordApprovalEvent(e) {
|
|
1962
|
+
this._approvalEvents.push(Object.assign({ at: new Date().toISOString() }, e));
|
|
1963
|
+
if (this._approvalEvents.length > 50) this._approvalEvents.splice(0, this._approvalEvents.length - 50);
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
_approvalNotice(key, line) {
|
|
1967
|
+
if (this._approvalNoticed.has(key)) return;
|
|
1968
|
+
this._approvalNoticed.add(key);
|
|
1969
|
+
try { process.stderr.write(approvals.printable(line) + '\n'); } catch (_e) { /* never sink the caller */ }
|
|
1970
|
+
}
|
|
1971
|
+
|
|
1972
|
+
/**
|
|
1973
|
+
* The refusal for a call to a tool whose changed descriptor waits for acceptance, or null.
|
|
1974
|
+
* `route`: 'lean' (text for a thrown error), 'curated' / 'raw' (an isError envelope). The wrap
|
|
1975
|
+
* ('raw') has no meta-tools, so it names only the UI and CLI routes.
|
|
1976
|
+
* @returns {{ text: string, envelope: object } | null}
|
|
1977
|
+
*/
|
|
1978
|
+
_approvalRefusal(upstreamId, realTool, route) {
|
|
1979
|
+
const waiting = this._pending.get(upstreamId);
|
|
1980
|
+
// Fast path: nothing waits for this tool AND the upstream is not withheld. A withheld upstream
|
|
1981
|
+
// has an EMPTY pending map, so without the flag a call resolved while its tool was pending (one
|
|
1982
|
+
// held at a permission prompt) would run once the watcher withheld it.
|
|
1983
|
+
if (!this._withheld.has(upstreamId) && (!waiting || !waiting.has(realTool))) return null;
|
|
1984
|
+
// The rare path, so re-read the approvals file first: an accept written by the UI, the CLI or a
|
|
1985
|
+
// management script (another process) counts from the very NEXT call, not from whenever the
|
|
1986
|
+
// file watcher fires.
|
|
1987
|
+
if (this._rawTools.has(upstreamId)) {
|
|
1988
|
+
const before = safeStringify(this._tools.get(upstreamId) || []);
|
|
1989
|
+
this._applyApprovals(upstreamId);
|
|
1990
|
+
if (safeStringify(this._tools.get(upstreamId) || []) !== before) this._signalToolsChanged(upstreamId);
|
|
1991
|
+
}
|
|
1992
|
+
const m = this._pending.get(upstreamId);
|
|
1993
|
+
const p = m && m.get(realTool);
|
|
1994
|
+
if (!p) {
|
|
1995
|
+
// Released by the re-read - unless it is no longer SERVED at all (the file turned unreadable,
|
|
1996
|
+
// which withholds the upstream, or the tool left the listing). Never fail open on that.
|
|
1997
|
+
if ((this._tools.get(upstreamId) || []).some((t) => t && t.name === realTool)) return null;
|
|
1998
|
+
const text = `ToolFunnel withheld this call: the tool ${JSON.stringify(realTool)} on upstream ${JSON.stringify(upstreamId)} ` +
|
|
1999
|
+
'is not approved right now (its approvals could not be read, or the server no longer lists it). See the gateway log.';
|
|
2000
|
+
return { text, envelope: { content: [{ type: 'text', text }], structuredContent: { toolfunnel: 'tool-withheld', upstream: upstreamId, tool: realTool }, isError: true } };
|
|
2001
|
+
}
|
|
2002
|
+
const arg = (s) => (/^[A-Za-z0-9_.:@/-]+$/.test(s) ? s : JSON.stringify(s));
|
|
2003
|
+
const client = this._clients.get(upstreamId);
|
|
2004
|
+
const version = client && client.initializeResult && client.initializeResult.serverInfo && client.initializeResult.serverInfo.version;
|
|
2005
|
+
const cli = `toolfunnel accept ${arg(upstreamId)} ${arg(realTool)} --hash ${p.hash}`;
|
|
2006
|
+
const acceptArgs = { upstream: upstreamId, tool: realTool, hash: p.hash };
|
|
2007
|
+
// The new text is never quoted here: an unapproved description is the attack, so it must not
|
|
2008
|
+
// reach the model's context through the refusal either. The user reviews it in the CLI or UI.
|
|
2009
|
+
const text = [
|
|
2010
|
+
`ToolFunnel withheld this call: the tool ${JSON.stringify(realTool)} on upstream ${JSON.stringify(upstreamId)} ` +
|
|
2011
|
+
`${p.reason === 'new' ? 'is new since its server was approved' : 'changed since it was approved'} (server version ${version || 'not reported'}).`,
|
|
2012
|
+
'That can be a legitimate update or a tampered server. Ask the user before accepting it.',
|
|
2013
|
+
`Changed: ${p.changed.join(', ')}. The user can review the new text with \`toolfunnel changes\` or in the ToolFunnel UI.`,
|
|
2014
|
+
// The agent route is a MANAGEMENT tool run through toolfunnel_run_tool, not a fifth meta-tool:
|
|
2015
|
+
// the public surface stays at four.
|
|
2016
|
+
route === 'raw'
|
|
2017
|
+
? `To accept it after the user agrees: the ToolFunnel UI, or run: ${cli}`
|
|
2018
|
+
: 'To accept it after the user agrees: run the tool tf_accept_change - toolfunnel_run_tool with ' +
|
|
2019
|
+
`${JSON.stringify({ name: 'tf_accept_change', args: acceptArgs })} - or use the ToolFunnel UI, or run: ${cli}`,
|
|
2020
|
+
].join('\n');
|
|
2021
|
+
const structuredContent = {
|
|
2022
|
+
toolfunnel: 'tool-change-withheld',
|
|
2023
|
+
upstream: upstreamId,
|
|
2024
|
+
tool: realTool,
|
|
2025
|
+
reason: p.reason,
|
|
2026
|
+
serverVersion: version || null,
|
|
2027
|
+
changed: p.changed,
|
|
2028
|
+
hash: p.hash,
|
|
2029
|
+
accept: route === 'raw' ? { cli } : { tool: 'tf_accept_change', via: 'toolfunnel_run_tool', arguments: acceptArgs, cli },
|
|
2030
|
+
};
|
|
2031
|
+
return { text, envelope: { content: [{ type: 'text', text }], structuredContent, isError: true } };
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
/** Accept one held change, only while it is still the one at `expectHash`, and re-apply it here.
|
|
2035
|
+
* The MRTR route - the UI, the CLI and tf_accept_change write the file from their own process.
|
|
2036
|
+
* @returns the acceptInFile result. Throws on an unreadable approvals file. */
|
|
2037
|
+
acceptChange(upstreamId, toolName, expectHash) {
|
|
2038
|
+
const res = approvals.acceptInFile(this._approvalsPath, upstreamId, toolName, expectHash);
|
|
2039
|
+
if (res.accepted.length) {
|
|
2040
|
+
logger.log({ type: 'mcp', event: 'tool_approval', kind: 'accepted', upstream: upstreamId, tools: res.accepted });
|
|
2041
|
+
try { process.stderr.write(approvals.printable(`[toolfunnel] accepted the changed descriptor(s) of upstream "${upstreamId}": ${res.accepted.join(', ')}.`) + '\n'); } catch (_e) { /* ignore */ }
|
|
2042
|
+
this.reloadApprovals();
|
|
2043
|
+
}
|
|
2044
|
+
return res;
|
|
2045
|
+
}
|
|
2046
|
+
|
|
2047
|
+
/** Re-apply approvals to every listed upstream (the approvals file changed on disk - a UI or CLI
|
|
2048
|
+
* accept). Signals a surface change for each upstream whose served tools changed. NEVER throws. */
|
|
2049
|
+
reloadApprovals() {
|
|
2050
|
+
for (const upstreamId of [...this._rawTools.keys()]) {
|
|
2051
|
+
try {
|
|
2052
|
+
const before = safeStringify(this._tools.get(upstreamId) || []);
|
|
2053
|
+
this._applyApprovals(upstreamId);
|
|
2054
|
+
if (safeStringify(this._tools.get(upstreamId) || []) !== before) this._signalToolsChanged(upstreamId);
|
|
2055
|
+
} catch (_e) { /* one upstream never blocks the rest */ }
|
|
2056
|
+
}
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
/** What waits for acceptance, and recent upgrades - the /health, CLI and UI source. Fresh copies. */
|
|
2060
|
+
toolChanges() {
|
|
2061
|
+
const pending = [];
|
|
2062
|
+
for (const [upstream, m] of this._pending) {
|
|
2063
|
+
for (const p of m.values()) pending.push({ upstream, tool: p.tool, reason: p.reason, changed: p.changed, hash: p.hash });
|
|
2064
|
+
}
|
|
2065
|
+
return { pending, upgraded: this._approvalEvents.map((e) => JSON.parse(JSON.stringify(e))) };
|
|
1548
2066
|
}
|
|
1549
2067
|
|
|
1550
2068
|
/** Tear down + forget a cached client (used on a failed connect). Never throws. */
|
|
@@ -1555,6 +2073,10 @@ class Aggregator {
|
|
|
1555
2073
|
}
|
|
1556
2074
|
this._clients.delete(upstreamId);
|
|
1557
2075
|
this._tools.delete(upstreamId);
|
|
2076
|
+
this._rawTools.delete(upstreamId);
|
|
2077
|
+
this._pending.delete(upstreamId);
|
|
2078
|
+
this._withheld.delete(upstreamId);
|
|
2079
|
+
this._invalid.delete(upstreamId);
|
|
1558
2080
|
}
|
|
1559
2081
|
|
|
1560
2082
|
/**
|
|
@@ -1650,8 +2172,8 @@ function withForwardedToolMeta(def, raw) {
|
|
|
1650
2172
|
const XMCP_TCHAR = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
|
|
1651
2173
|
|
|
1652
2174
|
/**
|
|
1653
|
-
* Strip INVALID `x-mcp-header` annotations from an advertised inputSchema, in place (
|
|
1654
|
-
*
|
|
2175
|
+
* Strip INVALID `x-mcp-header` annotations from an advertised inputSchema, in place (ingest
|
|
2176
|
+
* hygiene). The spec makes a single malformed annotation invalidate the WHOLE tool -
|
|
1655
2177
|
* conforming Streamable-HTTP clients MUST drop it from tools/list - so forwarding a broken
|
|
1656
2178
|
* upstream annotation verbatim makes the tool vanish client-side. Valid = on a pure
|
|
1657
2179
|
* `properties` chain from the root, a non-empty tchar token, on a string/integer/boolean
|
|
@@ -1701,6 +2223,13 @@ module.exports = {
|
|
|
1701
2223
|
Aggregator,
|
|
1702
2224
|
// exported for unit tests / reuse
|
|
1703
2225
|
defaultClientFactory,
|
|
2226
|
+
// The documented permanent-failure ceiling and its classifier.
|
|
2227
|
+
PERMANENT_RETRY_LIMIT,
|
|
2228
|
+
PERMANENT_ERROR_CODES,
|
|
2229
|
+
isPermanentConnectError,
|
|
2230
|
+
// Structural tool schema validation (drop one tool, never the upstream).
|
|
2231
|
+
toolSchemaProblem,
|
|
2232
|
+
MAX_SCHEMA_DEPTH,
|
|
1704
2233
|
looksLikePath,
|
|
1705
2234
|
isInside,
|
|
1706
2235
|
unwrapEnvelope,
|