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.
@@ -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 (0.7.0). Two classes with two rules. All platforms' vars are checked on all platforms because
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 was a one-character bypass there.
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 (0.7.0). Guarded because it decides where a bare, separator-free arg like
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. Until 0.7.0 the comment below claimed
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 (0.7.0). A value in any of these loads code
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
- * BEHAVIOUR CHANGE (concurrent connect): a startup failure is no longer a one-off failed[]
512
- * entry. Every failed upstream is handed to _scheduleReconnect, so a PERMANENT failure (bad
513
- * command, isolation refusal) now retries in the background for the whole session -
514
- * exponential 1s..16s, then the 30s keepalive indefinitely. That is the same treatment a
515
- * mid-session death already got; reconnect_slow is logged once when the slow phase begins,
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
- // These startup promises are NOT registered in _connecting. A concurrent ensureConnected(id)
556
- // during the batch would find the pre-handshake client _connectOne caches, treat it as stale
557
- // and discard it. Nothing can call in during startup today - http-transport awaits
558
- // connectAll() before listen() and before the reload swap - so the batch is unobservable.
559
- // If a caller is ever added that can race startup, set/delete the _connecting entry around
560
- // each task here.
561
- const promise = this._connectOne(upstream).catch((err) => {
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 - two concurrent discover() calls used to double-spawn the child and leak the
613
- // loser without closing it. ensureConnected also reaps its own failed connects.
614
- // allowDisabled: discover is the ONE caller permitted to connect a disabled upstream -
615
- // inspecting its tools pre-enable worked before the race fix and stays supported.
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
- return client.callTool(realTool, args, meta);
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 whitelist here ate the spec's
823
- // own serverInfo.title (2025-06-18) through the wrap - proven against the real
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 (F9)
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 (F7).
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 (F2/B-M4). onToolsChanged
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 30s keepalive so a long
1250
- * transient outage still self-heals (a detached/disabled upstream stops it via getUpstream; closeAll
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 envelope = await client.callTool(realTool, args);
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
- if (meta && typeof meta === 'object' && Object.keys(meta).length) params._meta = meta;
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
- return await client.request('tools/call', params, out);
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
- this._tools.set(upstreamId, safe);
1547
- return cloneTools(safe);
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 (0.7.0
1654
- * ingest hygiene). The spec makes a single malformed annotation invalidate the WHOLE tool -
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,