toolfunnel 0.6.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/README.md +11 -7
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +97 -18
  4. package/docs/KNOWN_BUGS.md +71 -90
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +24 -10
  7. package/hooks/hooks.manifest.json +1 -1
  8. package/hooks/scripts/example-deny-dangerous.js +12 -4
  9. package/mcp/servers/mock-upstream/server.js +36 -6
  10. package/package.json +67 -65
  11. package/src/auth/config.js +2 -2
  12. package/src/auth/resource-server.js +12 -3
  13. package/src/core/child-reaper.js +61 -0
  14. package/src/core/events.js +7 -7
  15. package/src/core/hook-engine.js +85 -31
  16. package/src/core/hook-loader.js +46 -13
  17. package/src/core/hook-runner.js +258 -38
  18. package/src/core/logger.js +138 -12
  19. package/src/core/matcher.js +59 -10
  20. package/src/core/rename-migration.js +163 -0
  21. package/src/core/requires.js +1 -1
  22. package/src/extend/add-hook.md +2 -2
  23. package/src/extend/add-mcp.md +3 -6
  24. package/src/extend/configure.md +2 -1
  25. package/src/extend/create-tool.md +1 -1
  26. package/src/extend/howto.js +3 -3
  27. package/src/extend/package.md +11 -8
  28. package/src/mcp/aggregator.js +480 -52
  29. package/src/mcp/expose-store.js +130 -13
  30. package/src/mcp/gated-run.js +26 -17
  31. package/src/mcp/http-transport.js +274 -22
  32. package/src/mcp/mcp-client.js +194 -31
  33. package/src/mcp/modern.js +44 -10
  34. package/src/mcp/server.js +513 -89
  35. package/src/tools/registry.js +172 -24
  36. package/src/ui/public/app.js +17 -4
  37. package/src/ui/server.js +297 -29
  38. package/tools/scripts/tf-hook-set.js +11 -3
  39. package/tools/tools.register.json +227 -227
  40. package/src/core/tool-registry.js +0 -180
  41. package/src/packages/packager.js +0 -715
  42. package/src/tools/drift.js +0 -138
@@ -13,7 +13,7 @@
13
13
  * Manager UI's 'discover' button calls.
14
14
  * 3. Computes the CURATED-DIRECT tool definitions to advertise downstream
15
15
  * (exposedToolDefinitions) - only for ENABLED expose[] entries whose upstream
16
- * is connected AND actually advertises the named tool. Each definition is
16
+ * is connected, enabled, AND actually advertises the named tool. Each definition is
17
17
  * renamed to its downstream `as` name (what the CLI / PreToolUse matchers see).
18
18
  * 4. RESOLVES a downstream call back to its upstream client + real tool name
19
19
  * (resolveExposedExecution) - the seam the MCP server's curated-direct call
@@ -31,7 +31,7 @@
31
31
  * may only reference CODE/FILES inside the gateway root. The
32
32
  * guard is applied to the ARGS (the script path, db files, ... that the interpreter
33
33
  * is pointed at): any arg that is an absolute path, or that looks like a file path
34
- * (contains a path separator), MUST resolve inside v3Root - otherwise the factory
34
+ * (contains a path separator), MUST resolve inside gatewayRoot - otherwise the factory
35
35
  * throws and connectAll records the upstream in failed[]. The COMMAND slot is the
36
36
  * interpreter/executable (node, npx, or an absolute path to a node binary that by
37
37
  * definition lives OUTSIDE the project, e.g. process.execPath / a system node.exe)
@@ -52,7 +52,7 @@ const logger = require('../core/logger');
52
52
  // The sandbox root = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package
53
53
  // root - see src/core/config-home.js). Used as the isolation boundary the default clientFactory
54
54
  // enforces: a pack's path-shaped upstream args live under the home, so the home is the boundary.
55
- // A caller may override via opts.v3Root for tests.
55
+ // A caller may override via opts.gatewayRoot for tests.
56
56
  const { resolveConfigHome } = require('../core/config-home');
57
57
  const DEFAULT_ROOT = resolveConfigHome();
58
58
 
@@ -64,7 +64,7 @@ function isNonEmptyString(v) {
64
64
  /**
65
65
  * Does this argv token LOOK like a file path? An absolute path (path.isAbsolute) or
66
66
  * any token containing a path separator ('/' or '\\') is treated as a path reference
67
- * and must be confined to v3Root. A bare token like 'node', 'npx', 'server' or a flag
67
+ * and must be confined to gatewayRoot. A bare token like 'node', 'npx', 'server' or a flag
68
68
  * like '--port' is NOT a path and is always allowed.
69
69
  * @param {string} arg
70
70
  * @returns {boolean}
@@ -72,9 +72,91 @@ function isNonEmptyString(v) {
72
72
  function looksLikePath(arg) {
73
73
  if (!isNonEmptyString(arg)) return false;
74
74
  if (path.isAbsolute(arg)) return true;
75
+ // win32 DRIVE-RELATIVE form ('C:foo.js') - path.win32.isAbsolute() is false for it and it holds
76
+ // no separator, so without this it looked like a bare token and skipped the guard entirely.
77
+ if (/^[A-Za-z]:/.test(arg)) return true;
75
78
  return arg.indexOf('/') !== -1 || arg.indexOf('\\') !== -1;
76
79
  }
77
80
 
81
+ /**
82
+ * The path-shaped candidates inside a single argv token. A runtime option in `--flag=value` form
83
+ * (node's --require=, --import=, --experimental-loader=, and their Deno/Bun kin) interprets only
84
+ * the part AFTER the first '=' as a path. The whole token '--require=../x.js' carries no '..'
85
+ * segment of its own, so a check of the token alone judges it inside the root while the child
86
+ * resolves the value and leaves it. So the value after the first '=' is returned as its own
87
+ * candidate, checked alongside the whole token. A non-path value ('--port=8080') simply fails
88
+ * looksLikePath() and is ignored; an inside value still resolves inside and passes.
89
+ * @param {string} arg
90
+ * @returns {string[]}
91
+ */
92
+ function argPathCandidates(arg) {
93
+ if (!isNonEmptyString(arg)) return [];
94
+ const out = [arg];
95
+ const eq = arg.indexOf('=');
96
+ if (eq !== -1 && eq < arg.length - 1) out.push(arg.slice(eq + 1));
97
+ return out;
98
+ }
99
+
100
+ /**
101
+ * Is this a win32 DRIVE-RELATIVE reference ('C:foo', drive letter + colon + no separator)?
102
+ * It resolves against the CHILD's per-drive current directory - a base the guard cannot pin -
103
+ * so it can never be PROVEN inside the root: path.resolve(root, 'C:x') judges it optimistically
104
+ * when root shares the drive. There is no legitimate in-sandbox use (an inside path can always
105
+ * be written absolutely or plainly relative), so it is refused outright wherever paths are
106
+ * guarded. Checked on every platform: a config is portable and gets run somewhere else.
107
+ * @param {string} t
108
+ * @returns {boolean}
109
+ */
110
+ function isDriveRelative(t) {
111
+ return /^[A-Za-z]:(?![\\/])/.test(t);
112
+ }
113
+
114
+ // 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
115
+ // a config is portable and gets run somewhere else, and keys are compared CASE-INSENSITIVELY
116
+ // everywhere because Windows env lookup is case-insensitive - `node_options` reaches the child
117
+ // exactly as `NODE_OPTIONS` does, so an exact-case allowlist was a one-character bypass there.
118
+ //
119
+ // OPTION-STRING vars are a mini command line in the runtime's OWN grammar: quoting, `=` forms,
120
+ // `file:`/`data:` URLs, inspector flags. A path-shaped guard cannot verify them (quoted
121
+ // values, URL specifiers and a bare `--inspect` all defeat any tokeniser written here),
122
+ // and a guard that cannot verify must refuse, not guess. Refused outright in funnel mode; the
123
+ // operator's deliberate opt-out is `allowCodeLoadingEnv: true` on the upstream entry (the
124
+ // legacyPin pattern - off by default, visible in config, warned when it excuses something).
125
+ const CODE_OPT_ENV = [
126
+ 'NODE_OPTIONS',
127
+ 'JAVA_TOOL_OPTIONS',
128
+ '_JAVA_OPTIONS',
129
+ 'PERL5OPT',
130
+ 'RUBYOPT',
131
+ ];
132
+ // PATH / PATH-LIST vars name locations directly and ARE checkable: extract pieces, judge each
133
+ // against the root. This list is a FLOOR, not a boundary - runtimes keep minting code-loading
134
+ // vars, and the `command` slot is arbitrary by design (see SECURITY.md: the guard is a tripwire
135
+ // for out-of-root references in an auditable config home, not a sandbox against an untrusted
136
+ // pack author).
137
+ const CODE_PATH_ENV = [
138
+ 'NODE_PATH',
139
+ 'NODE_REPL_EXTERNAL_MODULE',
140
+ 'LD_PRELOAD',
141
+ 'LD_LIBRARY_PATH',
142
+ 'LD_AUDIT',
143
+ 'DYLD_INSERT_LIBRARIES',
144
+ 'DYLD_LIBRARY_PATH',
145
+ 'DYLD_FRAMEWORK_PATH',
146
+ 'PYTHONPATH',
147
+ 'PYTHONSTARTUP',
148
+ 'PYTHONHOME',
149
+ 'PYTHONEXECUTABLE',
150
+ 'PERL5LIB',
151
+ 'RUBYLIB',
152
+ 'GEM_PATH',
153
+ 'CLASSPATH',
154
+ 'BASH_ENV',
155
+ 'ENV',
156
+ 'DOTNET_STARTUP_HOOKS',
157
+ 'CORECLR_PROFILER_PATH',
158
+ ];
159
+
78
160
  /**
79
161
  * Is `target` inside `root` (or equal to it)? Compares fully-resolved, normalised
80
162
  * absolute paths. A relative `target` is resolved AGAINST root (so 'vendor/x/server.js'
@@ -101,7 +183,7 @@ function isInside(root, target) {
101
183
  * The DEFAULT clientFactory. Constructs a McpClient for an upstream entry AFTER
102
184
  * enforcing the isolation guard on the ARGS: every arg that looks like a file path
103
185
  * (an absolute path, or any token containing a path separator) must resolve inside
104
- * `v3Root`. Throws a clear 'isolation: ...' error otherwise - connectAll catches that
186
+ * `gatewayRoot`. Throws a clear 'isolation: ...' error otherwise - connectAll catches that
105
187
  * into failed[].
106
188
  *
107
189
  * The COMMAND slot is intentionally NOT guarded: it names the interpreter/executable
@@ -112,27 +194,166 @@ function isInside(root, target) {
112
194
  * would wrongly reject every vendored upstream the moment it is run by the system node.
113
195
  *
114
196
  * @param {object} upstream a normalised ExposeStore upstream { id, command, args, env, ... }
115
- * @param {string} v3Root the sandbox root the upstream's path-shaped ARGS must stay inside
197
+ * @param {string} gatewayRoot the sandbox root the upstream's path-shaped ARGS must stay inside
116
198
  * @returns {McpClient}
117
199
  */
118
- function defaultClientFactory(upstream, v3Root, onClose, allowOutsidePaths, clientInfo) {
200
+ function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths, clientInfo, eraHint) {
119
201
  const id = isNonEmptyString(upstream && upstream.id) ? upstream.id : '(unknown)';
120
202
  const args = Array.isArray(upstream && upstream.args) ? upstream.args : [];
121
-
122
- // Guard every path-shaped arg (server scripts, db files, ...). The command (interpreter)
123
- // is NOT guarded - see the function doc: it is the executable, not the code being run.
203
+ // The caller passes TRUE for the wrapped upstream; the upstream may also claim the exemption for
204
+ // itself in config. Either way it is decided HERE, per upstream, from THIS upstream's own entry -
205
+ // there is no shared or latched state, so one server's opt-out can never relax another's guard.
206
+ const outsideAllowed = allowOutsidePaths === true || (upstream && upstream.allowOutsidePaths === true);
207
+ const byConfig = allowOutsidePaths !== true && outsideAllowed;
208
+
209
+ // Guard every field that decides WHAT CODE the spawned process runs: path-shaped args, the cwd
210
+ // they resolve against, and the env vars that load code irrespective of args. The command
211
+ // (interpreter) is NOT guarded - see the function doc: it is the executable, not the code.
124
212
  // allowOutsidePaths: TRUE only for the currently-WRAPPED upstream (transparent-wrapper mode -
125
213
  // the operator explicitly made this server the entire surface, e.g. server-filesystem serving
126
214
  // a documents folder). Warn instead of refuse; funnel-mode upstreams keep the hard guard.
215
+ // ONE notice per CLASS, not per upstream: `warnedPath` covers the path class (args + cwd); the
216
+ // env section below keeps its OWN flag. A single shared flag let the first path finding consume
217
+ // the only slot, so an env finding - the class that loads code - was excused in total silence,
218
+ // precisely for the operator who opted out and most needs the audit trail.
219
+ let warnedPath = false;
220
+ const refuse = (what) => {
221
+ if (outsideAllowed === true) {
222
+ if (!warnedPath) {
223
+ warnedPath = true;
224
+ // Name the REAL reason. Reporting a config opt-out as transparent-wrapper mode would put a
225
+ // false explanation in the operator's log for the one event they most need to trace.
226
+ process.stderr.write(byConfig
227
+ ? `[toolfunnel] isolation: upstream "${id}" reaches outside the gateway root (${what}) - ` +
228
+ `permitted because its config sets allowOutsidePaths:true. This upstream is NOT isolated ` +
229
+ `to the config home; every tool it serves runs with that exemption. Remove the flag to ` +
230
+ `restore the guard, or gate the tools with a PreToolUse hook.\n`
231
+ : `[toolfunnel] isolation: WRAPPED upstream "${id}" reaches outside the gateway root ` +
232
+ `(${what}) - permitted in transparent-wrapper mode. To restrict what it can do post-wrap, see the ` +
233
+ `manual's "Wrapping & security" section (PreToolUse gate, per-tool enabled:false).\n`);
234
+ }
235
+ return false; // permitted under the wrap, or by this upstream's own opt-out
236
+ }
237
+ throw new Error(`isolation: upstream ${id} references a path outside the gateway root`);
238
+ };
239
+
127
240
  for (const arg of args) {
128
- if (looksLikePath(arg) && !isInside(v3Root, arg)) {
241
+ // Check the whole token AND the value after a '=' (the --flag=value form the child splits) -
242
+ // either escaping the root refuses the arg. The notice still names the whole token.
243
+ const escapes = argPathCandidates(arg).some(
244
+ (cand) => looksLikePath(cand) && (isDriveRelative(cand) || !isInside(gatewayRoot, cand))
245
+ );
246
+ if (escapes) {
247
+ if (refuse(`arg "${arg}"`) === false) break; // one notice covers the path class
248
+ }
249
+ }
250
+
251
+ // cwd - the SPAWN BASE (0.7.0). Guarded because it decides where a bare, separator-free arg like
252
+ // 'server.js' actually resolves: looksLikePath() is false for such a token so the loop above
253
+ // never sees it, while the child resolves it against cwd. Until 0.7.0 the comment below claimed
254
+ // guard-base and spawn-base "agree by construction" - true only while cwd was unset.
255
+ // Drive-relative gets the same outright refusal as everywhere else: 'C:denied' resolves
256
+ // against the child's per-drive current directory, and isInside() judging it against the root
257
+ // is optimistic-and-wrong whenever the root shares the drive.
258
+ if (isNonEmptyString(upstream && upstream.cwd)
259
+ && (isDriveRelative(upstream.cwd) || !isInside(gatewayRoot, upstream.cwd))) {
260
+ refuse(`cwd "${upstream.cwd}"`);
261
+ }
262
+
263
+ // env - CODE-LOADING variables (0.7.0). A value in any of these loads code
264
+ // into the child however clean the args are (reachable in-band: tf_mcp_add forwards env
265
+ // verbatim). Keys are matched CASE-INSENSITIVELY on every platform - Windows env lookup is
266
+ // case-insensitive, so `node_options` is `NODE_OPTIONS` to the child - and every platform's
267
+ // variable is checked on every platform: a pack authored on Linux gets run on Windows and back.
268
+ //
269
+ // `allowCodeLoadingEnv: true` on the upstream entry is the operator's explicit opt-out for the
270
+ // whole class: the finding downgrades to a once-per-upstream stderr warning naming the flag.
271
+ const envObj = upstream && typeof upstream.env === 'object' && upstream.env ? upstream.env : null;
272
+ if (envObj) {
273
+ const envAllowed = (upstream && upstream.allowCodeLoadingEnv === true);
274
+ // The env class's OWN notice flag - deliberately independent of `warnedPath` (see above).
275
+ let warnedEnv = false;
276
+ const envNotice = (why) => {
277
+ if (warnedEnv) return;
278
+ warnedEnv = true;
279
+ process.stderr.write(`[toolfunnel] isolation: upstream "${id}" sets code-loading env ${why}`);
280
+ };
281
+ // Which flag excuses an env finding depends on its KIND:
282
+ // 'opt' - option-string vars (NODE_OPTIONS et al.): not path-checkable, so ONLY the class's
283
+ // own flag (allowCodeLoadingEnv) or the WRAP excuses them. allowOutsidePaths does
284
+ // NOT: it is documented (expose-store) as args/cwd/path-list scope, and letting the
285
+ // path flag silently disarm this separately-audited class was a cross-flag grant
286
+ // the operator never made.
287
+ // 'path' - path-list vars (NODE_PATH et al.): inside the path flag's documented scope, so
288
+ // wrap, allowCodeLoadingEnv or allowOutsidePaths excuses - each loudly, by name.
289
+ const refuseEnv = (what, kind) => {
290
+ if (envAllowed) {
291
+ envNotice(`(${what}) - permitted by allowCodeLoadingEnv:true on this upstream. Remove the ` +
292
+ 'flag to restore the refusal.\n');
293
+ return false;
294
+ }
129
295
  if (allowOutsidePaths === true) {
130
- process.stderr.write(`[toolfunnel] isolation: WRAPPED upstream "${id}" uses a path outside the gateway root ` +
131
- `("${arg}") - permitted in transparent-wrapper mode. To restrict what it can do post-wrap, see the ` +
132
- `manual's "Wrapping & security" section (PreToolUse gate, per-tool enabled:false).\n`);
133
- break; // one warning covers the upstream; every arg is permitted under the wrap
296
+ envNotice(`(${what}) - permitted in transparent-wrapper mode (this upstream IS the ` +
297
+ 'surface). To restrict it post-wrap, see the manual\'s "Wrapping & security" section.\n');
298
+ return false;
299
+ }
300
+ if (kind === 'path' && outsideAllowed === true) {
301
+ envNotice(`(${what}) - permitted because its config sets allowOutsidePaths:true ` +
302
+ '(path-list env is within that flag\'s scope). Remove the flag to restore the guard.\n');
303
+ return false;
304
+ }
305
+ throw new Error(`isolation: upstream ${id} sets code-loading env (${what})`);
306
+ };
307
+ // Enumerate EXACTLY as child_process does: `for...in`, which walks own AND INHERITED keys
308
+ // (node's normalizeSpawnArguments: "Prototype values are intentionally included"). An
309
+ // Object.keys() walk was blind to a `__proto__` carrier - JSON.parse makes `__proto__` an OWN
310
+ // data property, spread preserves it, and the client's Object.assign merge then uses [[Set]],
311
+ // firing the Object.prototype.__proto__ setter and installing the payload as the merged env's
312
+ // PROTOTYPE: invisible to Object.keys, delivered to the child.
313
+ for (const key in envObj) {
314
+ const upper = String(key).toUpperCase();
315
+ // A `__proto__` key is refused OUTRIGHT, whatever it carries. It is not an env var: it is
316
+ // the carrier that turns into the merged env's PROTOTYPE at the client's Object.assign, and
317
+ // from there into real variables for the child (for..in). Nothing legitimate needs it.
318
+ if (key === '__proto__') {
319
+ refuseEnv('env "__proto__" (a prototype carrier, not an environment variable)', 'opt');
320
+ continue;
321
+ }
322
+ // Judge the value as the spawn will USE it, not as it was authored. node builds each pair as
323
+ // `${key}=${value}`, so a one-element array becomes its element verbatim and a number becomes
324
+ // its digits. Testing isNonEmptyString FIRST let every non-string value skip both lists -
325
+ // again the guard reading a different representation than the consumer. A value that cannot
326
+ // even be coerced is unverifiable in a code-loading slot, so it is refused rather than
327
+ // skipped (the same rule the option-string class already follows).
328
+ const rawValue = envObj[key];
329
+ if (rawValue === undefined || rawValue === null) continue;
330
+ let raw;
331
+ try {
332
+ raw = typeof rawValue === 'string' ? rawValue : String(rawValue);
333
+ } catch (_e) {
334
+ if (CODE_OPT_ENV.includes(upper) || CODE_PATH_ENV.includes(upper)) {
335
+ refuseEnv(`env ${key} (value cannot be read as a string, so it cannot be verified)`,
336
+ CODE_OPT_ENV.includes(upper) ? 'opt' : 'path');
337
+ }
338
+ continue;
339
+ }
340
+ if (raw.length === 0) continue;
341
+ if (CODE_OPT_ENV.includes(upper)) {
342
+ // Not path-checkable (see the class doc above the lists) - the VALUE is irrelevant.
343
+ if (refuseEnv(`env ${key} (option-string vars are not path-checkable)`, 'opt') === false) continue;
344
+ continue;
345
+ }
346
+ if (!CODE_PATH_ENV.includes(upper)) continue;
347
+ // Path / path-list value: pieces are EXTRACTED (not split) so a drive-lettered piece
348
+ // ('C:\x', 'C:x') survives whole on every platform - splitting 'C:relative\denied' on ':'
349
+ // yields fragments that resolve inside the root while the child resolves the real path
350
+ // outside it. Both ';' and ':' end a piece everywhere (a POSIX filename containing ';'
351
+ // may be shredded and falsely refused - conservative, never a false allow).
352
+ const parts = (raw.match(/[A-Za-z]:[^;:]*|[^;:]+/g) || []).filter((t) => t.length > 0);
353
+ if (parts.length === 0) continue;
354
+ if (parts.some((t) => isDriveRelative(t) || !isInside(gatewayRoot, t))) {
355
+ refuseEnv(`env ${key}="${raw}"`, 'path');
134
356
  }
135
- throw new Error(`isolation: upstream ${id} references a path outside the gateway root`);
136
357
  }
137
358
  }
138
359
 
@@ -142,12 +363,12 @@ function defaultClientFactory(upstream, v3Root, onClose, allowOutsidePaths, clie
142
363
  args: upstream.args,
143
364
  env: upstream.env,
144
365
  // Child cwd from config - the wrap era-probe reads it too; it never survived the store
145
- // before. Default = the CONFIG HOME (v3Root), NOT the gateway's process cwd:
366
+ // before. Default = the CONFIG HOME (gatewayRoot), NOT the gateway's process cwd:
146
367
  // the isolation guard above resolves relative args against the home, so the spawn must
147
368
  // resolve them against the SAME base or a guard-passing relative path fails to spawn
148
369
  // whenever --config-dir differs from the launch directory (guard-base and spawn-base agree
149
370
  // by construction).
150
- cwd: isNonEmptyString(upstream && upstream.cwd) ? upstream.cwd : v3Root,
371
+ cwd: isNonEmptyString(upstream && upstream.cwd) ? upstream.cwd : gatewayRoot,
151
372
  // Per-upstream `timeoutMs` config -> the client's PAYLOAD window (tools/call | prompts/get |
152
373
  // resources/read; default 120 s).
153
374
  toolTimeoutMs: Number.isFinite(upstream && upstream.timeoutMs) && upstream.timeoutMs > 0
@@ -165,6 +386,10 @@ function defaultClientFactory(upstream, v3Root, onClose, allowOutsidePaths, clie
165
386
  // the connect with a clear error instead of negotiating down. Store validation refuses the
166
387
  // legacyPin+modernOnly contradiction before it ever reaches here.
167
388
  modernOnly: !!(upstream && upstream.modernOnly === true),
389
+ // The aggregator's era memo (last successful connect): 'legacy' lets a reconnect skip the
390
+ // server/discover probe. The client ignores it under modernOnly; the aggregator clears it on
391
+ // a failed connect and on an explicit reconnect().
392
+ eraHint: eraHint === 'legacy' ? 'legacy' : undefined,
168
393
  // clientInfo: the identity presented to this upstream (identity mirroring / configured
169
394
  // identity). null/undefined -> McpClient's built-in default, wire behaviour unchanged.
170
395
  clientInfo: clientInfo || undefined,
@@ -175,7 +400,7 @@ function defaultClientFactory(upstream, v3Root, onClose, allowOutsidePaths, clie
175
400
  /**
176
401
  * Aggregator - owns the live upstream connections and the curated-expose computation.
177
402
  *
178
- * Lifecycle: `new Aggregator({ store, v3Root, clientFactory })`
403
+ * Lifecycle: `new Aggregator({ store, gatewayRoot, clientFactory })`
179
404
  * -> `await connectAll()` (or `await discover(id)` lazily)
180
405
  * -> `exposedToolDefinitions()` / `resolveExposedExecution(name, args)`
181
406
  * -> `await closeAll()` (idempotent).
@@ -184,8 +409,8 @@ class Aggregator {
184
409
  /**
185
410
  * @param {object} opts
186
411
  * @param {object} opts.store an ExposeStore (the on-disk MCP config).
187
- * @param {string} [opts.v3Root] sandbox root for the isolation guard (default: the gateway root).
188
- * @param {Function} [opts.clientFactory] (upstreamEntry, v3Root) -> McpClient. Default enforces isolation.
412
+ * @param {string} [opts.gatewayRoot] sandbox root for the isolation guard (default: the gateway root).
413
+ * @param {Function} [opts.clientFactory] (upstreamEntry, gatewayRoot) -> McpClient. Default enforces isolation.
189
414
  * @param {Function} [opts.wrapTargetProvider] () -> the currently-WRAPPED upstream id (or null).
190
415
  * Read fresh at each connect: the wrapped upstream is EXEMPT from the path-isolation
191
416
  * guard (by design - a wrap is an explicit "this server IS my whole
@@ -197,12 +422,12 @@ class Aggregator {
197
422
  * provider) so a mirror captured after boot is honoured by the next (re)connect. null ->
198
423
  * McpClient's built-in default.
199
424
  */
200
- constructor({ store, v3Root, clientFactory, onToolsChanged, wrapTargetProvider, clientInfoProvider } = {}) {
425
+ constructor({ store, gatewayRoot, clientFactory, onToolsChanged, wrapTargetProvider, clientInfoProvider } = {}) {
201
426
  if (!store || typeof store !== 'object') {
202
427
  throw new Error('Aggregator: a `store` (ExposeStore) is required');
203
428
  }
204
429
  this._store = store;
205
- this._v3Root = isNonEmptyString(v3Root) ? v3Root : DEFAULT_ROOT;
430
+ this._gatewayRoot = isNonEmptyString(gatewayRoot) ? gatewayRoot : DEFAULT_ROOT;
206
431
  // Called when the LIVE tool set changes OUT OF BAND (a background reconnect recovers or finally
207
432
  // loses an upstream) so the transport can emit notifications/tools/list_changed. Default no-op;
208
433
  // the server wires it to emitToolsListChanged(send). Settable so reloadExpose can re-wire the
@@ -214,7 +439,7 @@ class Aggregator {
214
439
  // the server/http host wires it. Settable so reloadExpose can re-wire the new instance.
215
440
  this.onUpstreamNotification = () => {};
216
441
  // Called with (upstreamId, requestMsg, client) for each SERVER-INITIATED REQUEST from an
217
- // upstream (elicitation/create, sampling/createMessage, roots/list - Bridge B). The handler
442
+ // upstream (elicitation/create, sampling/createMessage, roots/list - the elicitation bridge). The handler
218
443
  // owns answering via client.respondToServer(Error). Default: -32601, so an unwired build
219
444
  // never leaves an upstream holding an open request. Settable; reloadExpose carries it over.
220
445
  this.onUpstreamServerRequest = (uid, msg, client) => {
@@ -226,16 +451,17 @@ class Aggregator {
226
451
  this.wrapChatterUpstream = null;
227
452
  this._wrapTargetProvider = typeof wrapTargetProvider === 'function' ? wrapTargetProvider : null;
228
453
  this._clientInfoProvider = typeof clientInfoProvider === 'function' ? clientInfoProvider : null;
229
- // The factory receives (upstream, v3Root, onClose). A caller-supplied factory may ignore the
230
- // extra args; the default uses v3Root for isolation and onClose for death-driven reconnect.
454
+ // The factory receives (upstream, gatewayRoot, onClose). A caller-supplied factory may ignore the
455
+ // extra args; the default uses gatewayRoot for isolation and onClose for death-driven reconnect.
231
456
  // The wrap-target check runs PER CONNECT (fresh provider read), so a wrap set after boot is
232
457
  // honoured by the next connect attempt.
233
458
  this._clientFactory =
234
459
  typeof clientFactory === 'function'
235
- ? (upstream, onClose) => clientFactory(upstream, this._v3Root, onClose)
236
- : (upstream, onClose) => defaultClientFactory(upstream, this._v3Root, onClose,
460
+ ? (upstream, onClose) => clientFactory(upstream, this._gatewayRoot, onClose)
461
+ : (upstream, onClose) => defaultClientFactory(upstream, this._gatewayRoot, onClose,
237
462
  this._isWrapTarget(upstream && upstream.id),
238
- this._readClientInfo(upstream && upstream.id));
463
+ this._readClientInfo(upstream && upstream.id),
464
+ this._eraMemo.get(upstream && upstream.id));
239
465
 
240
466
  /** @type {Map<string, object>} upstreamId -> connected McpClient */
241
467
  this._clients = new Map();
@@ -246,8 +472,28 @@ class Aggregator {
246
472
  /** @type {Set<string>} agreed resource-subscription URIs - replayed onto every (re)connect
247
473
  * of a subscribe-capable legacy upstream so the channel survives reconnects */
248
474
  this._subscribedUris = new Set();
475
+ /** @type {Map<string, number>} uri -> LISTEN-owned refcount. subscribeResources
476
+ * increments; releaseResources decrements and unsubscribes upstream at zero. The wrap-forward
477
+ * recording (a client's own resources/subscribe) writes the Set directly and is NOT counted
478
+ * here - the client manages that lifetime itself. */
479
+ this._subscribedRefs = new Map();
249
480
  /** @type {Map<string, any>} upstreamId -> pending background-reconnect timer (one per upstream) */
250
481
  this._reconnectTimers = new Map();
482
+ /** @type {Set<string>} upstreams whose reconnect ATTEMPT is currently executing (between the timer
483
+ * firing - which deletes its own entry from _reconnectTimers - and the attempt settling). While an
484
+ * id is in here, _handleUpstreamDown must NOT seed a fresh attempt-0: the in-flight attempt's own
485
+ * catch owns the next backoff step. Without this, a death DURING an attempt's tools/list re-seeds
486
+ * the 1s floor and the one-timer guard then swallows the escalation - a permanent 1s respawn loop. */
487
+ this._reconnecting = new Set();
488
+ /** @type {Map<string, 'modern'|'legacy'>} upstreamId -> era negotiated at the LAST successful
489
+ * connect. A death-driven reconnect passes it to the client as eraHint so a legacy upstream
490
+ * skips the server/discover probe (worst case ~3s against a server that silently drops unknown
491
+ * methods) instead of re-paying it on every respawn. Lifecycle: SET on a successful connect,
492
+ * CLEARED when a connect fails (the hinted attempt may be why - the next retry re-negotiates
493
+ * in full, so an upstream upgraded across a restart self-heals) and by an explicit reconnect()
494
+ * (a manual kick deliberately re-negotiates). A config reload swaps in a fresh Aggregator, so
495
+ * a changed entry never inherits a stale memo. */
496
+ this._eraMemo = new Map();
251
497
  /** Set true by closeAll(). A torn-down aggregator (e.g. swapped out by reloadExpose) must NEVER
252
498
  * (re)connect - a captured execute thunk holding this instance would otherwise spawn an orphan
253
499
  * upstream child on it that nothing ever closes. ensureConnected/connectAll check this. */
@@ -346,13 +592,14 @@ class Aggregator {
346
592
 
347
593
  /**
348
594
  * The CURATED-DIRECT tool definitions to advertise downstream. For each ENABLED
349
- * expose[] entry whose upstream is CONNECTED and ACTUALLY advertises the named tool:
595
+ * expose[] entry whose upstream is CONNECTED, ENABLED and ACTUALLY advertises the named tool:
350
596
  * - name = store.exposedName(entry) (the downstream `as` name)
351
597
  * - description = '[' + upstream + '] ' + (upstream tool description || 'tool')
352
598
  * - inputSchema = the upstream tool's inputSchema || { type:'object' }
353
599
  *
354
- * Entries whose upstream isn't connected, or whose named tool the upstream doesn't
355
- * advertise, are silently skipped (you can't expose a tool that isn't there).
600
+ * Entries whose upstream isn't connected, is explicitly disabled (the row's own flag
601
+ * cannot override the upstream's - a discover-cached client doesn't re-admit it), or
602
+ * whose named tool the upstream doesn't advertise, are silently skipped.
356
603
  *
357
604
  * @returns {Array<{name:string, description:string, inputSchema:object}>}
358
605
  */
@@ -367,6 +614,8 @@ class Aggregator {
367
614
 
368
615
  for (const e of exposed) {
369
616
  if (!e || !this._clients.has(e.upstream)) continue; // upstream not connected
617
+ const upstream = this._store.getUpstream(e.upstream);
618
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
370
619
  const upstreamTools = this._tools.get(e.upstream) || [];
371
620
  const upstreamTool = upstreamTools.find((t) => t && t.name === e.tool);
372
621
  if (!upstreamTool) continue; // upstream doesn't actually advertise this tool
@@ -379,15 +628,29 @@ class Aggregator {
379
628
  ? upstreamTool.inputSchema
380
629
  : { type: 'object' };
381
630
 
382
- defs.push({
631
+ defs.push(withForwardedToolMeta({
383
632
  name,
384
633
  description: `[${e.upstream}] ${desc}`,
385
- inputSchema: cloneJson(inputSchema),
386
- });
634
+ inputSchema: sanitiseXMcpHeaders(cloneJson(inputSchema)),
635
+ }, upstreamTool));
387
636
  }
388
637
  return defs;
389
638
  }
390
639
 
640
+ /**
641
+ * The upstream ids in STORE order (expose.json's own sequence) - the stable advertisement
642
+ * order handleToolsList's hot-upstream block iterates by. Connection-map order
643
+ * shifts on death/recovery re-insertion; the store's order never does. NEVER throws.
644
+ * @returns {string[]}
645
+ */
646
+ upstreamOrder() {
647
+ try {
648
+ return this._store.listUpstreams().map((u) => u && u.id).filter(Boolean);
649
+ } catch (_e) {
650
+ return [];
651
+ }
652
+ }
653
+
391
654
  /**
392
655
  * Is `name` a downstream `as` name currently exposed (enabled) AND backed by a
393
656
  * connected upstream that advertises the underlying tool? Mirrors the set
@@ -425,7 +688,7 @@ class Aggregator {
425
688
  };
426
689
  }
427
690
 
428
- // ── lean register forwarding (slice 2) ──────────────────────────────────────
691
+ // ── lean register forwarding ──────────────────────────────────────
429
692
  // The LEAN path surfaces an attached upstream's tools through the 4 meta-tools (toolfunnel_list_tools
430
693
  // / _tool_instructions / _run_tool) instead of injecting them top-level every turn. The full
431
694
  // discovered set of every CONNECTED + enabled upstream is surfaced (attaching an MCP makes its
@@ -513,7 +776,7 @@ class Aggregator {
513
776
  // serverInfo passes VERBATIM minus the two fields some servers wrongly nest inside it
514
777
  // (capabilities/instructions - extracted separately below). A whitelist here ate the spec's
515
778
  // own serverInfo.title (2025-06-18) through the wrap - proven against the real
516
- // server-everything (wrap-lab, 2026-07-17). Blocklist, so future identity fields survive.
779
+ // server-everything (found wire-testing the wrap, 2026-07-17). Blocklist, so future identity fields survive.
517
780
  const serverInfo = {};
518
781
  for (const k of Object.keys(si)) {
519
782
  if (k === 'capabilities' || k === 'instructions') continue;
@@ -583,6 +846,7 @@ class Aggregator {
583
846
  const next = v == null ? null : v;
584
847
  if (next !== this.wrapChatterUpstream && this._subscribedUris instanceof Set) {
585
848
  this._subscribedUris.clear();
849
+ if (this._subscribedRefs instanceof Map) this._subscribedRefs.clear(); // 5.4: refs go with the context
586
850
  }
587
851
  this.wrapChatterUpstream = next;
588
852
  }
@@ -623,13 +887,56 @@ class Aggregator {
623
887
  try {
624
888
  const list = Array.isArray(uris) ? uris.filter((u) => typeof u === 'string' && u.length) : [];
625
889
  if (!list.length) return;
626
- for (const uri of list) this._subscribedUris.add(uri);
890
+ // 5.4: refcount per LISTEN-owned uri; the upstream subscribe is sent on the 0->1 crossing
891
+ // only (a re-listen agreeing the same uri must not spam idempotent subscribes).
892
+ const fresh = [];
893
+ for (const uri of list) {
894
+ const n = this._subscribedRefs.get(uri) || 0;
895
+ this._subscribedRefs.set(uri, n + 1);
896
+ if (!this._subscribedUris.has(uri)) { this._subscribedUris.add(uri); fresh.push(uri); }
897
+ }
898
+ if (!fresh.length) return;
627
899
  for (const [id, client] of this._clients) {
628
900
  if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
629
901
  if (!client || client.era !== 'legacy' || typeof client.request !== 'function') continue;
630
902
  const caps = client.initializeResult && client.initializeResult.capabilities;
631
903
  if (!(caps && caps.resources && caps.resources.subscribe)) continue;
632
- for (const uri of list) client.request('resources/subscribe', { uri }).catch(() => {});
904
+ for (const uri of fresh) client.request('resources/subscribe', { uri }).catch(() => {});
905
+ }
906
+ } catch (_e) { /* never throw */ }
907
+ }
908
+
909
+ /**
910
+ * Release LISTEN-owned resource subscriptions (the refcount leak's fix). Decrements each
911
+ * uri's refcount; on the 1->0 crossing the uri leaves the replay set and the upstream is told
912
+ * resources/unsubscribe (same scope rules as subscribeResources). ACCEPTED EDGE, documented:
913
+ * a uri subscribed BOTH by a listen agreement and a client's own wrap-forwarded
914
+ * resources/subscribe shares one upstream channel - when the last listen releases, the
915
+ * upstream unsubscribe fires and the recorded Set entry goes with it (the exotic overlap is
916
+ * not disambiguated). NEVER throws.
917
+ * @param {string[]} uris
918
+ */
919
+ releaseResources(uris) {
920
+ try {
921
+ const list = Array.isArray(uris) ? uris.filter((u) => typeof u === 'string' && u.length) : [];
922
+ if (!list.length) return;
923
+ const gone = [];
924
+ for (const uri of list) {
925
+ const n = this._subscribedRefs.get(uri) || 0;
926
+ if (n <= 1) {
927
+ if (n === 1) this._subscribedRefs.delete(uri);
928
+ if (this._subscribedUris.has(uri)) { this._subscribedUris.delete(uri); gone.push(uri); }
929
+ } else {
930
+ this._subscribedRefs.set(uri, n - 1);
931
+ }
932
+ }
933
+ if (!gone.length) return;
934
+ for (const [id, client] of this._clients) {
935
+ if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
936
+ if (!client || client.era !== 'legacy' || typeof client.request !== 'function') continue;
937
+ const caps = client.initializeResult && client.initializeResult.capabilities;
938
+ if (!(caps && caps.resources && caps.resources.subscribe)) continue;
939
+ for (const uri of gone) client.request('resources/unsubscribe', { uri }).catch(() => {});
633
940
  }
634
941
  } catch (_e) { /* never throw */ }
635
942
  }
@@ -672,8 +979,13 @@ class Aggregator {
672
979
  try {
673
980
  if (this._closed) return; // a swapped-out/torn-down aggregator must never fan out (F9)
674
981
  if (!notification || typeof notification.method !== 'string') return;
982
+ // tasks/status rides the wrap-chatter lane too: the wrap advertises the
983
+ // upstream's `tasks` capability verbatim, so a task started through the wrap owes its
984
+ // status notifications exactly as a direct connection would deliver them (2025-11-25
985
+ // schema TaskStatusNotification). Wrapped upstream only - same scope as message/progress.
675
986
  const isWrapChatter = this.wrapChatterUpstream === upstreamId &&
676
- (notification.method === 'notifications/message' || notification.method === 'notifications/progress');
987
+ (notification.method === 'notifications/message' || notification.method === 'notifications/progress' ||
988
+ notification.method === 'notifications/tasks/status');
677
989
  if (!Aggregator.BRIDGED_NOTIFICATIONS.has(notification.method) && !isWrapChatter) return;
678
990
  // Forward a CLEAN notification only - a misbehaving upstream's stray `id: null` field would
679
991
  // read as a response to downstream parsers (F7).
@@ -815,6 +1127,9 @@ class Aggregator {
815
1127
  try { await this._connecting.get(upstreamId); } catch (_e) { /* settled is all we need */ }
816
1128
  }
817
1129
  this._discard(upstreamId);
1130
+ // A MANUAL kick deliberately re-runs the full era negotiation - the operator may have just
1131
+ // upgraded the server, and "reconnect" should observe reality, not replay a memo of it.
1132
+ this._eraMemo.delete(upstreamId);
818
1133
  try {
819
1134
  return await this.ensureConnected(upstreamId);
820
1135
  } catch (err) {
@@ -844,6 +1159,11 @@ class Aggregator {
844
1159
  logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'died' });
845
1160
  this._discard(upstreamId); // remove the dead client + its tools -> drops from the lean list
846
1161
  this._signalToolsChanged(upstreamId); // list_changed only if the upstream is on the top-level surface
1162
+ // If a reconnect ATTEMPT is already executing, it owns the next backoff step (its catch escalates
1163
+ // attempt+1). Seeding attempt-0 here would reset the delay to 1s on every mid-attempt death, and
1164
+ // the one-timer guard would then swallow the attempt's escalation - a permanent 1s respawn loop.
1165
+ // Only start a fresh cycle when no attempt is in flight (the ordinary first death of a live upstream).
1166
+ if (this._reconnecting.has(upstreamId)) return;
847
1167
  this._scheduleReconnect(upstreamId, 0);
848
1168
  }
849
1169
 
@@ -900,6 +1220,9 @@ class Aggregator {
900
1220
  if (this._closed) return;
901
1221
  const u = this._store.getUpstream(upstreamId);
902
1222
  if (!u || u.enabled !== true) return;
1223
+ // Mark the attempt in flight so a death DURING ensureConnected's connect/list (onClose ->
1224
+ // _handleUpstreamDown) does not re-seed attempt-0: THIS attempt's catch owns the escalation.
1225
+ this._reconnecting.add(upstreamId);
903
1226
  try {
904
1227
  await this.ensureConnected(upstreamId); // allowConnect default - connects + re-lists, off-chain
905
1228
  logger.log({ type: 'mcp', event: 'reconnect', upstream: upstreamId, attempt });
@@ -913,8 +1236,12 @@ class Aggregator {
913
1236
  logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: MAX_FAST_ATTEMPTS });
914
1237
  }
915
1238
  // Keep escalating to the cap, then keep retrying slowly (cap the counter so the delay stays
916
- // at 30s) - never permanently give up while the upstream stays enabled.
1239
+ // at 30s) - never permanently give up while the upstream stays enabled. The escalation is
1240
+ // scheduled while still in flight (the guard below checks _reconnectTimers, which is empty
1241
+ // because a mid-attempt death was skipped) - the finally then releases the marker.
917
1242
  this._scheduleReconnect(upstreamId, Math.min(attempt + 1, MAX_FAST_ATTEMPTS));
1243
+ } finally {
1244
+ this._reconnecting.delete(upstreamId);
918
1245
  }
919
1246
  }, delay);
920
1247
  if (timer && typeof timer.unref === 'function') timer.unref();
@@ -951,13 +1278,13 @@ class Aggregator {
951
1278
  }
952
1279
  seen.add(name);
953
1280
  const schema = t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : { type: 'object' };
954
- defs.push({
1281
+ defs.push(withForwardedToolMeta({
955
1282
  name,
956
1283
  description: isNonEmptyString(t.description) ? t.description : '',
957
- inputSchema: cloneJson(schema),
1284
+ inputSchema: sanitiseXMcpHeaders(cloneJson(schema)),
958
1285
  upstream: upstreamId,
959
1286
  tool: t.name,
960
- });
1287
+ }, t));
961
1288
  }
962
1289
  }
963
1290
  } catch (_e) {
@@ -972,8 +1299,9 @@ class Aggregator {
972
1299
  * name (underscore-ambiguous). The execute thunk lazy-(re)connects via ensureConnected and UNWRAPS
973
1300
  * the upstream MCP envelope to a clean payload, so run_tool returns the upstream's content (e.g.
974
1301
  * "pong"), not a stringified envelope. The connect is INSIDE the thunk -> a PreToolUse deny spawns
975
- * nothing. The gate matches `toolName` (the surfaced name). Returns null if no connected upstream
976
- * advertises a tool with this surfaced name (-> protocol's clean "not runnable" error).
1302
+ * nothing. The gate matches `toolName` (the surfaced name). Returns null if no connected + enabled
1303
+ * upstream advertises a tool with this surfaced name (-> protocol's clean "not runnable" error) -
1304
+ * the same connected/enabled predicate leanToolDefinitions lists by, so hidden means not runnable.
977
1305
  * @param {string} name surfaced downstream name
978
1306
  * @param {any} args
979
1307
  * @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
@@ -982,6 +1310,8 @@ class Aggregator {
982
1310
  if (!isNonEmptyString(name)) return null;
983
1311
  for (const [upstreamId, tools] of this._tools) {
984
1312
  if (!this._clients.has(upstreamId)) continue;
1313
+ const upstream = this._store.getUpstream(upstreamId);
1314
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
985
1315
  if (!Array.isArray(tools)) continue;
986
1316
  for (const t of tools) {
987
1317
  if (!t || !isNonEmptyString(t.name)) continue;
@@ -1029,6 +1359,8 @@ class Aggregator {
1029
1359
  if (!isNonEmptyString(name)) return null;
1030
1360
  for (const [upstreamId, tools] of this._tools) {
1031
1361
  if (!this._clients.has(upstreamId)) continue;
1362
+ const upstream = this._store.getUpstream(upstreamId);
1363
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
1032
1364
  if (!Array.isArray(tools)) continue;
1033
1365
  for (const t of tools) {
1034
1366
  if (!t || !isNonEmptyString(t.name)) continue;
@@ -1065,6 +1397,7 @@ class Aggregator {
1065
1397
  try { clearTimeout(timer); } catch (_e) { /* ignore */ }
1066
1398
  }
1067
1399
  this._reconnectTimers.clear();
1400
+ this._reconnecting.clear(); // no in-flight attempt survives teardown (its finally is a no-op then)
1068
1401
  for (const [id, client] of this._clients) {
1069
1402
  try {
1070
1403
  if (client && typeof client.close === 'function') client.close();
@@ -1100,7 +1433,7 @@ class Aggregator {
1100
1433
  // out to modern subscriptions/listen streams). Filtered to the known change methods so arbitrary
1101
1434
  // upstream chatter never reaches a client. NEVER throws.
1102
1435
  client.onNotification = (n) => this._forwardUpstreamNotification(upstream.id, n);
1103
- // Bridge B: surface server-initiated requests with the upstream id + the live client (the
1436
+ // Elicitation bridge: surface server-initiated requests with the upstream id + the live client (the
1104
1437
  // handler answers on it). Guarded so a handler bug can never break the client's read loop.
1105
1438
  client.onServerRequest = (m) => {
1106
1439
  try {
@@ -1113,8 +1446,26 @@ class Aggregator {
1113
1446
  // Cache the client BEFORE connect so a connect-failure path can still _discard() and
1114
1447
  // close any half-spawned child.
1115
1448
  this._clients.set(upstream.id, client);
1116
- await client.connect();
1117
- await this._listAndCache(upstream.id);
1449
+ try {
1450
+ await client.connect();
1451
+ // The LIST is part of the connect contract: a handshake that cannot list its tools is a
1452
+ // FAILED connect to every caller, and the era observation must not outlive it either.
1453
+ await this._listAndCache(upstream.id);
1454
+ } catch (err) {
1455
+ // The failed attempt may have been era-HINTED (probe skipped on the memo). Whatever the
1456
+ // cause - handshake or the post-handshake list - a failure invalidates the observation:
1457
+ // clear it so the NEXT attempt re-runs the full negotiation. An upstream upgraded to
1458
+ // modern-only across a restart recovers on the following backoff retry instead of failing
1459
+ // hinted forever.
1460
+ this._eraMemo.delete(upstream.id);
1461
+ throw err;
1462
+ }
1463
+ // Remember the negotiated era so a death-driven reconnect can skip the probe (eraHint) - but
1464
+ // only a DEFINITIVE observation (the probe was ANSWERED). A probe that timed out proves
1465
+ // nothing about the upstream's eras, and memoising that fallback would pin a merely-SLOW
1466
+ // dual-era upstream to legacy on every future reconnect. A hinted connect (probe skipped)
1467
+ // leaves the existing memo in place.
1468
+ if (client.eraDefinitive !== false) this._eraMemo.set(upstream.id, client.era);
1118
1469
  // Re-arm any agreed resource subscriptions on the fresh process - every connect
1119
1470
  // path funnels through here, so reconnects and reloads replay uniformly.
1120
1471
  this._replaySubscriptions(upstream.id);
@@ -1163,8 +1514,8 @@ class Aggregator {
1163
1514
 
1164
1515
  /**
1165
1516
  * Shared lookup behind isExposed / resolveExposedExecution: find the ENABLED expose
1166
- * entry whose downstream name === `name`, whose upstream is connected, and whose real
1167
- * tool the upstream advertises. Returns the resolution context or null.
1517
+ * entry whose downstream name === `name`, whose upstream is connected AND enabled, and
1518
+ * whose real tool the upstream advertises. Returns the resolution context or null.
1168
1519
  * @param {string} name
1169
1520
  * @returns {{ client:object, realTool:string, upstream:string, exposedName:string } | null}
1170
1521
  */
@@ -1181,6 +1532,8 @@ class Aggregator {
1181
1532
  if (this._store.exposedName(e) !== name) continue;
1182
1533
  const client = this._clients.get(e.upstream);
1183
1534
  if (!client) continue; // upstream not connected
1535
+ const upstream = this._store.getUpstream(e.upstream);
1536
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
1184
1537
  const upstreamTools = this._tools.get(e.upstream) || [];
1185
1538
  if (!upstreamTools.some((t) => t && t.name === e.tool)) continue; // tool not advertised
1186
1539
  return { client, realTool: e.tool, upstream: e.upstream, exposedName: name };
@@ -1208,7 +1561,7 @@ function safeStringify(value) {
1208
1561
  * value as-is when it isn't an envelope. This is what avoids double-wrapping the envelope as JSON
1209
1562
  * inside wrapProtocolResult (the curated-direct path passes the envelope through transparently
1210
1563
  * instead; the lean path goes through protocol.runTool -> wrapProtocolResult, so it unwraps here).
1211
- * NOTE (slice 2): an upstream isError:true is surfaced as its text content with ok:true (homogeneous
1564
+ * NOTE: an upstream isError:true is surfaced as its text content with ok:true (homogeneous
1212
1565
  * with how local tools surface error text) - mapping isError -> ok:false is a later concern.
1213
1566
  * @param {*} envelope
1214
1567
  * @returns {*}
@@ -1230,6 +1583,70 @@ function cloneJson(value) {
1230
1583
  return JSON.parse(JSON.stringify(value));
1231
1584
  }
1232
1585
 
1586
+ /** The upstream tool-def metadata the funnel FORWARDS onto advertised defs.
1587
+ * ICONS ARE WITHHELD by policy: 2025-11-25 and 2026-07-28 both require icon consumers to
1588
+ * verify icon URIs are SAME-ORIGIN with the server, so gateway-forwarded icons are third-party
1589
+ * URLs a conforming client must reject - or worse, trust as ours. Reversible (adding a field
1590
+ * later is non-breaking); the decision is open - forwarding them later flips it here. */
1591
+ const FORWARDED_TOOL_META = Object.freeze(['title', 'annotations', 'outputSchema', '_meta', 'execution']);
1592
+
1593
+ /** Selectively copy the forwardable metadata from a RAW discovered def onto an advertised def.
1594
+ * NEVER a spread: lean defs carry internal routing ids (upstream/tool), and a spread would
1595
+ * also inherit unknown future fields sight-unseen (the explicit selective-add rule). Mutates + returns def. */
1596
+ function withForwardedToolMeta(def, raw) {
1597
+ if (!raw || typeof raw !== 'object') return def;
1598
+ for (const k of FORWARDED_TOOL_META) {
1599
+ if (raw[k] !== undefined && def[k] === undefined) def[k] = cloneJson(raw[k]);
1600
+ }
1601
+ return def;
1602
+ }
1603
+
1604
+ /** HTTP field-name token syntax (RFC 9110 §5.1) - the constraint on x-mcp-header values. */
1605
+ const XMCP_TCHAR = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
1606
+
1607
+ /**
1608
+ * Strip INVALID `x-mcp-header` annotations from an advertised inputSchema, in place (0.7.0
1609
+ * ingest hygiene). The spec makes a single malformed annotation invalidate the WHOLE tool -
1610
+ * conforming Streamable-HTTP clients MUST drop it from tools/list - so forwarding a broken
1611
+ * upstream annotation verbatim makes the tool vanish client-side. Valid = on a pure
1612
+ * `properties` chain from the root, a non-empty tchar token, on a string/integer/boolean
1613
+ * param, case-insensitively unique among the valid set. Everything else is deleted (the TOOL
1614
+ * survives; the annotation was the contaminant). The WRAP path stays raw-verbatim - a wrapped
1615
+ * server's brokenness is presented faithfully, exactly as a direct connection would.
1616
+ * @param {object} schema the advertised (already-cloned) inputSchema - mutated
1617
+ * @returns {object} schema
1618
+ */
1619
+ function sanitiseXMcpHeaders(schema) {
1620
+ if (!schema || typeof schema !== 'object') return schema;
1621
+ const valid = []; // { node, name } - candidates that pass every per-node rule
1622
+ const strip = []; // nodes whose annotation is deleted
1623
+ (function walk(node, onChain) {
1624
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
1625
+ if (Object.prototype.hasOwnProperty.call(node, 'x-mcp-header')) {
1626
+ const v = node['x-mcp-header'];
1627
+ const okType = node.type === 'string' || node.type === 'integer' || node.type === 'boolean';
1628
+ if (onChain && typeof v === 'string' && XMCP_TCHAR.test(v) && okType) valid.push({ node, name: v.toLowerCase() });
1629
+ else strip.push(node);
1630
+ }
1631
+ for (const k of Object.keys(node)) {
1632
+ const child = node[k];
1633
+ if (!child || typeof child !== 'object') continue;
1634
+ if (k === 'properties' && !Array.isArray(child)) {
1635
+ for (const sub of Object.values(child)) walk(sub, onChain); // the chain continues
1636
+ } else if (Array.isArray(child)) {
1637
+ for (const el of child) walk(el, false); // items/oneOf/... break the chain
1638
+ } else {
1639
+ walk(child, false);
1640
+ }
1641
+ }
1642
+ })(schema, true);
1643
+ const seen = new Map();
1644
+ for (const e of valid) seen.set(e.name, (seen.get(e.name) || 0) + 1);
1645
+ for (const e of valid) if (seen.get(e.name) > 1) strip.push(e.node); // ambiguous duplicates: all go
1646
+ for (const n of strip) delete n['x-mcp-header'];
1647
+ return schema;
1648
+ }
1649
+
1233
1650
  /** Clone an array of tool defs so callers can't mutate the aggregator's cache. */
1234
1651
  function cloneTools(tools) {
1235
1652
  return Array.isArray(tools) ? tools.map((t) => cloneJson(t)) : [];
@@ -1243,4 +1660,15 @@ module.exports = {
1243
1660
  isInside,
1244
1661
  unwrapEnvelope,
1245
1662
  DEFAULT_ROOT,
1663
+ // 3.7: the forwardable def metadata + the selective-add - server.js's hot-promotion
1664
+ // projection uses the same list so the two advertised paths can never drift apart again.
1665
+ FORWARDED_TOOL_META,
1666
+ withForwardedToolMeta,
1667
+ // 4.3: ingest hygiene for x-mcp-header annotations (exported for tests).
1668
+ sanitiseXMcpHeaders,
1669
+ // A3: the wrap-time SECURITY NOTICE (bin/toolfunnel.js) enumerates the same classes the guard
1670
+ // judges - shared lists, so the two surfaces can never classify differently.
1671
+ isDriveRelative,
1672
+ CODE_OPT_ENV,
1673
+ CODE_PATH_ENV,
1246
1674
  };