toolfunnel 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -7
- package/SECURITY.md +84 -0
- package/bin/toolfunnel.js +97 -18
- package/docs/KNOWN_BUGS.md +71 -90
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +24 -10
- package/hooks/hooks.manifest.json +1 -1
- package/hooks/scripts/example-deny-dangerous.js +12 -4
- package/mcp/servers/mock-upstream/server.js +36 -6
- package/package.json +67 -65
- package/src/auth/config.js +2 -2
- package/src/auth/resource-server.js +12 -3
- package/src/core/child-reaper.js +61 -0
- package/src/core/events.js +7 -7
- package/src/core/hook-engine.js +85 -31
- package/src/core/hook-loader.js +46 -13
- package/src/core/hook-runner.js +258 -38
- package/src/core/logger.js +138 -12
- package/src/core/matcher.js +59 -10
- package/src/core/rename-migration.js +163 -0
- package/src/core/requires.js +1 -1
- package/src/extend/add-hook.md +2 -2
- package/src/extend/add-mcp.md +3 -6
- package/src/extend/configure.md +2 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/howto.js +3 -3
- package/src/extend/package.md +11 -8
- package/src/mcp/aggregator.js +480 -52
- package/src/mcp/expose-store.js +130 -13
- package/src/mcp/gated-run.js +26 -17
- package/src/mcp/http-transport.js +274 -22
- package/src/mcp/mcp-client.js +194 -31
- package/src/mcp/modern.js +44 -10
- package/src/mcp/server.js +513 -89
- package/src/tools/registry.js +172 -24
- package/src/ui/public/app.js +17 -4
- package/src/ui/server.js +297 -29
- package/tools/scripts/tf-hook-set.js +11 -3
- package/tools/tools.register.json +227 -227
- package/src/core/tool-registry.js +0 -180
- package/src/packages/packager.js +0 -715
- package/src/tools/drift.js +0 -138
package/src/mcp/aggregator.js
CHANGED
|
@@ -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
|
|
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.
|
|
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
|
|
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
|
-
* `
|
|
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}
|
|
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,
|
|
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
|
-
//
|
|
123
|
-
// is
|
|
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
|
-
|
|
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
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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 (
|
|
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 :
|
|
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,
|
|
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.
|
|
188
|
-
* @param {Function} [opts.clientFactory] (upstreamEntry,
|
|
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,
|
|
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.
|
|
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 -
|
|
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,
|
|
230
|
-
// extra args; the default uses
|
|
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.
|
|
236
|
-
: (upstream, onClose) => defaultClientFactory(upstream, this.
|
|
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,
|
|
355
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
1117
|
-
|
|
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
|
|
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
|
|
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
|
};
|