toolfunnel 0.6.0 → 0.7.1

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 (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +380 -350
  14. package/package.json +3 -1
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,1246 +1,1719 @@
1
- 'use strict';
2
-
3
- /**
4
- * aggregator.js - the upstream-MCP connection + curated-expose layer.
5
- *
6
- * The aggregator is the bridge between the persisted MCP config (src/mcp/expose-store.js)
7
- * and the live upstream MCP servers (src/mcp/mcp-client.js). It:
8
- *
9
- * 1. CONNECTS one McpClient per ENABLED upstream in the ExposeStore (connectAll),
10
- * caching the connected client + its discovered tools. A failed upstream never
11
- * sinks the others - failures are collected, not thrown.
12
- * 2. DISCOVERS an upstream's tool surface on demand (discover) - what the MCP
13
- * Manager UI's 'discover' button calls.
14
- * 3. Computes the CURATED-DIRECT tool definitions to advertise downstream
15
- * (exposedToolDefinitions) - only for ENABLED expose[] entries whose upstream
16
- * is connected AND actually advertises the named tool. Each definition is
17
- * renamed to its downstream `as` name (what the CLI / PreToolUse matchers see).
18
- * 4. RESOLVES a downstream call back to its upstream client + real tool name
19
- * (resolveExposedExecution) - the seam the MCP server's curated-direct call
20
- * path uses to actually execute, AFTER the hook gate has run.
21
- *
22
- * Decoupling note: downstream the CLI sees the renamed `as` tool; upstream we call
23
- * the REAL tool name. resolveExposedExecution carries BOTH so the gate (PreToolUse)
24
- * matches on the downstream name while execution hits the real upstream.
25
- *
26
- * SAFETY CONTRACT (mirrors expose-store.js / mcp-client.js):
27
- * - connectAll / discover / closeAll NEVER throw. connectAll collects per-upstream
28
- * failures into failed[]; a dead/misconfigured upstream cannot crash the server
29
- * start. closeAll is idempotent and tears down every cached client.
30
- * - The DEFAULT clientFactory enforces the HARD ISOLATION rule: a vendored upstream
31
- * may only reference CODE/FILES inside the gateway root. The
32
- * guard is applied to the ARGS (the script path, db files, ... that the interpreter
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
35
- * throws and connectAll records the upstream in failed[]. The COMMAND slot is the
36
- * interpreter/executable (node, npx, or an absolute path to a node binary that by
37
- * definition lives OUTSIDE the project, e.g. process.execPath / a system node.exe)
38
- * and is therefore NOT subject to the in-sandbox requirement - bare commands AND an
39
- * absolute interpreter path are both allowed. Isolation is about WHAT CODE runs in
40
- * the spawned process, not which interpreter binary runs it; the args are that code.
41
- * This is what stops a config from pointing at a live `.mcp.json` server's script
42
- * outside the sandbox while still letting the system node interpreter spawn it.
43
- *
44
- * CommonJS only. Node built-ins only. No new npm dependency. No @modelcontextprotocol/sdk.
45
- */
46
-
47
- const path = require('node:path');
48
-
49
- const { McpClient, PROTOCOL_VERSION: LEGACY_UPSTREAM_VERSION } = require('./mcp-client');
50
- const logger = require('../core/logger');
51
-
52
- // The sandbox root = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package
53
- // root - see src/core/config-home.js). Used as the isolation boundary the default clientFactory
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.
56
- const { resolveConfigHome } = require('../core/config-home');
57
- const DEFAULT_ROOT = resolveConfigHome();
58
-
59
- /** True for a non-empty string. */
60
- function isNonEmptyString(v) {
61
- return typeof v === 'string' && v.length > 0;
62
- }
63
-
64
- /**
65
- * Does this argv token LOOK like a file path? An absolute path (path.isAbsolute) or
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
68
- * like '--port' is NOT a path and is always allowed.
69
- * @param {string} arg
70
- * @returns {boolean}
71
- */
72
- function looksLikePath(arg) {
73
- if (!isNonEmptyString(arg)) return false;
74
- if (path.isAbsolute(arg)) return true;
75
- return arg.indexOf('/') !== -1 || arg.indexOf('\\') !== -1;
76
- }
77
-
78
- /**
79
- * Is `target` inside `root` (or equal to it)? Compares fully-resolved, normalised
80
- * absolute paths. A relative `target` is resolved AGAINST root (so 'vendor/x/server.js'
81
- * checks the in-sandbox location). Returns false for anything that escapes root via
82
- * '..' or an absolute path elsewhere on disk.
83
- * @param {string} root absolute sandbox root
84
- * @param {string} target path token (absolute or relative)
85
- * @returns {boolean}
86
- */
87
- function isInside(root, target) {
88
- const resolvedRoot = path.resolve(root);
89
- // An absolute target is checked as-is; a relative one is resolved within the root so
90
- // a vendored relative path ('vendor/foo/server.js') is judged at its in-sandbox spot.
91
- const resolvedTarget = path.isAbsolute(target)
92
- ? path.resolve(target)
93
- : path.resolve(resolvedRoot, target);
94
- const rel = path.relative(resolvedRoot, resolvedTarget);
95
- // Inside iff the relative path neither starts with '..' nor is itself absolute
96
- // (an absolute `rel` happens on win32 across drive letters). '' (equal) counts as inside.
97
- return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
98
- }
99
-
100
- /**
101
- * The DEFAULT clientFactory. Constructs a McpClient for an upstream entry AFTER
102
- * enforcing the isolation guard on the ARGS: every arg that looks like a file path
103
- * (an absolute path, or any token containing a path separator) must resolve inside
104
- * `v3Root`. Throws a clear 'isolation: ...' error otherwise - connectAll catches that
105
- * into failed[].
106
- *
107
- * The COMMAND slot is intentionally NOT guarded: it names the interpreter/executable
108
- * (e.g. 'node', 'npx', or an absolute path to a node binary such as process.execPath /
109
- * a system node.exe) which by definition lives OUTSIDE the project. Isolation is about
110
- * WHAT CODE the spawned process runs - that is the args (the server script + any data
111
- * files it loads) - not which interpreter binary launches it. Guarding the command too
112
- * would wrongly reject every vendored upstream the moment it is run by the system node.
113
- *
114
- * @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
116
- * @returns {McpClient}
117
- */
118
- function defaultClientFactory(upstream, v3Root, onClose, allowOutsidePaths, clientInfo) {
119
- const id = isNonEmptyString(upstream && upstream.id) ? upstream.id : '(unknown)';
120
- 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.
124
- // allowOutsidePaths: TRUE only for the currently-WRAPPED upstream (transparent-wrapper mode -
125
- // the operator explicitly made this server the entire surface, e.g. server-filesystem serving
126
- // a documents folder). Warn instead of refuse; funnel-mode upstreams keep the hard guard.
127
- for (const arg of args) {
128
- if (looksLikePath(arg) && !isInside(v3Root, arg)) {
129
- 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
134
- }
135
- throw new Error(`isolation: upstream ${id} references a path outside the gateway root`);
136
- }
137
- }
138
-
139
- return new McpClient({
140
- id: upstream.id,
141
- command: upstream.command,
142
- args: upstream.args,
143
- env: upstream.env,
144
- // 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:
146
- // the isolation guard above resolves relative args against the home, so the spawn must
147
- // resolve them against the SAME base or a guard-passing relative path fails to spawn
148
- // whenever --config-dir differs from the launch directory (guard-base and spawn-base agree
149
- // by construction).
150
- cwd: isNonEmptyString(upstream && upstream.cwd) ? upstream.cwd : v3Root,
151
- // Per-upstream `timeoutMs` config -> the client's PAYLOAD window (tools/call | prompts/get |
152
- // resources/read; default 120 s).
153
- toolTimeoutMs: Number.isFinite(upstream && upstream.timeoutMs) && upstream.timeoutMs > 0
154
- ? upstream.timeoutMs : undefined,
155
- // Per-upstream `requestTimeoutMs` config -> the CONTROL window (handshake/list/discover/
156
- // ping; default 10 s = the dead-upstream detector). Opt-in for slow-boot servers that
157
- // cannot answer initialize inside 10 s - without it they can never be attached at all.
158
- requestTimeoutMs: Number.isFinite(upstream && upstream.requestTimeoutMs) && upstream.requestTimeoutMs > 0
159
- ? upstream.requestTimeoutMs : undefined,
160
- // legacyPin FORCES the legacy era: skip the modern server/discover probe and go straight to
161
- // the initialize handshake, so a pinned upstream is genuinely spoken to as 2024-11-05 (the pin
162
- // is an ENFORCED policy, not just a warning). Off by default.
163
- forceLegacy: !!(upstream && upstream.legacyPin === true),
164
- // modernOnly (legacyPin's mirror): refuse the legacy fallback - a non-modern upstream fails
165
- // the connect with a clear error instead of negotiating down. Store validation refuses the
166
- // legacyPin+modernOnly contradiction before it ever reaches here.
167
- modernOnly: !!(upstream && upstream.modernOnly === true),
168
- // clientInfo: the identity presented to this upstream (identity mirroring / configured
169
- // identity). null/undefined -> McpClient's built-in default, wire behaviour unchanged.
170
- clientInfo: clientInfo || undefined,
171
- onClose, // unexpected death -> the aggregator schedules a background reconnect
172
- });
173
- }
174
-
175
- /**
176
- * Aggregator - owns the live upstream connections and the curated-expose computation.
177
- *
178
- * Lifecycle: `new Aggregator({ store, v3Root, clientFactory })`
179
- * -> `await connectAll()` (or `await discover(id)` lazily)
180
- * -> `exposedToolDefinitions()` / `resolveExposedExecution(name, args)`
181
- * -> `await closeAll()` (idempotent).
182
- */
183
- class Aggregator {
184
- /**
185
- * @param {object} opts
186
- * @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.
189
- * @param {Function} [opts.wrapTargetProvider] () -> the currently-WRAPPED upstream id (or null).
190
- * Read fresh at each connect: the wrapped upstream is EXEMPT from the path-isolation
191
- * guard (by design - a wrap is an explicit "this server IS my whole
192
- * surface" declaration, so ToolFunnel is a transparent wrapper in that incidence; the
193
- * operator gets a strong warning + post-wrap restriction docs instead of a refusal).
194
- * Funnel-mode upstreams keep the guard unchanged.
195
- * @param {Function} [opts.clientInfoProvider] (upstreamId) -> {name, version}|null. The identity
196
- * to PRESENT to that upstream. Read fresh at each connect (same pattern as the wrap-target
197
- * provider) so a mirror captured after boot is honoured by the next (re)connect. null ->
198
- * McpClient's built-in default.
199
- */
200
- constructor({ store, v3Root, clientFactory, onToolsChanged, wrapTargetProvider, clientInfoProvider } = {}) {
201
- if (!store || typeof store !== 'object') {
202
- throw new Error('Aggregator: a `store` (ExposeStore) is required');
203
- }
204
- this._store = store;
205
- this._v3Root = isNonEmptyString(v3Root) ? v3Root : DEFAULT_ROOT;
206
- // Called when the LIVE tool set changes OUT OF BAND (a background reconnect recovers or finally
207
- // loses an upstream) so the transport can emit notifications/tools/list_changed. Default no-op;
208
- // the server wires it to emitToolsListChanged(send). Settable so reloadExpose can re-wire the
209
- // new instance.
210
- this.onToolsChanged = typeof onToolsChanged === 'function' ? onToolsChanged : () => {};
211
- // Called with (upstreamId, notification) for each server-initiated CHANGE notification from an
212
- // upstream (resources/updated, resources/list_changed, prompts/list_changed, tools/list_changed)
213
- // so the transport can bridge it to a modern client's subscriptions/listen stream. Default no-op;
214
- // the server/http host wires it. Settable so reloadExpose can re-wire the new instance.
215
- this.onUpstreamNotification = () => {};
216
- // Called with (upstreamId, requestMsg, client) for each SERVER-INITIATED REQUEST from an
217
- // upstream (elicitation/create, sampling/createMessage, roots/list - Bridge B). The handler
218
- // owns answering via client.respondToServer(Error). Default: -32601, so an unwired build
219
- // never leaves an upstream holding an open request. Settable; reloadExpose carries it over.
220
- this.onUpstreamServerRequest = (uid, msg, client) => {
221
- try { client.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method); } catch (_e) { /* never throw */ }
222
- };
223
- // When a passthrough WRAP is active, the wrapped upstream's request-scoped chatter
224
- // (notifications/message, notifications/progress) also bridges - connected directly, the
225
- // client would receive it. The server keeps this current with the live wrap state.
226
- this.wrapChatterUpstream = null;
227
- this._wrapTargetProvider = typeof wrapTargetProvider === 'function' ? wrapTargetProvider : null;
228
- 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.
231
- // The wrap-target check runs PER CONNECT (fresh provider read), so a wrap set after boot is
232
- // honoured by the next connect attempt.
233
- this._clientFactory =
234
- typeof clientFactory === 'function'
235
- ? (upstream, onClose) => clientFactory(upstream, this._v3Root, onClose)
236
- : (upstream, onClose) => defaultClientFactory(upstream, this._v3Root, onClose,
237
- this._isWrapTarget(upstream && upstream.id),
238
- this._readClientInfo(upstream && upstream.id));
239
-
240
- /** @type {Map<string, object>} upstreamId -> connected McpClient */
241
- this._clients = new Map();
242
- /** @type {Map<string, Array>} upstreamId -> discovered tool defs [{name,description,inputSchema}] */
243
- this._tools = new Map();
244
- /** @type {Map<string, Promise>} upstreamId -> in-flight connect promise (race guard for ensureConnected) */
245
- this._connecting = new Map();
246
- /** @type {Set<string>} agreed resource-subscription URIs - replayed onto every (re)connect
247
- * of a subscribe-capable legacy upstream so the channel survives reconnects */
248
- this._subscribedUris = new Set();
249
- /** @type {Map<string, any>} upstreamId -> pending background-reconnect timer (one per upstream) */
250
- this._reconnectTimers = new Map();
251
- /** Set true by closeAll(). A torn-down aggregator (e.g. swapped out by reloadExpose) must NEVER
252
- * (re)connect - a captured execute thunk holding this instance would otherwise spawn an orphan
253
- * upstream child on it that nothing ever closes. ensureConnected/connectAll check this. */
254
- this._closed = false;
255
- }
256
-
257
- /**
258
- * Connect every ENABLED upstream in the store, caching each connected client and its
259
- * discovered tools. NEVER throws - a per-upstream failure (isolation guard, spawn
260
- * failure, handshake timeout, listTools failure) is collected into failed[]. An
261
- * upstream already connected (cached) is treated as connected without reconnecting.
262
- *
263
- * @returns {Promise<{ connected: string[], failed: Array<{id:string, error:string}> }>}
264
- */
265
- async connectAll() {
266
- const connected = [];
267
- const failed = [];
268
- if (this._closed) return { connected, failed }; // a closed (torn-down) aggregator never connects
269
-
270
- let upstreams;
271
- try {
272
- upstreams = this._store.listUpstreams();
273
- } catch (err) {
274
- // listUpstreams() shouldn't throw (it clones), but never let it sink connectAll.
275
- return { connected, failed: [{ id: '(store)', error: errMsg(err) }] };
276
- }
277
-
278
- for (const upstream of upstreams) {
279
- if (!upstream || upstream.enabled !== true) continue; // only ENABLED upstreams
280
-
281
- const id = upstream.id;
282
- if (this._clients.has(id)) {
283
- // Already connected this session - keep it; don't double-spawn.
284
- connected.push(id);
285
- continue;
286
- }
287
-
288
- try {
289
- await this._connectOne(upstream);
290
- connected.push(id);
291
- } catch (err) {
292
- // Clean up any half-built client so a failure leaves no zombie/cache entry.
293
- this._discard(id);
294
- const error = errMsg(err);
295
- logger.log({ type: 'mcp', event: 'connect_failed', upstream: id, error });
296
- failed.push({ id, error });
297
- }
298
- }
299
-
300
- return { connected, failed };
301
- }
302
-
303
- /**
304
- * Discover one upstream's tool surface. Connects the upstream if it isn't already
305
- * (lazily, honouring the isolation guard), runs tools/list, caches and returns the
306
- * raw tool definitions [{name, description, inputSchema}]. This is what the MCP
307
- * Manager 'discover' button calls.
308
- *
309
- * Unlike connectAll this MAY reject (it is a direct user action, so the UI wants the
310
- * error) - but it cleans up a failed connection so no zombie child is left.
311
- *
312
- * @param {string} upstreamId
313
- * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
314
- */
315
- async discover(upstreamId) {
316
- if (!isNonEmptyString(upstreamId)) {
317
- throw new Error('Aggregator.discover: upstreamId (non-empty string) is required');
318
- }
319
-
320
- // Route through ensureConnected: it JOINS an in-flight connect via _connecting instead of
321
- // racing it - two concurrent discover() calls used to double-spawn the child and leak the
322
- // loser without closing it. ensureConnected also reaps its own failed connects.
323
- // allowDisabled: discover is the ONE caller permitted to connect a disabled upstream -
324
- // inspecting its tools pre-enable worked before the race fix and stays supported.
325
- try {
326
- await this.ensureConnected(upstreamId, { allowDisabled: true });
327
- } catch (err) {
328
- throw new Error(`Aggregator.discover: cannot connect "${upstreamId}": ${errMsg(err)}`);
329
- }
330
- // Re-list even when the fast path returned a cached client - the discover button must always
331
- // reflect live state (a fresh connect lists twice; cheap, and the semantics stay honest).
332
- return await this._listAndCache(upstreamId);
333
- }
334
-
335
- /**
336
- * @returns {Object<string, Array>} a clone map of upstreamId -> cached discovered tools.
337
- * Only includes upstreams that have been connected/discovered this session.
338
- */
339
- toolsByUpstream() {
340
- const out = {};
341
- for (const [id, tools] of this._tools) {
342
- out[id] = cloneTools(tools);
343
- }
344
- return out;
345
- }
346
-
347
- /**
348
- * The CURATED-DIRECT tool definitions to advertise downstream. For each ENABLED
349
- * expose[] entry whose upstream is CONNECTED and ACTUALLY advertises the named tool:
350
- * - name = store.exposedName(entry) (the downstream `as` name)
351
- * - description = '[' + upstream + '] ' + (upstream tool description || 'tool')
352
- * - inputSchema = the upstream tool's inputSchema || { type:'object' }
353
- *
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).
356
- *
357
- * @returns {Array<{name:string, description:string, inputSchema:object}>}
358
- */
359
- exposedToolDefinitions() {
360
- const defs = [];
361
- let exposed;
362
- try {
363
- exposed = this._store.listExposed({ enabledOnly: true });
364
- } catch (_err) {
365
- return defs; // never throw out of a definition build
366
- }
367
-
368
- for (const e of exposed) {
369
- if (!e || !this._clients.has(e.upstream)) continue; // upstream not connected
370
- const upstreamTools = this._tools.get(e.upstream) || [];
371
- const upstreamTool = upstreamTools.find((t) => t && t.name === e.tool);
372
- if (!upstreamTool) continue; // upstream doesn't actually advertise this tool
373
-
374
- const name = this._store.exposedName(e);
375
- const desc =
376
- isNonEmptyString(upstreamTool.description) ? upstreamTool.description : 'tool';
377
- const inputSchema =
378
- upstreamTool.inputSchema && typeof upstreamTool.inputSchema === 'object'
379
- ? upstreamTool.inputSchema
380
- : { type: 'object' };
381
-
382
- defs.push({
383
- name,
384
- description: `[${e.upstream}] ${desc}`,
385
- inputSchema: cloneJson(inputSchema),
386
- });
387
- }
388
- return defs;
389
- }
390
-
391
- /**
392
- * Is `name` a downstream `as` name currently exposed (enabled) AND backed by a
393
- * connected upstream that advertises the underlying tool? Mirrors the set
394
- * exposedToolDefinitions() advertises.
395
- * @param {string} name
396
- * @returns {boolean}
397
- */
398
- isExposed(name) {
399
- return this._findExposedExecution(name) !== null;
400
- }
401
-
402
- /**
403
- * Resolve a downstream `as` name + args into an executor. Returns
404
- * { execute, toolName, upstream } | null
405
- * where:
406
- * - execute() invokes the connected client's callTool(<real upstream tool name>, args)
407
- * - toolName = the downstream `as` (what PreToolUse matchers see - the gate matches
408
- * the downstream name, execution hits the real upstream tool)
409
- * - upstream = the upstream id
410
- * Returns null if `name` is not exposed/enabled or its upstream isn't connected.
411
- *
412
- * @param {string} name downstream `as` name
413
- * @param {any} args structured arguments forwarded verbatim to callTool
414
- * @returns {{ execute: Function, toolName: string, upstream: string } | null}
415
- */
416
- resolveExposedExecution(name, args, meta) {
417
- const hit = this._findExposedExecution(name);
418
- if (!hit) return null;
419
- const { client, realTool, upstream, exposedName } = hit;
420
- return {
421
- // The execute thunk is the seam the server calls AFTER the PreToolUse gate allows it.
422
- execute: this._forwardExecutor(client, realTool, args, meta),
423
- toolName: exposedName, // downstream name - the gate's matcher target
424
- upstream,
425
- };
426
- }
427
-
428
- // ── lean register forwarding (slice 2) ──────────────────────────────────────
429
- // The LEAN path surfaces an attached upstream's tools through the 4 meta-tools (toolfunnel_list_tools
430
- // / _tool_instructions / _run_tool) instead of injecting them top-level every turn. The full
431
- // discovered set of every CONNECTED + enabled upstream is surfaced (attaching an MCP makes its
432
- // tools appear with no per-tool curation); expose[]/hot governs only the SEPARATE top-level
433
- // (curated-direct) promotion. A lean tool is surfaced under its enabled expose `as` if one exists,
434
- // else the namespaced default `<upstream>_<tool>` - so the lean name equals the curated-direct name
435
- // for an exposed tool and ONE PreToolUse rule gates both routes. All of this lives here in the
436
- // aggregator (it owns the connections, the _clients/_tools caches, and the connect log chokepoint).
437
-
438
- /**
439
- * The single forward seam: a thunk that calls the upstream's real tool. Shared by the
440
- * curated-direct path (resolveExposedExecution, transparent envelope passthrough) and the lean
441
- * path (resolveLeanExecution, which unwraps). Kept tiny so both routes execute identically.
442
- * @param {object} client a connected McpClient
443
- * @param {string} realTool the upstream's own tool name
444
- * @param {any} args
445
- * @returns {() => Promise<any>}
446
- */
447
- _forwardExecutor(client, realTool, args, meta) {
448
- return () => {
449
- this._warnIfPinned(client.id, realTool);
450
- return client.callTool(realTool, args, meta);
451
- };
452
- }
453
-
454
- /**
455
- * Legacy shim PER-CALL warning (the pin's contract: warn on every forwarded call, naming the
456
- * upstream + version). Called from BOTH forward paths - curated-direct (_forwardExecutor) and
457
- * lean (resolveLeanExecution's own closure; it cannot use _forwardExecutor because it needs
458
- * ensureConnected + envelope unwrapping). Checked at CALL time so a live expose.json edit takes
459
- * effect immediately; stderr + activity log, never the content. NEVER throws.
460
- * @param {string} upstreamId
461
- * @param {string} realTool the upstream's own tool name
462
- */
463
- _warnIfPinned(upstreamId, realTool) {
464
- try {
465
- const upstream = this._store.getUpstream(upstreamId);
466
- if (upstream && upstream.legacyPin === true) {
467
- process.stderr.write(
468
- `[toolfunnel] legacy shim: forwarding "${realTool}" to pinned legacy upstream ` +
469
- `"${upstreamId}" (MCP ${LEGACY_UPSTREAM_VERSION})\n`
470
- );
471
- logger.log({ type: 'mcp', event: 'legacy_shim_call', upstream: upstreamId, tool: realTool, protocolVersion: LEGACY_UPSTREAM_VERSION });
472
- }
473
- } catch (_e) { /* the warning must never affect the call */ }
474
- }
475
-
476
- /**
477
- * Legacy-shim info for a surfaced OR original call name: { upstream, protocolVersion } when the
478
- * tool belongs to a PINNED (legacyPin) upstream, else null. Used by the modern era to tag
479
- * results with _meta["io.toolfunnel/legacyShim"] - metadata, never content. NEVER throws.
480
- * @param {string} name the name a client called (surfaced `<upstream>_<tool>`/`as`, or the
481
- * ORIGINAL tool name under passthrough)
482
- * @returns {{ upstream: string, protocolVersion: string } | null}
483
- */
484
- legacyShimInfo(name) {
485
- if (typeof name !== 'string' || name.length === 0) return null;
486
- try {
487
- const d = this.leanToolDefinitions().find((x) => x && (x.name === name || x.tool === name));
488
- if (!d) return null;
489
- const upstream = this._store.getUpstream(d.upstream);
490
- if (!upstream || upstream.legacyPin !== true) return null;
491
- return { upstream: d.upstream, protocolVersion: LEGACY_UPSTREAM_VERSION };
492
- } catch (_e) {
493
- return null;
494
- }
495
- }
496
-
497
- /**
498
- * The wrapped upstream's OWN identity, extracted from its captured `initialize` result - so a
499
- * passthrough WRAP can present the wrapped server verbatim (indistinguishable from connecting to
500
- * it directly, save for the ToolFunnel process). Returns { protocolVersion, serverInfo,
501
- * capabilities, instructions } with best-effort extraction (some servers nest capabilities /
502
- * instructions inside serverInfo), or null if the upstream isn't connected. NEVER throws.
503
- * @param {string} upstreamId
504
- * @returns {{ protocolVersion?:string, serverInfo?:object, capabilities?:object, instructions?:string } | null}
505
- */
506
- wrappedIdentity(upstreamId) {
507
- try {
508
- const client = this._clients.get(upstreamId);
509
- if (!client || typeof client.initializeResult === 'undefined') return null;
510
- const init = client.initializeResult;
511
- if (!init || typeof init !== 'object') return null;
512
- const si = init.serverInfo && typeof init.serverInfo === 'object' ? init.serverInfo : {};
513
- // serverInfo passes VERBATIM minus the two fields some servers wrongly nest inside it
514
- // (capabilities/instructions - extracted separately below). A whitelist here ate the spec's
515
- // 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.
517
- const serverInfo = {};
518
- for (const k of Object.keys(si)) {
519
- if (k === 'capabilities' || k === 'instructions') continue;
520
- serverInfo[k] = cloneJson(si[k]);
521
- }
522
- return {
523
- protocolVersion: typeof init.protocolVersion === 'string' ? init.protocolVersion : undefined,
524
- serverInfo: Object.keys(serverInfo).length ? serverInfo : undefined,
525
- // capabilities / instructions: prefer top-level (standard), fall back to serverInfo-nested.
526
- capabilities: (init.capabilities && typeof init.capabilities === 'object') ? init.capabilities
527
- : (si.capabilities && typeof si.capabilities === 'object') ? si.capabilities : undefined,
528
- instructions: typeof init.instructions === 'string' ? init.instructions
529
- : typeof si.instructions === 'string' ? si.instructions : undefined,
530
- };
531
- } catch (_e) {
532
- return null;
533
- }
534
- }
535
-
536
- /**
537
- * Forward a RAW JSON-RPC method to a connected upstream and return its result - the passthrough
538
- * WRAP's relay for non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
539
- * server behaves exactly as it would connected directly. Uses a LIVE client only (no synchronous
540
- * (re)connect - same rule as the lean run path). Throws if the upstream is not connected or the
541
- * call fails; the caller shapes that into a JSON-RPC error.
542
- * @param {string} upstreamId
543
- * @param {string} method
544
- * @param {any} params
545
- * @returns {Promise<any>}
546
- */
547
- async forwardRaw(upstreamId, method, params, out) {
548
- const client = await this.ensureConnected(upstreamId, { allowConnect: false });
549
- if (!client || typeof client.request !== 'function') {
550
- throw new Error(`upstream "${upstreamId}" is not connected`);
551
- }
552
- return await client.request(method, params, out);
553
- }
554
-
555
- /** The clientInfo to PRESENT to `upstreamId`, or null for the built-in. Fresh provider read per
556
- * call (per-connect, same as the wrap-target check); NEVER throws. */
557
- _readClientInfo(upstreamId) {
558
- try {
559
- const info = this._clientInfoProvider ? this._clientInfoProvider(upstreamId) : null;
560
- return info && typeof info === 'object' &&
561
- typeof info.name === 'string' && info.name.length ? info : null;
562
- } catch (_e) {
563
- return null; // a broken provider degrades to the built-in identity
564
- }
565
- }
566
-
567
- /** Is `upstreamId` the currently-wrapped upstream? Fresh provider read per call; NEVER throws.
568
- * Drives the per-connect isolation exemption (transparent-wrapper mode). */
569
- _isWrapTarget(upstreamId) {
570
- try {
571
- return !!(this._wrapTargetProvider && isNonEmptyString(upstreamId) &&
572
- this._wrapTargetProvider() === upstreamId);
573
- } catch (_e) {
574
- return false; // a broken provider must degrade to the SAFE default (guard applies)
575
- }
576
- }
577
-
578
- /** Set the wrap-chatter scope. When the passthrough TARGET CHANGES (A->B, A->null, null->A) the
579
- * recorded resource subscriptions belong to the OLD context and must not replay onto the new
580
- * one - a client of wrap B never subscribed to wrap A's URIs. A
581
- * same-value re-arm (every message does one) clears nothing. */
582
- setWrapChatterUpstream(v) {
583
- const next = v == null ? null : v;
584
- if (next !== this.wrapChatterUpstream && this._subscribedUris instanceof Set) {
585
- this._subscribedUris.clear();
586
- }
587
- this.wrapChatterUpstream = next;
588
- }
589
-
590
- /** Relay a client NOTIFICATION to an upstream (wrap passthrough: notifications/cancelled,
591
- * notifications/progress ...). Fire-and-forget; never throws, never answers. */
592
- notifyRaw(upstreamId, method, params) {
593
- try {
594
- const client = this._clients.get(upstreamId);
595
- if (client && typeof client.notify === 'function') client.notify(method, params);
596
- } catch (_e) { /* fire-and-forget */ }
597
- }
598
-
599
- /** TRUE if a connected upstream can ACTUALLY deliver per-URI resource updates. That means a
600
- * LEGACY upstream advertising resources.subscribe (we forward the subscribes; it emits
601
- * resources/updated). A MODERN upstream does NOT count: its only notification channel is a
602
- * subscriptions/listen stream this client does not yet open (28-July item), so counting it
603
- * agreed a channel nothing would ever feed. Under a wrap only the WRAPPED
604
- * upstream counts - delivery is filtered to it, so another upstream honouring the channel
605
- * would be agreed-then-dropped. The ack must never promise a dead channel. */
606
- canHonourResourceSubscriptions() {
607
- for (const [id, client] of this._clients) {
608
- if (!client) continue;
609
- if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
610
- if (client.era === 'modern') continue;
611
- const caps = client.initializeResult && client.initializeResult.capabilities;
612
- if (caps && caps.resources && caps.resources.subscribe) return true;
613
- }
614
- return false;
615
- }
616
-
617
- /** Best-effort: forward resources/subscribe for each URI to every subscribe-capable legacy
618
- * upstream in scope (the wrapped one only, under a wrap). The URIs are RECORDED
619
- * so every later (re)connect replays them: a reconnected upstream is a fresh process that
620
- * remembers no subscriptions, and without the replay the client's agreed channel went silently
621
- * dead forever. Fire-and-forget; never throws. */
622
- subscribeResources(uris) {
623
- try {
624
- const list = Array.isArray(uris) ? uris.filter((u) => typeof u === 'string' && u.length) : [];
625
- if (!list.length) return;
626
- for (const uri of list) this._subscribedUris.add(uri);
627
- for (const [id, client] of this._clients) {
628
- if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
629
- if (!client || client.era !== 'legacy' || typeof client.request !== 'function') continue;
630
- const caps = client.initializeResult && client.initializeResult.capabilities;
631
- if (!(caps && caps.resources && caps.resources.subscribe)) continue;
632
- for (const uri of list) client.request('resources/subscribe', { uri }).catch(() => {});
633
- }
634
- } catch (_e) { /* never throw */ }
635
- }
636
-
637
- /** Replay the recorded resource subscriptions onto ONE freshly-connected upstream.
638
- * Called from _connectOne so every connect path - startup, lazy, background reconnect, reload -
639
- * re-arms the channel. Same scope rules as subscribeResources. Never throws. */
640
- _replaySubscriptions(upstreamId) {
641
- try {
642
- if (!this._subscribedUris || this._subscribedUris.size === 0) return;
643
- if (this.wrapChatterUpstream && upstreamId !== this.wrapChatterUpstream) return;
644
- const client = this._clients.get(upstreamId);
645
- if (!client || client.era !== 'legacy' || typeof client.request !== 'function') return;
646
- const caps = client.initializeResult && client.initializeResult.capabilities;
647
- if (!(caps && caps.resources && caps.resources.subscribe)) return;
648
- for (const uri of this._subscribedUris) client.request('resources/subscribe', { uri }).catch(() => {});
649
- } catch (_e) { /* never throw */ }
650
- }
651
-
652
- /** The change-notification methods an upstream may push that we bridge to modern clients. Anything
653
- * else from the upstream is ignored (request-scoped notifications ride their own request stream). */
654
- static get BRIDGED_NOTIFICATIONS() {
655
- return new Set([
656
- 'notifications/tools/list_changed',
657
- 'notifications/prompts/list_changed',
658
- 'notifications/resources/list_changed',
659
- 'notifications/resources/updated',
660
- ]);
661
- }
662
-
663
- /**
664
- * Forward a server-initiated CHANGE notification from an upstream to the transport hook, which
665
- * fans it to modern subscriptions/listen streams. Only the known change methods pass; the
666
- * notification is handed on verbatim (the transport tags + filters per subscription). NEVER
667
- * throws (an onNotification handler must never break the client's read loop).
668
- * @param {string} upstreamId
669
- * @param {object} notification a JSON-RPC notification { method, params? }
670
- */
671
- _forwardUpstreamNotification(upstreamId, notification) {
672
- try {
673
- if (this._closed) return; // a swapped-out/torn-down aggregator must never fan out (F9)
674
- if (!notification || typeof notification.method !== 'string') return;
675
- const isWrapChatter = this.wrapChatterUpstream === upstreamId &&
676
- (notification.method === 'notifications/message' || notification.method === 'notifications/progress');
677
- if (!Aggregator.BRIDGED_NOTIFICATIONS.has(notification.method) && !isWrapChatter) return;
678
- // Forward a CLEAN notification only - a misbehaving upstream's stray `id: null` field would
679
- // read as a response to downstream parsers (F7).
680
- const clean = { jsonrpc: '2.0', method: notification.method };
681
- if (notification.params !== undefined) clean.params = notification.params;
682
- const emit = () => {
683
- try {
684
- if (typeof this.onUpstreamNotification === 'function') {
685
- this.onUpstreamNotification(upstreamId, clean);
686
- }
687
- } catch (_e) { /* never let a bridged notification break the read loop */ }
688
- };
689
- if (notification.method === 'notifications/tools/list_changed') {
690
- // A relayed "tools changed" must not point the client at a STALE surface - refresh the
691
- // cache FIRST, then relay, so the re-fetch sees the new list (F2/B-M4). onToolsChanged
692
- // fires too so ToolFunnel's own funnel-mode signal stays coherent.
693
- this._listAndCache(upstreamId)
694
- .then(() => { try { if (typeof this.onToolsChanged === 'function') this.onToolsChanged(); } catch (_e) { /* ignore */ } })
695
- .catch(() => { /* refresh failed - still relay; the old cache is the best we have */ })
696
- .then(emit);
697
- return;
698
- }
699
- emit();
700
- } catch (_e) { /* never let a bridged notification break the read loop */ }
701
- }
702
-
703
- /**
704
- * The downstream name a discovered upstream tool is surfaced under: its ENABLED expose `as` if one
705
- * exists for (upstream, tool), else the namespaced default `<upstream>_<tool>` (matching
706
- * expose-store.exposedName). Keeps the lean list and the lean run path in agreement, and aligns an
707
- * exposed tool's lean name with its curated-direct top-level name. Never throws.
708
- * @param {string} upstreamId
709
- * @param {string} toolName
710
- * @returns {string}
711
- */
712
- _surfacedName(upstreamId, toolName) {
713
- try {
714
- const exposed = this._store.listExposed({ enabledOnly: true });
715
- for (const e of exposed) {
716
- if (e && e.upstream === upstreamId && e.tool === toolName) return this._store.exposedName(e);
717
- }
718
- } catch (_e) {
719
- /* fall through to the default */
720
- }
721
- return `${upstreamId}_${toolName}`;
722
- }
723
-
724
- /**
725
- * Connect an upstream ON DEMAND and return the ready McpClient. Idempotent + race-safe:
726
- * - fast path: a cached client that is still `connected` is returned as-is; a cached-but-DEAD
727
- * client (its child exited - McpClient flips `connected` but stays in the cache) is logged as a
728
- * disconnect, discarded, and reconnected - a reconnect re-lists the upstream's tools.
729
- * - concurrent callers JOIN the same in-flight connect promise (keyed in _connecting), which
730
- * resolves only AFTER _listAndCache - so no caller ever sees the half-built client that
731
- * _connectOne caches BEFORE the handshake completes (the double-spawn / mid-handshake race).
732
- * Throws on an unknown/disabled upstream or a connect/list failure (the caller surfaces it; for the
733
- * lean run path that means gatedRun returns { ok:false, error } cleanly).
734
- * @param {string} upstreamId
735
- * @returns {Promise<object>} the connected McpClient
736
- */
737
- async ensureConnected(upstreamId, opts) {
738
- if (!isNonEmptyString(upstreamId)) throw new Error('Aggregator.ensureConnected: upstreamId (non-empty string) is required');
739
- // allowConnect:false (the RUN path) must NEVER block on a connect - not a from-scratch connect
740
- // (connect()+tools/list, ~20s, would freeze the serialized stdio chain and starve pings) AND not
741
- // by JOINING an in-flight background reconnect (same latency). It returns a live client or fails
742
- // clean; the background reconnect owns recovery and the run retries next turn. allowConnect
743
- // defaults true (the background path connects / joins).
744
- const allowConnect = !(opts && opts.allowConnect === false);
745
- // allowDisabled: ONLY the discover path sets it - inspecting a disabled upstream's tools
746
- // ("what does this have, before I enable it?") is a deliberate operator action that worked
747
- // before discover was routed through here for the race fix. Every
748
- // other caller keeps the refusal; a disabled upstream's tools stay OFF the lean surface
749
- // regardless (leanToolDefinitions checks enabled itself).
750
- const allowDisabled = !!(opts && opts.allowDisabled === true);
751
- // A torn-down aggregator (swapped out by reloadExpose, then closeAll'd) must NEVER (re)connect:
752
- // a captured execute thunk holding this instance would otherwise spawn an ORPHAN child on it.
753
- if (this._closed) throw new Error('Aggregator.ensureConnected: aggregator is closed (config reloaded) - retry on the current one');
754
-
755
- // A connect already in flight: JOIN it - BEFORE inspecting the cache. _connectOne caches the
756
- // client pre-handshake, so a concurrent caller inspecting first sees `connected:false`, reads
757
- // it as stale, closes it (killing the in-flight child mid-handshake) and then joins the
758
- // connect it just doomed - both callers fail. The RUN path
759
- // (allowConnect:false) still never joins - joining would block the stdio chain.
760
- if (this._connecting.has(upstreamId)) {
761
- if (!(opts && opts.allowConnect === false)) return this._connecting.get(upstreamId);
762
- throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background)`);
763
- }
764
-
765
- // Fast path: a live cached client serves BOTH modes immediately.
766
- const existing = this._clients.get(upstreamId);
767
- if (existing) {
768
- const alive = typeof existing.connected === 'boolean' ? existing.connected : true;
769
- if (alive) return existing;
770
- // Dead cached client. The RUN path fails clean (never (re)connects); the background path drops
771
- // it and reconnects below.
772
- if (!allowConnect) {
773
- throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" disconnected (reconnecting in background)`);
774
- }
775
- logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'stale' });
776
- this._discard(upstreamId);
777
- } else if (!allowConnect) {
778
- // Not connected. The RUN path neither connects NOR joins an in-flight connect - joining would
779
- // block the stdio chain for the connect+list latency (the head-of-line freeze this mode avoids).
780
- throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background)`);
781
- }
782
-
783
- // Background path only (allowConnect): connect from scratch. In-flight connects were joined
784
- // at the top of the method (before cache inspection - the mid-connect discard race); nothing
785
- // between there and here awaits, so no new entry can have appeared.
786
- const upstream = this._store.getUpstream(upstreamId);
787
- if (!upstream) throw new Error(`Aggregator.ensureConnected: unknown upstream "${upstreamId}"`);
788
- if (upstream.enabled !== true && !allowDisabled) throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is disabled`);
789
-
790
- const promise = (async () => {
791
- await this._connectOne(upstream); // caches client + tools, logs the connect
792
- return this._clients.get(upstreamId);
793
- })();
794
- this._connecting.set(upstreamId, promise);
795
- try {
796
- return await promise;
797
- } catch (err) {
798
- this._discard(upstreamId); // reap the half-spawned child; leave no zombie/cache entry
799
- throw err;
800
- } finally {
801
- this._connecting.delete(upstreamId);
802
- }
803
- }
804
-
805
- /**
806
- * Force a fresh connect of `upstreamId` presenting the CURRENT provider-supplied identity -
807
- * the wrap identity-mirror's inline path. Settles any in-flight connect FIRST: discarding a
808
- * half-built client and then joining its doomed promise leaves the upstream wedged with no
809
- * background reconnect and no self-heal. Rejects
810
- * like ensureConnected on failure.
811
- */
812
- async reconnect(upstreamId) {
813
- if (!isNonEmptyString(upstreamId)) throw new Error('Aggregator.reconnect: upstreamId (non-empty string) is required');
814
- while (this._connecting.has(upstreamId)) {
815
- try { await this._connecting.get(upstreamId); } catch (_e) { /* settled is all we need */ }
816
- }
817
- this._discard(upstreamId);
818
- try {
819
- return await this.ensureConnected(upstreamId);
820
- } catch (err) {
821
- // This path DESTROYED a (possibly healthy) connection, and the deliberate close means
822
- // onClose never fires - without this, one transient connect failure leaves no client, no
823
- // in-flight connect AND no retry timer: the upstream is wedged until an external kick
824
- // while ensureConnected's "reconnecting in background" message lies. The destroyer owns the retry. A stale PENDING timer (a prior death's slow
825
- // 30s keepalive) must be cleared first - _scheduleReconnect's one-timer guard would
826
- // otherwise no-op and leave the upstream on the long window instead of the immediate
827
- // retry this path promises.
828
- const stale = this._reconnectTimers.get(upstreamId);
829
- if (stale) { clearTimeout(stale); this._reconnectTimers.delete(upstreamId); }
830
- this._scheduleReconnect(upstreamId, 0);
831
- throw err;
832
- }
833
- }
834
-
835
- /**
836
- * UNEXPECTED upstream death (fired by McpClient.onClose). Log it, drop the dead client so it
837
- * leaves the lean list, tell the client the tool set shrank, then schedule a BACKGROUND reconnect.
838
- * Keeping recovery off the serialized stdio chain is what closes the head-of-line regression: a run
839
- * during the down-window fails clean (ensureConnected allowConnect:false) instead of blocking.
840
- * @param {string} upstreamId
841
- */
842
- _handleUpstreamDown(upstreamId) {
843
- if (this._closed) return;
844
- logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'died' });
845
- this._discard(upstreamId); // remove the dead client + its tools -> drops from the lean list
846
- this._signalToolsChanged(upstreamId); // list_changed only if the upstream is on the top-level surface
847
- this._scheduleReconnect(upstreamId, 0);
848
- }
849
-
850
- /**
851
- * Does this upstream contribute to the TOP-LEVEL tools/list (i.e. has an ENABLED expose[] entry)?
852
- * Only then does its death/recovery change tools/list and warrant a notifications/tools/list_changed.
853
- * A lean-only upstream's tools live behind toolfunnel_list_tools (pulled per call), so notifying for
854
- * one is spurious (the client re-fetches and sees no diff). Never throws.
855
- * @param {string} upstreamId
856
- * @returns {boolean}
857
- */
858
- _affectsTopLevel(upstreamId) {
859
- try {
860
- // Under a WRAP the wrapped upstream IS the entire top-level surface - its death/recovery
861
- // changes tools/list even with an empty expose[] (a typical wrap has no expose
862
- // entries, so death/recovery was silent and clients held a stale surface forever).
863
- // wrapChatterUpstream mirrors the live wrap state (armed at boot, per message, on reload).
864
- if (this.wrapChatterUpstream && this.wrapChatterUpstream === upstreamId) return true;
865
- // Hot-promoted upstream tools sit on the funnel's top-level surface without an expose[]
866
- // entry; the host injects this predicate (armWrapChatter) because promotion lives in the
867
- // tool-state overlay the aggregator has no business reading directly.
868
- if (typeof this.topLevelUpstreamExtra === 'function' && this.topLevelUpstreamExtra(upstreamId)) return true;
869
- return this._store.listExposed({ enabledOnly: true }).some((e) => e && e.upstream === upstreamId);
870
- } catch (_e) {
871
- return false;
872
- }
873
- }
874
-
875
- /** Fire onToolsChanged ONLY when the upstream is actually on the top-level surface (else the
876
- * notification is spurious - its lean tools refresh on the next toolfunnel_list_tools). Never throws. */
877
- _signalToolsChanged(upstreamId) {
878
- if (!this._affectsTopLevel(upstreamId)) return;
879
- try { this.onToolsChanged(); } catch (_e) { /* never throw out of an event handler */ }
880
- }
881
-
882
- /**
883
- * Schedule ONE background reconnect attempt with exponential backoff: 1s,2s,4s,8s,16s, then capped
884
- * at 30s. After the fast phase it does NOT give up - it falls back to a slow 30s keepalive so a long
885
- * transient outage still self-heals (a detached/disabled upstream stops it via getUpstream; closeAll
886
- * stops it via _closed). On success the tools are re-listed and a top-level change is signalled. One
887
- * timer per upstream (no pile-up); the timer is unref'd so it never keeps the process alive. NEVER throws.
888
- * @param {string} upstreamId
889
- * @param {number} attempt
890
- */
891
- _scheduleReconnect(upstreamId, attempt) {
892
- if (this._closed) return;
893
- if (this._reconnectTimers.has(upstreamId)) return; // a reconnect is already pending for this id
894
- const upstream = this._store.getUpstream(upstreamId);
895
- if (!upstream || upstream.enabled !== true) return; // detached/disabled - stop trying
896
- const MAX_FAST_ATTEMPTS = 6; // exponential phase; beyond it, a slow keepalive at the 30s cap
897
- const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
898
- const timer = setTimeout(async () => {
899
- this._reconnectTimers.delete(upstreamId);
900
- if (this._closed) return;
901
- const u = this._store.getUpstream(upstreamId);
902
- if (!u || u.enabled !== true) return;
903
- try {
904
- await this.ensureConnected(upstreamId); // allowConnect default - connects + re-lists, off-chain
905
- logger.log({ type: 'mcp', event: 'reconnect', upstream: upstreamId, attempt });
906
- this._signalToolsChanged(upstreamId);
907
- } catch (err) {
908
- logger.log({ type: 'mcp', event: 'reconnect_failed', upstream: upstreamId, attempt, error: errMsg(err) });
909
- // Log the fast->slow transition exactly ONCE - when the counter FIRST reaches the cap. The
910
- // old guard sat on the re-entry side, where the capped counter re-satisfied it every 30s
911
- // forever against a permanently-dead upstream (log spam, not information).
912
- if (attempt + 1 === MAX_FAST_ATTEMPTS) {
913
- logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: MAX_FAST_ATTEMPTS });
914
- }
915
- // 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.
917
- this._scheduleReconnect(upstreamId, Math.min(attempt + 1, MAX_FAST_ATTEMPTS));
918
- }
919
- }, delay);
920
- if (timer && typeof timer.unref === 'function') timer.unref();
921
- this._reconnectTimers.set(upstreamId, timer);
922
- }
923
-
924
- /**
925
- * The LEAN tool definitions: every discovered tool of every CONNECTED + enabled upstream, surfaced
926
- * for toolfunnel_list_tools. Each: { name, description, inputSchema, upstream, tool }. The FULL
927
- * discovered set (not just expose[]) - attaching an MCP makes its tools appear leanly with no
928
- * curation. Only connected upstreams contribute (we need their real schemas); a not-yet-connected
929
- * upstream's tools simply appear once it connects (the next list call reflects it - no
930
- * notification needed, the lean surface is delivered inside a per-call meta-tool). Never throws.
931
- * @returns {Array<{name:string, description:string, inputSchema:object, upstream:string, tool:string}>}
932
- */
933
- leanToolDefinitions() {
934
- const defs = [];
935
- const seen = new Set(); // surfaced-name collision guard - the list must agree with resolve (first wins)
936
- try {
937
- for (const [upstreamId, tools] of this._tools) {
938
- if (!this._clients.has(upstreamId)) continue; // connected only
939
- const upstream = this._store.getUpstream(upstreamId);
940
- if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
941
- if (!Array.isArray(tools)) continue;
942
- for (const t of tools) {
943
- if (!t || !isNonEmptyString(t.name)) continue;
944
- const name = this._surfacedName(upstreamId, t.name);
945
- if (seen.has(name)) {
946
- // Two tools collapse to one surfaced name (underscore ambiguity, or a duplicate `as`).
947
- // resolveLeanExecution returns the FIRST match, so advertise only the first - the list
948
- // must never show a name that resolves to a different tool. Log the shadowing.
949
- logger.log({ type: 'mcp', event: 'name_collision', upstream: upstreamId, tool: t.name, surfaced: name });
950
- continue;
951
- }
952
- seen.add(name);
953
- const schema = t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : { type: 'object' };
954
- defs.push({
955
- name,
956
- description: isNonEmptyString(t.description) ? t.description : '',
957
- inputSchema: cloneJson(schema),
958
- upstream: upstreamId,
959
- tool: t.name,
960
- });
961
- }
962
- }
963
- } catch (_e) {
964
- /* never throw out of a definition build - return what we have */
965
- }
966
- return defs;
967
- }
968
-
969
- /**
970
- * Resolve a lean downstream name + args into a forwarding executor for toolfunnel_run_tool. Matches
971
- * by ITERATION over discovered tools (computing each tool's surfaced name) - never by parsing the
972
- * name (underscore-ambiguous). The execute thunk lazy-(re)connects via ensureConnected and UNWRAPS
973
- * the upstream MCP envelope to a clean payload, so run_tool returns the upstream's content (e.g.
974
- * "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).
977
- * @param {string} name surfaced downstream name
978
- * @param {any} args
979
- * @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
980
- */
981
- resolveLeanExecution(name, args) {
982
- if (!isNonEmptyString(name)) return null;
983
- for (const [upstreamId, tools] of this._tools) {
984
- if (!this._clients.has(upstreamId)) continue;
985
- if (!Array.isArray(tools)) continue;
986
- for (const t of tools) {
987
- if (!t || !isNonEmptyString(t.name)) continue;
988
- if (this._surfacedName(upstreamId, t.name) !== name) continue;
989
- const realTool = t.name;
990
- const self = this;
991
- const execute = async () => {
992
- // allowConnect:false - the run path NEVER synchronously (re)connects (that would freeze the
993
- // stdio loop, starving pings). It uses a live client or joins an in-flight background
994
- // reconnect, else fails clean; the onClose-driven background reconnect owns recovery.
995
- const client = await self.ensureConnected(upstreamId, { allowConnect: false });
996
- self._warnIfPinned(upstreamId, realTool);
997
- const envelope = await client.callTool(realTool, args);
998
- // Preserve the upstream's failure signal. callTool RESOLVES even on isError:true, so a
999
- // failed upstream call must become a THROW -> gatedRun returns { ok:false } -> run_tool
1000
- // surfaces isError:true. Otherwise a failed call would be reported as success - and the
1001
- // lean path must agree with curated-direct's isError passthrough.
1002
- const payload = unwrapEnvelope(envelope);
1003
- if (envelope && typeof envelope === 'object' && envelope.isError === true) {
1004
- const text = typeof payload === 'string' ? payload : safeStringify(payload);
1005
- const err = new Error(text && text.length ? text : 'upstream tool reported an error');
1006
- err.upstreamToolError = true;
1007
- throw err;
1008
- }
1009
- return payload;
1010
- };
1011
- return { execute, toolName: name, upstream: upstreamId };
1012
- }
1013
- }
1014
- return null;
1015
- }
1016
-
1017
- /**
1018
- * resolveRawExecution - the passthrough (WRAP) twin of resolveLeanExecution. Its execute returns
1019
- * the upstream's tools/call result VERBATIM - multi-block content, isError, structuredContent
1020
- * and any future fields untouched. A wrapped client must receive exactly what a direct client
1021
- * would; the lean unwrap (single text block, throw-on-isError) destroys that. No unwrap, no
1022
- * throw-on-isError: the envelope IS the product. Uses client.request() so a modern upstream
1023
- * gets its per-request _meta trio.
1024
- * @param {string} name surfaced downstream name
1025
- * @param {any} args
1026
- * @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
1027
- */
1028
- resolveRawExecution(name, args, meta, out) {
1029
- if (!isNonEmptyString(name)) return null;
1030
- for (const [upstreamId, tools] of this._tools) {
1031
- if (!this._clients.has(upstreamId)) continue;
1032
- if (!Array.isArray(tools)) continue;
1033
- for (const t of tools) {
1034
- if (!t || !isNonEmptyString(t.name)) continue;
1035
- if (this._surfacedName(upstreamId, t.name) !== name) continue;
1036
- const realTool = t.name;
1037
- const self = this;
1038
- const execute = async () => {
1039
- // allowConnect:false - same freeze-avoidance rule as the lean path.
1040
- const client = await self.ensureConnected(upstreamId, { allowConnect: false });
1041
- self._warnIfPinned(upstreamId, realTool);
1042
- const params = { name: realTool, arguments: args == null ? {} : args };
1043
- // The caller's request-scoped _meta (progressToken, trace keys - already stripped of
1044
- // protocol keys) rides the forward: a direct server would receive it, so the wrapped
1045
- // one must too - without it the upstream never emits progress for the call.
1046
- if (meta && typeof meta === 'object' && Object.keys(meta).length) params._meta = meta;
1047
- // `out.rpcId` is set synchronously at issue time - the caller's cancel-translation
1048
- // hook (the same contract as forwardRaw's).
1049
- return await client.request('tools/call', params, out);
1050
- };
1051
- return { execute, toolName: name, upstream: upstreamId };
1052
- }
1053
- }
1054
- return null;
1055
- }
1056
-
1057
- /**
1058
- * Close every cached client and clear the caches. Idempotent - safe to call twice and
1059
- * safe when nothing was ever connected. NEVER throws (close() on McpClient never throws).
1060
- * @returns {Promise<void>}
1061
- */
1062
- async closeAll() {
1063
- this._closed = true; // latch BEFORE teardown so any in-flight execute thunk refuses to re-spawn
1064
- for (const [, timer] of this._reconnectTimers) {
1065
- try { clearTimeout(timer); } catch (_e) { /* ignore */ }
1066
- }
1067
- this._reconnectTimers.clear();
1068
- for (const [id, client] of this._clients) {
1069
- try {
1070
- if (client && typeof client.close === 'function') client.close();
1071
- // Activity log (self-gating): the disconnect half. A reload closes the old aggregator here,
1072
- // so a reconnect reads as a disconnect line followed by a connect line for the same id.
1073
- logger.log({ type: 'mcp', event: 'disconnect', upstream: id });
1074
- } catch (_e) {
1075
- /* McpClient.close never throws, but never let teardown sink the loop */
1076
- }
1077
- }
1078
- this._clients.clear();
1079
- this._tools.clear();
1080
- }
1081
-
1082
- // ── internals ──────────────────────────────────────────────────────────────
1083
-
1084
- /**
1085
- * Connect a single upstream entry and cache the client + its discovered tools.
1086
- * Throws on factory (isolation), connect, or listTools failure - callers decide
1087
- * whether to collect (connectAll) or surface (discover) the error. On throw, the
1088
- * caller is responsible for _discard()-ing the id.
1089
- * @param {object} upstream a normalised ExposeStore upstream
1090
- */
1091
- async _connectOne(upstream) {
1092
- // The factory may throw (isolation guard) - that propagates out as a connect failure. Pass an
1093
- // onClose so an UNEXPECTED upstream death triggers a BACKGROUND reconnect (not a lazy one in the
1094
- // run path, which would freeze the stdio chain).
1095
- const client = this._clientFactory(upstream, () => this._handleUpstreamDown(upstream.id));
1096
- if (!client || typeof client.connect !== 'function') {
1097
- throw new Error(`clientFactory returned an invalid client for "${upstream.id}"`);
1098
- }
1099
- // Bridge this upstream's server-initiated change-notifications to the transport (which fans them
1100
- // out to modern subscriptions/listen streams). Filtered to the known change methods so arbitrary
1101
- // upstream chatter never reaches a client. NEVER throws.
1102
- client.onNotification = (n) => this._forwardUpstreamNotification(upstream.id, n);
1103
- // Bridge B: surface server-initiated requests with the upstream id + the live client (the
1104
- // handler answers on it). Guarded so a handler bug can never break the client's read loop.
1105
- client.onServerRequest = (m) => {
1106
- try {
1107
- if (this._closed) { client.respondToServerError(m.id, -32603, 'shutting down'); return; }
1108
- this.onUpstreamServerRequest(upstream.id, m, client);
1109
- } catch (_e) {
1110
- try { client.respondToServerError(m.id, -32603, 'request failed'); } catch (_e2) { /* ignore */ }
1111
- }
1112
- };
1113
- // Cache the client BEFORE connect so a connect-failure path can still _discard() and
1114
- // close any half-spawned child.
1115
- this._clients.set(upstream.id, client);
1116
- await client.connect();
1117
- await this._listAndCache(upstream.id);
1118
- // Re-arm any agreed resource subscriptions on the fresh process - every connect
1119
- // path funnels through here, so reconnects and reloads replay uniformly.
1120
- this._replaySubscriptions(upstream.id);
1121
- // Activity log (self-gating; no-op unless logging is enabled): the upstream half of connect
1122
- // logging. EVERY connect flows through here - startup connectAll, a live reload, a lazy
1123
- // discover, and (later) a health-driven reconnect - so all of them are logged uniformly.
1124
- logger.log({
1125
- type: 'mcp',
1126
- event: 'connect',
1127
- upstream: upstream.id,
1128
- command: upstream.command,
1129
- tools: (this._tools.get(upstream.id) || []).length,
1130
- });
1131
- // Legacy shim STARTUP warning (the pin's contract: warn at startup AND per call, naming the
1132
- // version). stderr, so the operator sees it without the client's content ever growing.
1133
- if (upstream.legacyPin === true) {
1134
- try {
1135
- process.stderr.write(
1136
- `[toolfunnel] legacy shim: upstream "${upstream.id}" is PINNED to MCP ${LEGACY_UPSTREAM_VERSION} ` +
1137
- '(legacyPin) - this gateway keeps it working for modern clients; it cannot speak 2026-07-28 itself.\n'
1138
- );
1139
- } catch (_e) { /* never let a warning sink a connect */ }
1140
- logger.log({ type: 'mcp', event: 'legacy_shim_connect', upstream: upstream.id, protocolVersion: LEGACY_UPSTREAM_VERSION });
1141
- }
1142
- }
1143
-
1144
- /** tools/list one connected upstream and cache the result; returns a clone. */
1145
- async _listAndCache(upstreamId) {
1146
- const client = this._clients.get(upstreamId);
1147
- if (!client) throw new Error(`no connected client for "${upstreamId}"`);
1148
- const tools = await client.listTools();
1149
- const safe = Array.isArray(tools) ? tools.filter((t) => t && isNonEmptyString(t.name)) : [];
1150
- this._tools.set(upstreamId, safe);
1151
- return cloneTools(safe);
1152
- }
1153
-
1154
- /** Tear down + forget a cached client (used on a failed connect). Never throws. */
1155
- _discard(upstreamId) {
1156
- const client = this._clients.get(upstreamId);
1157
- if (client && typeof client.close === 'function') {
1158
- try { client.close(); } catch (_e) { /* never throw */ }
1159
- }
1160
- this._clients.delete(upstreamId);
1161
- this._tools.delete(upstreamId);
1162
- }
1163
-
1164
- /**
1165
- * 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.
1168
- * @param {string} name
1169
- * @returns {{ client:object, realTool:string, upstream:string, exposedName:string } | null}
1170
- */
1171
- _findExposedExecution(name) {
1172
- if (!isNonEmptyString(name)) return null;
1173
- let exposed;
1174
- try {
1175
- exposed = this._store.listExposed({ enabledOnly: true });
1176
- } catch (_err) {
1177
- return null;
1178
- }
1179
- for (const e of exposed) {
1180
- if (!e) continue;
1181
- if (this._store.exposedName(e) !== name) continue;
1182
- const client = this._clients.get(e.upstream);
1183
- if (!client) continue; // upstream not connected
1184
- const upstreamTools = this._tools.get(e.upstream) || [];
1185
- if (!upstreamTools.some((t) => t && t.name === e.tool)) continue; // tool not advertised
1186
- return { client, realTool: e.tool, upstream: e.upstream, exposedName: name };
1187
- }
1188
- return null;
1189
- }
1190
- }
1191
-
1192
- // ── small helpers (no external deps) ───────────────────────────────────────────
1193
-
1194
- /** Extract a clean message from any thrown value. */
1195
- function errMsg(err) {
1196
- return (err && err.message) || String(err);
1197
- }
1198
-
1199
- /** JSON.stringify that never throws (circular/oddball values fall back to String). */
1200
- function safeStringify(value) {
1201
- try { return JSON.stringify(value); } catch (_e) { return String(value); }
1202
- }
1203
-
1204
- /**
1205
- * Turn an upstream MCP tools/call envelope { content:[...], isError? } into a clean payload for the
1206
- * LEAN run path. A single text block -> its string (so toolfunnel_run_tool returns "pong", matching
1207
- * what the curated-direct content passthrough surfaces); otherwise the content array; otherwise the
1208
- * value as-is when it isn't an envelope. This is what avoids double-wrapping the envelope as JSON
1209
- * inside wrapProtocolResult (the curated-direct path passes the envelope through transparently
1210
- * 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
1212
- * with how local tools surface error text) - mapping isError -> ok:false is a later concern.
1213
- * @param {*} envelope
1214
- * @returns {*}
1215
- */
1216
- function unwrapEnvelope(envelope) {
1217
- if (!envelope || typeof envelope !== 'object') return envelope;
1218
- const content = envelope.content;
1219
- if (Array.isArray(content)) {
1220
- if (content.length === 1 && content[0] && content[0].type === 'text' && typeof content[0].text === 'string') {
1221
- return content[0].text;
1222
- }
1223
- return content;
1224
- }
1225
- return envelope;
1226
- }
1227
-
1228
- /** Plain-JSON deep clone (schemas are JSON; structuredClone may be absent on old node). */
1229
- function cloneJson(value) {
1230
- return JSON.parse(JSON.stringify(value));
1231
- }
1232
-
1233
- /** Clone an array of tool defs so callers can't mutate the aggregator's cache. */
1234
- function cloneTools(tools) {
1235
- return Array.isArray(tools) ? tools.map((t) => cloneJson(t)) : [];
1236
- }
1237
-
1238
- module.exports = {
1239
- Aggregator,
1240
- // exported for unit tests / reuse
1241
- defaultClientFactory,
1242
- looksLikePath,
1243
- isInside,
1244
- unwrapEnvelope,
1245
- DEFAULT_ROOT,
1246
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * aggregator.js - the upstream-MCP connection + curated-expose layer.
5
+ *
6
+ * The aggregator is the bridge between the persisted MCP config (src/mcp/expose-store.js)
7
+ * and the live upstream MCP servers (src/mcp/mcp-client.js). It:
8
+ *
9
+ * 1. CONNECTS one McpClient per ENABLED upstream in the ExposeStore (connectAll),
10
+ * caching the connected client + its discovered tools. Upstreams connect CONCURRENTLY,
11
+ * so startup costs the SLOWEST upstream rather than the SUM of all of them. A failed
12
+ * upstream never sinks the others - failures are collected, not thrown, and each one
13
+ * arms the same background reconnect a mid-session death gets.
14
+ * 2. DISCOVERS an upstream's tool surface on demand (discover) - what the MCP
15
+ * Manager UI's 'discover' button calls.
16
+ * 3. Computes the CURATED-DIRECT tool definitions to advertise downstream
17
+ * (exposedToolDefinitions) - only for ENABLED expose[] entries whose upstream
18
+ * is connected, enabled, AND actually advertises the named tool. Each definition is
19
+ * renamed to its downstream `as` name (what the CLI / PreToolUse matchers see).
20
+ * 4. RESOLVES a downstream call back to its upstream client + real tool name
21
+ * (resolveExposedExecution) - the seam the MCP server's curated-direct call
22
+ * path uses to actually execute, AFTER the hook gate has run.
23
+ *
24
+ * Decoupling note: downstream the CLI sees the renamed `as` tool; upstream we call
25
+ * the REAL tool name. resolveExposedExecution carries BOTH so the gate (PreToolUse)
26
+ * matches on the downstream name while execution hits the real upstream.
27
+ *
28
+ * SAFETY CONTRACT (mirrors expose-store.js / mcp-client.js):
29
+ * - connectAll / discover / closeAll NEVER throw. connectAll collects per-upstream
30
+ * failures into failed[]; a dead/misconfigured upstream cannot crash the server
31
+ * start. closeAll is idempotent and tears down every cached client.
32
+ * - The DEFAULT clientFactory enforces the HARD ISOLATION rule: a vendored upstream
33
+ * may only reference CODE/FILES inside the gateway root. The
34
+ * guard is applied to the ARGS (the script path, db files, ... that the interpreter
35
+ * is pointed at): any arg that is an absolute path, or that looks like a file path
36
+ * (contains a path separator), MUST resolve inside gatewayRoot - otherwise the factory
37
+ * throws and connectAll records the upstream in failed[]. The COMMAND slot is the
38
+ * interpreter/executable (node, npx, or an absolute path to a node binary that by
39
+ * definition lives OUTSIDE the project, e.g. process.execPath / a system node.exe)
40
+ * and is therefore NOT subject to the in-sandbox requirement - bare commands AND an
41
+ * absolute interpreter path are both allowed. Isolation is about WHAT CODE runs in
42
+ * the spawned process, not which interpreter binary runs it; the args are that code.
43
+ * This is what stops a config from pointing at a live `.mcp.json` server's script
44
+ * outside the sandbox while still letting the system node interpreter spawn it.
45
+ *
46
+ * CommonJS only. Node built-ins only. No new npm dependency. No @modelcontextprotocol/sdk.
47
+ */
48
+
49
+ const path = require('node:path');
50
+
51
+ const { McpClient, PROTOCOL_VERSION: LEGACY_UPSTREAM_VERSION } = require('./mcp-client');
52
+ const logger = require('../core/logger');
53
+
54
+ // The sandbox root = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package
55
+ // root - see src/core/config-home.js). Used as the isolation boundary the default clientFactory
56
+ // enforces: a pack's path-shaped upstream args live under the home, so the home is the boundary.
57
+ // A caller may override via opts.gatewayRoot for tests.
58
+ const { resolveConfigHome } = require('../core/config-home');
59
+ const DEFAULT_ROOT = resolveConfigHome();
60
+
61
+ /** True for a non-empty string. */
62
+ function isNonEmptyString(v) {
63
+ return typeof v === 'string' && v.length > 0;
64
+ }
65
+
66
+ /**
67
+ * Does this argv token LOOK like a file path? An absolute path (path.isAbsolute) or
68
+ * any token containing a path separator ('/' or '\\') is treated as a path reference
69
+ * and must be confined to gatewayRoot. A bare token like 'node', 'npx', 'server' or a flag
70
+ * like '--port' is NOT a path and is always allowed.
71
+ * @param {string} arg
72
+ * @returns {boolean}
73
+ */
74
+ function looksLikePath(arg) {
75
+ if (!isNonEmptyString(arg)) return false;
76
+ if (path.isAbsolute(arg)) return true;
77
+ // win32 DRIVE-RELATIVE form ('C:foo.js') - path.win32.isAbsolute() is false for it and it holds
78
+ // no separator, so without this it looked like a bare token and skipped the guard entirely.
79
+ if (/^[A-Za-z]:/.test(arg)) return true;
80
+ return arg.indexOf('/') !== -1 || arg.indexOf('\\') !== -1;
81
+ }
82
+
83
+ /**
84
+ * The path-shaped candidates inside a single argv token. A runtime option in `--flag=value` form
85
+ * (node's --require=, --import=, --experimental-loader=, and their Deno/Bun kin) interprets only
86
+ * the part AFTER the first '=' as a path. The whole token '--require=../x.js' carries no '..'
87
+ * segment of its own, so a check of the token alone judges it inside the root while the child
88
+ * resolves the value and leaves it. So the value after the first '=' is returned as its own
89
+ * candidate, checked alongside the whole token. A non-path value ('--port=8080') simply fails
90
+ * looksLikePath() and is ignored; an inside value still resolves inside and passes.
91
+ * @param {string} arg
92
+ * @returns {string[]}
93
+ */
94
+ function argPathCandidates(arg) {
95
+ if (!isNonEmptyString(arg)) return [];
96
+ const out = [arg];
97
+ const eq = arg.indexOf('=');
98
+ if (eq !== -1 && eq < arg.length - 1) out.push(arg.slice(eq + 1));
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Is this a win32 DRIVE-RELATIVE reference ('C:foo', drive letter + colon + no separator)?
104
+ * It resolves against the CHILD's per-drive current directory - a base the guard cannot pin -
105
+ * so it can never be PROVEN inside the root: path.resolve(root, 'C:x') judges it optimistically
106
+ * when root shares the drive. There is no legitimate in-sandbox use (an inside path can always
107
+ * be written absolutely or plainly relative), so it is refused outright wherever paths are
108
+ * guarded. Checked on every platform: a config is portable and gets run somewhere else.
109
+ * @param {string} t
110
+ * @returns {boolean}
111
+ */
112
+ function isDriveRelative(t) {
113
+ return /^[A-Za-z]:(?![\\/])/.test(t);
114
+ }
115
+
116
+ // Environment variables that make a child load code, whatever its args say (0.7.0). Two classes with two rules. All platforms' vars are checked on all platforms because
117
+ // a config is portable and gets run somewhere else, and keys are compared CASE-INSENSITIVELY
118
+ // everywhere because Windows env lookup is case-insensitive - `node_options` reaches the child
119
+ // exactly as `NODE_OPTIONS` does, so an exact-case allowlist was a one-character bypass there.
120
+ //
121
+ // OPTION-STRING vars are a mini command line in the runtime's OWN grammar: quoting, `=` forms,
122
+ // `file:`/`data:` URLs, inspector flags. A path-shaped guard cannot verify them (quoted
123
+ // values, URL specifiers and a bare `--inspect` all defeat any tokeniser written here),
124
+ // and a guard that cannot verify must refuse, not guess. Refused outright in funnel mode; the
125
+ // operator's deliberate opt-out is `allowCodeLoadingEnv: true` on the upstream entry (the
126
+ // legacyPin pattern - off by default, visible in config, warned when it excuses something).
127
+ const CODE_OPT_ENV = [
128
+ 'NODE_OPTIONS',
129
+ 'JAVA_TOOL_OPTIONS',
130
+ '_JAVA_OPTIONS',
131
+ 'PERL5OPT',
132
+ 'RUBYOPT',
133
+ ];
134
+ // PATH / PATH-LIST vars name locations directly and ARE checkable: extract pieces, judge each
135
+ // against the root. This list is a FLOOR, not a boundary - runtimes keep minting code-loading
136
+ // vars, and the `command` slot is arbitrary by design (see SECURITY.md: the guard is a tripwire
137
+ // for out-of-root references in an auditable config home, not a sandbox against an untrusted
138
+ // pack author).
139
+ const CODE_PATH_ENV = [
140
+ 'NODE_PATH',
141
+ 'NODE_REPL_EXTERNAL_MODULE',
142
+ 'LD_PRELOAD',
143
+ 'LD_LIBRARY_PATH',
144
+ 'LD_AUDIT',
145
+ 'DYLD_INSERT_LIBRARIES',
146
+ 'DYLD_LIBRARY_PATH',
147
+ 'DYLD_FRAMEWORK_PATH',
148
+ 'PYTHONPATH',
149
+ 'PYTHONSTARTUP',
150
+ 'PYTHONHOME',
151
+ 'PYTHONEXECUTABLE',
152
+ 'PERL5LIB',
153
+ 'RUBYLIB',
154
+ 'GEM_PATH',
155
+ 'CLASSPATH',
156
+ 'BASH_ENV',
157
+ 'ENV',
158
+ 'DOTNET_STARTUP_HOOKS',
159
+ 'CORECLR_PROFILER_PATH',
160
+ ];
161
+
162
+ /**
163
+ * Is `target` inside `root` (or equal to it)? Compares fully-resolved, normalised
164
+ * absolute paths. A relative `target` is resolved AGAINST root (so 'vendor/x/server.js'
165
+ * checks the in-sandbox location). Returns false for anything that escapes root via
166
+ * '..' or an absolute path elsewhere on disk.
167
+ * @param {string} root absolute sandbox root
168
+ * @param {string} target path token (absolute or relative)
169
+ * @returns {boolean}
170
+ */
171
+ function isInside(root, target) {
172
+ const resolvedRoot = path.resolve(root);
173
+ // An absolute target is checked as-is; a relative one is resolved within the root so
174
+ // a vendored relative path ('vendor/foo/server.js') is judged at its in-sandbox spot.
175
+ const resolvedTarget = path.isAbsolute(target)
176
+ ? path.resolve(target)
177
+ : path.resolve(resolvedRoot, target);
178
+ const rel = path.relative(resolvedRoot, resolvedTarget);
179
+ // Inside iff the relative path neither starts with '..' nor is itself absolute
180
+ // (an absolute `rel` happens on win32 across drive letters). '' (equal) counts as inside.
181
+ return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
182
+ }
183
+
184
+ /**
185
+ * The DEFAULT clientFactory. Constructs a McpClient for an upstream entry AFTER
186
+ * enforcing the isolation guard on the ARGS: every arg that looks like a file path
187
+ * (an absolute path, or any token containing a path separator) must resolve inside
188
+ * `gatewayRoot`. Throws a clear 'isolation: ...' error otherwise - connectAll catches that
189
+ * into failed[].
190
+ *
191
+ * The COMMAND slot is intentionally NOT guarded: it names the interpreter/executable
192
+ * (e.g. 'node', 'npx', or an absolute path to a node binary such as process.execPath /
193
+ * a system node.exe) which by definition lives OUTSIDE the project. Isolation is about
194
+ * WHAT CODE the spawned process runs - that is the args (the server script + any data
195
+ * files it loads) - not which interpreter binary launches it. Guarding the command too
196
+ * would wrongly reject every vendored upstream the moment it is run by the system node.
197
+ *
198
+ * @param {object} upstream a normalised ExposeStore upstream { id, command, args, env, ... }
199
+ * @param {string} gatewayRoot the sandbox root the upstream's path-shaped ARGS must stay inside
200
+ * @returns {McpClient}
201
+ */
202
+ function defaultClientFactory(upstream, gatewayRoot, onClose, allowOutsidePaths, clientInfo, eraHint) {
203
+ const id = isNonEmptyString(upstream && upstream.id) ? upstream.id : '(unknown)';
204
+ const args = Array.isArray(upstream && upstream.args) ? upstream.args : [];
205
+ // The caller passes TRUE for the wrapped upstream; the upstream may also claim the exemption for
206
+ // itself in config. Either way it is decided HERE, per upstream, from THIS upstream's own entry -
207
+ // there is no shared or latched state, so one server's opt-out can never relax another's guard.
208
+ const outsideAllowed = allowOutsidePaths === true || (upstream && upstream.allowOutsidePaths === true);
209
+ const byConfig = allowOutsidePaths !== true && outsideAllowed;
210
+
211
+ // Guard every field that decides WHAT CODE the spawned process runs: path-shaped args, the cwd
212
+ // they resolve against, and the env vars that load code irrespective of args. The command
213
+ // (interpreter) is NOT guarded - see the function doc: it is the executable, not the code.
214
+ // allowOutsidePaths: TRUE only for the currently-WRAPPED upstream (transparent-wrapper mode -
215
+ // the operator explicitly made this server the entire surface, e.g. server-filesystem serving
216
+ // a documents folder). Warn instead of refuse; funnel-mode upstreams keep the hard guard.
217
+ // ONE notice per CLASS, not per upstream: `warnedPath` covers the path class (args + cwd); the
218
+ // env section below keeps its OWN flag. A single shared flag let the first path finding consume
219
+ // the only slot, so an env finding - the class that loads code - was excused in total silence,
220
+ // precisely for the operator who opted out and most needs the audit trail.
221
+ let warnedPath = false;
222
+ const refuse = (what) => {
223
+ if (outsideAllowed === true) {
224
+ if (!warnedPath) {
225
+ warnedPath = true;
226
+ // Name the REAL reason. Reporting a config opt-out as transparent-wrapper mode would put a
227
+ // false explanation in the operator's log for the one event they most need to trace.
228
+ process.stderr.write(byConfig
229
+ ? `[toolfunnel] isolation: upstream "${id}" reaches outside the gateway root (${what}) - ` +
230
+ `permitted because its config sets allowOutsidePaths:true. This upstream is NOT isolated ` +
231
+ `to the config home; every tool it serves runs with that exemption. Remove the flag to ` +
232
+ `restore the guard, or gate the tools with a PreToolUse hook.\n`
233
+ : `[toolfunnel] isolation: WRAPPED upstream "${id}" reaches outside the gateway root ` +
234
+ `(${what}) - permitted in transparent-wrapper mode. To restrict what it can do post-wrap, see the ` +
235
+ `manual's "Wrapping & security" section (PreToolUse gate, per-tool enabled:false).\n`);
236
+ }
237
+ return false; // permitted under the wrap, or by this upstream's own opt-out
238
+ }
239
+ throw new Error(`isolation: upstream ${id} references a path outside the gateway root`);
240
+ };
241
+
242
+ for (const arg of args) {
243
+ // Check the whole token AND the value after a '=' (the --flag=value form the child splits) -
244
+ // either escaping the root refuses the arg. The notice still names the whole token.
245
+ const escapes = argPathCandidates(arg).some(
246
+ (cand) => looksLikePath(cand) && (isDriveRelative(cand) || !isInside(gatewayRoot, cand))
247
+ );
248
+ if (escapes) {
249
+ if (refuse(`arg "${arg}"`) === false) break; // one notice covers the path class
250
+ }
251
+ }
252
+
253
+ // cwd - the SPAWN BASE (0.7.0). Guarded because it decides where a bare, separator-free arg like
254
+ // 'server.js' actually resolves: looksLikePath() is false for such a token so the loop above
255
+ // never sees it, while the child resolves it against cwd. Until 0.7.0 the comment below claimed
256
+ // guard-base and spawn-base "agree by construction" - true only while cwd was unset.
257
+ // Drive-relative gets the same outright refusal as everywhere else: 'C:denied' resolves
258
+ // against the child's per-drive current directory, and isInside() judging it against the root
259
+ // is optimistic-and-wrong whenever the root shares the drive.
260
+ if (isNonEmptyString(upstream && upstream.cwd)
261
+ && (isDriveRelative(upstream.cwd) || !isInside(gatewayRoot, upstream.cwd))) {
262
+ refuse(`cwd "${upstream.cwd}"`);
263
+ }
264
+
265
+ // env - CODE-LOADING variables (0.7.0). A value in any of these loads code
266
+ // into the child however clean the args are (reachable in-band: tf_mcp_add forwards env
267
+ // verbatim). Keys are matched CASE-INSENSITIVELY on every platform - Windows env lookup is
268
+ // case-insensitive, so `node_options` is `NODE_OPTIONS` to the child - and every platform's
269
+ // variable is checked on every platform: a pack authored on Linux gets run on Windows and back.
270
+ //
271
+ // `allowCodeLoadingEnv: true` on the upstream entry is the operator's explicit opt-out for the
272
+ // whole class: the finding downgrades to a once-per-upstream stderr warning naming the flag.
273
+ const envObj = upstream && typeof upstream.env === 'object' && upstream.env ? upstream.env : null;
274
+ if (envObj) {
275
+ const envAllowed = (upstream && upstream.allowCodeLoadingEnv === true);
276
+ // The env class's OWN notice flag - deliberately independent of `warnedPath` (see above).
277
+ let warnedEnv = false;
278
+ const envNotice = (why) => {
279
+ if (warnedEnv) return;
280
+ warnedEnv = true;
281
+ process.stderr.write(`[toolfunnel] isolation: upstream "${id}" sets code-loading env ${why}`);
282
+ };
283
+ // Which flag excuses an env finding depends on its KIND:
284
+ // 'opt' - option-string vars (NODE_OPTIONS et al.): not path-checkable, so ONLY the class's
285
+ // own flag (allowCodeLoadingEnv) or the WRAP excuses them. allowOutsidePaths does
286
+ // NOT: it is documented (expose-store) as args/cwd/path-list scope, and letting the
287
+ // path flag silently disarm this separately-audited class was a cross-flag grant
288
+ // the operator never made.
289
+ // 'path' - path-list vars (NODE_PATH et al.): inside the path flag's documented scope, so
290
+ // wrap, allowCodeLoadingEnv or allowOutsidePaths excuses - each loudly, by name.
291
+ const refuseEnv = (what, kind) => {
292
+ if (envAllowed) {
293
+ envNotice(`(${what}) - permitted by allowCodeLoadingEnv:true on this upstream. Remove the ` +
294
+ 'flag to restore the refusal.\n');
295
+ return false;
296
+ }
297
+ if (allowOutsidePaths === true) {
298
+ envNotice(`(${what}) - permitted in transparent-wrapper mode (this upstream IS the ` +
299
+ 'surface). To restrict it post-wrap, see the manual\'s "Wrapping & security" section.\n');
300
+ return false;
301
+ }
302
+ if (kind === 'path' && outsideAllowed === true) {
303
+ envNotice(`(${what}) - permitted because its config sets allowOutsidePaths:true ` +
304
+ '(path-list env is within that flag\'s scope). Remove the flag to restore the guard.\n');
305
+ return false;
306
+ }
307
+ throw new Error(`isolation: upstream ${id} sets code-loading env (${what})`);
308
+ };
309
+ // Enumerate EXACTLY as child_process does: `for...in`, which walks own AND INHERITED keys
310
+ // (node's normalizeSpawnArguments: "Prototype values are intentionally included"). An
311
+ // Object.keys() walk was blind to a `__proto__` carrier - JSON.parse makes `__proto__` an OWN
312
+ // data property, spread preserves it, and the client's Object.assign merge then uses [[Set]],
313
+ // firing the Object.prototype.__proto__ setter and installing the payload as the merged env's
314
+ // PROTOTYPE: invisible to Object.keys, delivered to the child.
315
+ for (const key in envObj) {
316
+ const upper = String(key).toUpperCase();
317
+ // A `__proto__` key is refused OUTRIGHT, whatever it carries. It is not an env var: it is
318
+ // the carrier that turns into the merged env's PROTOTYPE at the client's Object.assign, and
319
+ // from there into real variables for the child (for..in). Nothing legitimate needs it.
320
+ if (key === '__proto__') {
321
+ refuseEnv('env "__proto__" (a prototype carrier, not an environment variable)', 'opt');
322
+ continue;
323
+ }
324
+ // Judge the value as the spawn will USE it, not as it was authored. node builds each pair as
325
+ // `${key}=${value}`, so a one-element array becomes its element verbatim and a number becomes
326
+ // its digits. Testing isNonEmptyString FIRST let every non-string value skip both lists -
327
+ // again the guard reading a different representation than the consumer. A value that cannot
328
+ // even be coerced is unverifiable in a code-loading slot, so it is refused rather than
329
+ // skipped (the same rule the option-string class already follows).
330
+ const rawValue = envObj[key];
331
+ if (rawValue === undefined || rawValue === null) continue;
332
+ let raw;
333
+ try {
334
+ raw = typeof rawValue === 'string' ? rawValue : String(rawValue);
335
+ } catch (_e) {
336
+ if (CODE_OPT_ENV.includes(upper) || CODE_PATH_ENV.includes(upper)) {
337
+ refuseEnv(`env ${key} (value cannot be read as a string, so it cannot be verified)`,
338
+ CODE_OPT_ENV.includes(upper) ? 'opt' : 'path');
339
+ }
340
+ continue;
341
+ }
342
+ if (raw.length === 0) continue;
343
+ if (CODE_OPT_ENV.includes(upper)) {
344
+ // Not path-checkable (see the class doc above the lists) - the VALUE is irrelevant.
345
+ if (refuseEnv(`env ${key} (option-string vars are not path-checkable)`, 'opt') === false) continue;
346
+ continue;
347
+ }
348
+ if (!CODE_PATH_ENV.includes(upper)) continue;
349
+ // Path / path-list value: pieces are EXTRACTED (not split) so a drive-lettered piece
350
+ // ('C:\x', 'C:x') survives whole on every platform - splitting 'C:relative\denied' on ':'
351
+ // yields fragments that resolve inside the root while the child resolves the real path
352
+ // outside it. Both ';' and ':' end a piece everywhere (a POSIX filename containing ';'
353
+ // may be shredded and falsely refused - conservative, never a false allow).
354
+ const parts = (raw.match(/[A-Za-z]:[^;:]*|[^;:]+/g) || []).filter((t) => t.length > 0);
355
+ if (parts.length === 0) continue;
356
+ if (parts.some((t) => isDriveRelative(t) || !isInside(gatewayRoot, t))) {
357
+ refuseEnv(`env ${key}="${raw}"`, 'path');
358
+ }
359
+ }
360
+ }
361
+
362
+ return new McpClient({
363
+ id: upstream.id,
364
+ command: upstream.command,
365
+ args: upstream.args,
366
+ env: upstream.env,
367
+ // Child cwd from config - the wrap era-probe reads it too; it never survived the store
368
+ // before. Default = the CONFIG HOME (gatewayRoot), NOT the gateway's process cwd:
369
+ // the isolation guard above resolves relative args against the home, so the spawn must
370
+ // resolve them against the SAME base or a guard-passing relative path fails to spawn
371
+ // whenever --config-dir differs from the launch directory (guard-base and spawn-base agree
372
+ // by construction).
373
+ cwd: isNonEmptyString(upstream && upstream.cwd) ? upstream.cwd : gatewayRoot,
374
+ // Per-upstream `timeoutMs` config -> the client's PAYLOAD window (tools/call | prompts/get |
375
+ // resources/read; default 120 s).
376
+ toolTimeoutMs: Number.isFinite(upstream && upstream.timeoutMs) && upstream.timeoutMs > 0
377
+ ? upstream.timeoutMs : undefined,
378
+ // Per-upstream `requestTimeoutMs` config -> the CONTROL window (handshake/list/discover/
379
+ // ping; default 10 s = the dead-upstream detector). Opt-in for slow-boot servers that
380
+ // cannot answer initialize inside 10 s - without it they can never be attached at all.
381
+ requestTimeoutMs: Number.isFinite(upstream && upstream.requestTimeoutMs) && upstream.requestTimeoutMs > 0
382
+ ? upstream.requestTimeoutMs : undefined,
383
+ // legacyPin FORCES the legacy era: skip the modern server/discover probe and go straight to
384
+ // the initialize handshake, so a pinned upstream is genuinely spoken to as 2024-11-05 (the pin
385
+ // is an ENFORCED policy, not just a warning). Off by default.
386
+ forceLegacy: !!(upstream && upstream.legacyPin === true),
387
+ // modernOnly (legacyPin's mirror): refuse the legacy fallback - a non-modern upstream fails
388
+ // the connect with a clear error instead of negotiating down. Store validation refuses the
389
+ // legacyPin+modernOnly contradiction before it ever reaches here.
390
+ modernOnly: !!(upstream && upstream.modernOnly === true),
391
+ // The aggregator's era memo (last successful connect): 'legacy' lets a reconnect skip the
392
+ // server/discover probe. The client ignores it under modernOnly; the aggregator clears it on
393
+ // a failed connect and on an explicit reconnect().
394
+ eraHint: eraHint === 'legacy' ? 'legacy' : undefined,
395
+ // clientInfo: the identity presented to this upstream (identity mirroring / configured
396
+ // identity). null/undefined -> McpClient's built-in default, wire behaviour unchanged.
397
+ clientInfo: clientInfo || undefined,
398
+ onClose, // unexpected death -> the aggregator schedules a background reconnect
399
+ });
400
+ }
401
+
402
+ /**
403
+ * Aggregator - owns the live upstream connections and the curated-expose computation.
404
+ *
405
+ * Lifecycle: `new Aggregator({ store, gatewayRoot, clientFactory })`
406
+ * -> `await connectAll()` (or `await discover(id)` lazily)
407
+ * -> `exposedToolDefinitions()` / `resolveExposedExecution(name, args)`
408
+ * -> `await closeAll()` (idempotent).
409
+ */
410
+ class Aggregator {
411
+ /**
412
+ * @param {object} opts
413
+ * @param {object} opts.store an ExposeStore (the on-disk MCP config).
414
+ * @param {string} [opts.gatewayRoot] sandbox root for the isolation guard (default: the gateway root).
415
+ * @param {Function} [opts.clientFactory] (upstreamEntry, gatewayRoot) -> McpClient. Default enforces isolation.
416
+ * @param {Function} [opts.wrapTargetProvider] () -> the currently-WRAPPED upstream id (or null).
417
+ * Read fresh at each connect: the wrapped upstream is EXEMPT from the path-isolation
418
+ * guard (by design - a wrap is an explicit "this server IS my whole
419
+ * surface" declaration, so ToolFunnel is a transparent wrapper in that incidence; the
420
+ * operator gets a strong warning + post-wrap restriction docs instead of a refusal).
421
+ * Funnel-mode upstreams keep the guard unchanged.
422
+ * @param {Function} [opts.clientInfoProvider] (upstreamId) -> {name, version}|null. The identity
423
+ * to PRESENT to that upstream. Read fresh at each connect (same pattern as the wrap-target
424
+ * provider) so a mirror captured after boot is honoured by the next (re)connect. null ->
425
+ * McpClient's built-in default.
426
+ */
427
+ constructor({ store, gatewayRoot, clientFactory, onToolsChanged, wrapTargetProvider, clientInfoProvider } = {}) {
428
+ if (!store || typeof store !== 'object') {
429
+ throw new Error('Aggregator: a `store` (ExposeStore) is required');
430
+ }
431
+ this._store = store;
432
+ this._gatewayRoot = isNonEmptyString(gatewayRoot) ? gatewayRoot : DEFAULT_ROOT;
433
+ // Called when the LIVE tool set changes OUT OF BAND (a background reconnect recovers or finally
434
+ // loses an upstream) so the transport can emit notifications/tools/list_changed. Default no-op;
435
+ // the server wires it to emitToolsListChanged(send). Settable so reloadExpose can re-wire the
436
+ // new instance.
437
+ this.onToolsChanged = typeof onToolsChanged === 'function' ? onToolsChanged : () => {};
438
+ // Called with (upstreamId, notification) for each server-initiated CHANGE notification from an
439
+ // upstream (resources/updated, resources/list_changed, prompts/list_changed, tools/list_changed)
440
+ // so the transport can bridge it to a modern client's subscriptions/listen stream. Default no-op;
441
+ // the server/http host wires it. Settable so reloadExpose can re-wire the new instance.
442
+ this.onUpstreamNotification = () => {};
443
+ // Called with (upstreamId, requestMsg, client) for each SERVER-INITIATED REQUEST from an
444
+ // upstream (elicitation/create, sampling/createMessage, roots/list - the elicitation bridge). The handler
445
+ // owns answering via client.respondToServer(Error). Default: -32601, so an unwired build
446
+ // never leaves an upstream holding an open request. Settable; reloadExpose carries it over.
447
+ this.onUpstreamServerRequest = (uid, msg, client) => {
448
+ try { client.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method); } catch (_e) { /* never throw */ }
449
+ };
450
+ // When a passthrough WRAP is active, the wrapped upstream's request-scoped chatter
451
+ // (notifications/message, notifications/progress) also bridges - connected directly, the
452
+ // client would receive it. The server keeps this current with the live wrap state.
453
+ this.wrapChatterUpstream = null;
454
+ this._wrapTargetProvider = typeof wrapTargetProvider === 'function' ? wrapTargetProvider : null;
455
+ this._clientInfoProvider = typeof clientInfoProvider === 'function' ? clientInfoProvider : null;
456
+ // The factory receives (upstream, gatewayRoot, onClose). A caller-supplied factory may ignore the
457
+ // extra args; the default uses gatewayRoot for isolation and onClose for death-driven reconnect.
458
+ // The wrap-target check runs PER CONNECT (fresh provider read), so a wrap set after boot is
459
+ // honoured by the next connect attempt.
460
+ this._clientFactory =
461
+ typeof clientFactory === 'function'
462
+ ? (upstream, onClose) => clientFactory(upstream, this._gatewayRoot, onClose)
463
+ : (upstream, onClose) => defaultClientFactory(upstream, this._gatewayRoot, onClose,
464
+ this._isWrapTarget(upstream && upstream.id),
465
+ this._readClientInfo(upstream && upstream.id),
466
+ this._eraMemo.get(upstream && upstream.id));
467
+
468
+ /** @type {Map<string, object>} upstreamId -> connected McpClient */
469
+ this._clients = new Map();
470
+ /** @type {Map<string, Array>} upstreamId -> discovered tool defs [{name,description,inputSchema}] */
471
+ this._tools = new Map();
472
+ /** @type {Map<string, Promise>} upstreamId -> in-flight connect promise (race guard for ensureConnected) */
473
+ this._connecting = new Map();
474
+ /** @type {Set<string>} agreed resource-subscription URIs - replayed onto every (re)connect
475
+ * of a subscribe-capable legacy upstream so the channel survives reconnects */
476
+ this._subscribedUris = new Set();
477
+ /** @type {Map<string, number>} uri -> LISTEN-owned refcount. subscribeResources
478
+ * increments; releaseResources decrements and unsubscribes upstream at zero. The wrap-forward
479
+ * recording (a client's own resources/subscribe) writes the Set directly and is NOT counted
480
+ * here - the client manages that lifetime itself. */
481
+ this._subscribedRefs = new Map();
482
+ /** @type {Map<string, any>} upstreamId -> pending background-reconnect timer (one per upstream) */
483
+ this._reconnectTimers = new Map();
484
+ /** @type {Set<string>} upstreams whose reconnect ATTEMPT is currently executing (between the timer
485
+ * firing - which deletes its own entry from _reconnectTimers - and the attempt settling). While an
486
+ * id is in here, _handleUpstreamDown must NOT seed a fresh attempt-0: the in-flight attempt's own
487
+ * catch owns the next backoff step. Without this, a death DURING an attempt's tools/list re-seeds
488
+ * the 1s floor and the one-timer guard then swallows the escalation - a permanent 1s respawn loop. */
489
+ this._reconnecting = new Set();
490
+ /** @type {Map<string, 'modern'|'legacy'>} upstreamId -> era negotiated at the LAST successful
491
+ * connect. A death-driven reconnect passes it to the client as eraHint so a legacy upstream
492
+ * skips the server/discover probe (worst case ~3s against a server that silently drops unknown
493
+ * methods) instead of re-paying it on every respawn. Lifecycle: SET on a successful connect,
494
+ * CLEARED when a connect fails (the hinted attempt may be why - the next retry re-negotiates
495
+ * in full, so an upstream upgraded across a restart self-heals) and by an explicit reconnect()
496
+ * (a manual kick deliberately re-negotiates). A config reload swaps in a fresh Aggregator, so
497
+ * a changed entry never inherits a stale memo. */
498
+ this._eraMemo = new Map();
499
+ /** Set true by closeAll(). A torn-down aggregator (e.g. swapped out by reloadExpose) must NEVER
500
+ * (re)connect - a captured execute thunk holding this instance would otherwise spawn an orphan
501
+ * upstream child on it that nothing ever closes. ensureConnected/connectAll check this. */
502
+ this._closed = false;
503
+ }
504
+
505
+ /**
506
+ * Connect every ENABLED upstream in the store, caching each connected client and its
507
+ * discovered tools. NEVER throws - a per-upstream failure (isolation guard, spawn
508
+ * failure, handshake timeout, listTools failure) is collected into failed[]. An
509
+ * upstream already connected (cached) is treated as connected without reconnecting.
510
+ *
511
+ * BEHAVIOUR CHANGE (concurrent connect): a startup failure is no longer a one-off failed[]
512
+ * entry. Every failed upstream is handed to _scheduleReconnect, so a PERMANENT failure (bad
513
+ * command, isolation refusal) now retries in the background for the whole session -
514
+ * exponential 1s..16s, then the 30s keepalive indefinitely. That is the same treatment a
515
+ * mid-session death already got; reconnect_slow is logged once when the slow phase begins,
516
+ * and failed[] still reports the startup outcome to the caller as before.
517
+ *
518
+ * @returns {Promise<{ connected: string[], failed: Array<{id:string, error:string}> }>}
519
+ */
520
+ async connectAll() {
521
+ const connected = [];
522
+ const failed = [];
523
+ if (this._closed) return { connected, failed }; // a closed (torn-down) aggregator never connects
524
+
525
+ let upstreams;
526
+ try {
527
+ upstreams = this._store.listUpstreams();
528
+ } catch (err) {
529
+ // listUpstreams() shouldn't throw (it clones), but never let it sink connectAll.
530
+ return { connected, failed: [{ id: '(store)', error: errMsg(err) }] };
531
+ }
532
+
533
+ // CONCURRENT connect. Per-upstream cost is dominated by process boot, remote round-trips and
534
+ // the dual-era probe window, so connecting SEQUENTIALLY made startup the SUM of every
535
+ // upstream's latency; started together it is the SLOWEST one instead. Upstreams are
536
+ // independent: _connectOne touches only this upstream's own entry in _clients/_tools/_eraMemo,
537
+ // and the store rejects duplicate ids at load, so there is no cross-upstream state to race on.
538
+ // Each promise carries its own cleanup, and allSettled means ONE upstream's failure can never
539
+ // reject the batch - the NEVER-THROWS contract above still holds.
540
+ const tasks = [];
541
+ for (const upstream of upstreams) {
542
+ if (!upstream || upstream.enabled !== true) continue; // only ENABLED upstreams
543
+
544
+ const id = upstream.id;
545
+ if (this._clients.has(id)) {
546
+ // Already connected this session - keep it; don't double-spawn.
547
+ tasks.push({ id, cached: true, promise: null });
548
+ continue;
549
+ }
550
+
551
+ // Discard INSIDE the catch (not in the result loop below) so a half-built client is reaped
552
+ // the moment its connect fails, instead of lingering for the rest of the batch. The rethrow
553
+ // hands the original error to allSettled; a throwing _discard must never mask it.
554
+ //
555
+ // These startup promises are NOT registered in _connecting. A concurrent ensureConnected(id)
556
+ // during the batch would find the pre-handshake client _connectOne caches, treat it as stale
557
+ // and discard it. Nothing can call in during startup today - http-transport awaits
558
+ // connectAll() before listen() and before the reload swap - so the batch is unobservable.
559
+ // If a caller is ever added that can race startup, set/delete the _connecting entry around
560
+ // each task here.
561
+ const promise = this._connectOne(upstream).catch((err) => {
562
+ try { this._discard(id); } catch (_e) { /* cleanup is best-effort; report the real error */ }
563
+ throw err;
564
+ });
565
+ tasks.push({ id, cached: false, promise });
566
+ }
567
+
568
+ const settled = await Promise.allSettled(tasks.map((t) => t.promise));
569
+
570
+ // Walk the tasks in STORE order so connected[]/failed[] stay stable and every enabled
571
+ // upstream lands in EXACTLY ONE of them - a settled promise is fulfilled or rejected, so
572
+ // no upstream can fall through this loop unreported.
573
+ for (let i = 0; i < tasks.length; i++) {
574
+ const { id, cached } = tasks[i];
575
+ if (cached || settled[i].status === 'fulfilled') {
576
+ connected.push(id);
577
+ continue;
578
+ }
579
+ const error = errMsg(settled[i].reason);
580
+ logger.log({ type: 'mcp', event: 'connect_failed', upstream: id, error });
581
+ failed.push({ id, error });
582
+ // A STARTUP failure is usually transient - a slow-booting upstream that missed its
583
+ // handshake window while eleven siblings were spawning next to it. Arm the same
584
+ // self-healing backoff a mid-session death gets: without it a startup failure stranded
585
+ // the upstream with no client, no in-flight connect and no timer for the WHOLE session,
586
+ // silently costing the agent that upstream's tools. The retry runs off-chain (unref'd
587
+ // timer) and signals tools/list_changed when the upstream comes back.
588
+ this._scheduleReconnect(id, 0);
589
+ }
590
+
591
+ return { connected, failed };
592
+ }
593
+
594
+ /**
595
+ * Discover one upstream's tool surface. Connects the upstream if it isn't already
596
+ * (lazily, honouring the isolation guard), runs tools/list, caches and returns the
597
+ * raw tool definitions [{name, description, inputSchema}]. This is what the MCP
598
+ * Manager 'discover' button calls.
599
+ *
600
+ * Unlike connectAll this MAY reject (it is a direct user action, so the UI wants the
601
+ * error) - but it cleans up a failed connection so no zombie child is left.
602
+ *
603
+ * @param {string} upstreamId
604
+ * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
605
+ */
606
+ async discover(upstreamId) {
607
+ if (!isNonEmptyString(upstreamId)) {
608
+ throw new Error('Aggregator.discover: upstreamId (non-empty string) is required');
609
+ }
610
+
611
+ // Route through ensureConnected: it JOINS an in-flight connect via _connecting instead of
612
+ // racing it - two concurrent discover() calls used to double-spawn the child and leak the
613
+ // loser without closing it. ensureConnected also reaps its own failed connects.
614
+ // allowDisabled: discover is the ONE caller permitted to connect a disabled upstream -
615
+ // inspecting its tools pre-enable worked before the race fix and stays supported.
616
+ try {
617
+ await this.ensureConnected(upstreamId, { allowDisabled: true });
618
+ } catch (err) {
619
+ throw new Error(`Aggregator.discover: cannot connect "${upstreamId}": ${errMsg(err)}`);
620
+ }
621
+ // Re-list even when the fast path returned a cached client - the discover button must always
622
+ // reflect live state (a fresh connect lists twice; cheap, and the semantics stay honest).
623
+ return await this._listAndCache(upstreamId);
624
+ }
625
+
626
+ /**
627
+ * @returns {Object<string, Array>} a clone map of upstreamId -> cached discovered tools.
628
+ * Only includes upstreams that have been connected/discovered this session.
629
+ */
630
+ toolsByUpstream() {
631
+ const out = {};
632
+ for (const [id, tools] of this._tools) {
633
+ out[id] = cloneTools(tools);
634
+ }
635
+ return out;
636
+ }
637
+
638
+ /**
639
+ * The CURATED-DIRECT tool definitions to advertise downstream. For each ENABLED
640
+ * expose[] entry whose upstream is CONNECTED, ENABLED and ACTUALLY advertises the named tool:
641
+ * - name = store.exposedName(entry) (the downstream `as` name)
642
+ * - description = '[' + upstream + '] ' + (upstream tool description || 'tool')
643
+ * - inputSchema = the upstream tool's inputSchema || { type:'object' }
644
+ *
645
+ * Entries whose upstream isn't connected, is explicitly disabled (the row's own flag
646
+ * cannot override the upstream's - a discover-cached client doesn't re-admit it), or
647
+ * whose named tool the upstream doesn't advertise, are silently skipped.
648
+ *
649
+ * @returns {Array<{name:string, description:string, inputSchema:object}>}
650
+ */
651
+ exposedToolDefinitions() {
652
+ const defs = [];
653
+ let exposed;
654
+ try {
655
+ exposed = this._store.listExposed({ enabledOnly: true });
656
+ } catch (_err) {
657
+ return defs; // never throw out of a definition build
658
+ }
659
+
660
+ for (const e of exposed) {
661
+ if (!e || !this._clients.has(e.upstream)) continue; // upstream not connected
662
+ const upstream = this._store.getUpstream(e.upstream);
663
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
664
+ const upstreamTools = this._tools.get(e.upstream) || [];
665
+ const upstreamTool = upstreamTools.find((t) => t && t.name === e.tool);
666
+ if (!upstreamTool) continue; // upstream doesn't actually advertise this tool
667
+
668
+ const name = this._store.exposedName(e);
669
+ const desc =
670
+ isNonEmptyString(upstreamTool.description) ? upstreamTool.description : 'tool';
671
+ const inputSchema =
672
+ upstreamTool.inputSchema && typeof upstreamTool.inputSchema === 'object'
673
+ ? upstreamTool.inputSchema
674
+ : { type: 'object' };
675
+
676
+ defs.push(withForwardedToolMeta({
677
+ name,
678
+ description: `[${e.upstream}] ${desc}`,
679
+ inputSchema: sanitiseXMcpHeaders(cloneJson(inputSchema)),
680
+ }, upstreamTool));
681
+ }
682
+ return defs;
683
+ }
684
+
685
+ /**
686
+ * The upstream ids in STORE order (expose.json's own sequence) - the stable advertisement
687
+ * order handleToolsList's hot-upstream block iterates by. Connection-map order
688
+ * shifts on death/recovery re-insertion; the store's order never does. NEVER throws.
689
+ * @returns {string[]}
690
+ */
691
+ upstreamOrder() {
692
+ try {
693
+ return this._store.listUpstreams().map((u) => u && u.id).filter(Boolean);
694
+ } catch (_e) {
695
+ return [];
696
+ }
697
+ }
698
+
699
+ /**
700
+ * Is `name` a downstream `as` name currently exposed (enabled) AND backed by a
701
+ * connected upstream that advertises the underlying tool? Mirrors the set
702
+ * exposedToolDefinitions() advertises.
703
+ * @param {string} name
704
+ * @returns {boolean}
705
+ */
706
+ isExposed(name) {
707
+ return this._findExposedExecution(name) !== null;
708
+ }
709
+
710
+ /**
711
+ * Resolve a downstream `as` name + args into an executor. Returns
712
+ * { execute, toolName, upstream } | null
713
+ * where:
714
+ * - execute() invokes the connected client's callTool(<real upstream tool name>, args)
715
+ * - toolName = the downstream `as` (what PreToolUse matchers see - the gate matches
716
+ * the downstream name, execution hits the real upstream tool)
717
+ * - upstream = the upstream id
718
+ * Returns null if `name` is not exposed/enabled or its upstream isn't connected.
719
+ *
720
+ * @param {string} name downstream `as` name
721
+ * @param {any} args structured arguments forwarded verbatim to callTool
722
+ * @returns {{ execute: Function, toolName: string, upstream: string } | null}
723
+ */
724
+ resolveExposedExecution(name, args, meta) {
725
+ const hit = this._findExposedExecution(name);
726
+ if (!hit) return null;
727
+ const { client, realTool, upstream, exposedName } = hit;
728
+ return {
729
+ // The execute thunk is the seam the server calls AFTER the PreToolUse gate allows it.
730
+ execute: this._forwardExecutor(client, realTool, args, meta),
731
+ toolName: exposedName, // downstream name - the gate's matcher target
732
+ upstream,
733
+ };
734
+ }
735
+
736
+ // ── lean register forwarding ──────────────────────────────────────
737
+ // The LEAN path surfaces an attached upstream's tools through the 4 meta-tools (toolfunnel_list_tools
738
+ // / _tool_instructions / _run_tool) instead of injecting them top-level every turn. The full
739
+ // discovered set of every CONNECTED + enabled upstream is surfaced (attaching an MCP makes its
740
+ // tools appear with no per-tool curation); expose[]/hot governs only the SEPARATE top-level
741
+ // (curated-direct) promotion. A lean tool is surfaced under its enabled expose `as` if one exists,
742
+ // else the namespaced default `<upstream>_<tool>` - so the lean name equals the curated-direct name
743
+ // for an exposed tool and ONE PreToolUse rule gates both routes. All of this lives here in the
744
+ // aggregator (it owns the connections, the _clients/_tools caches, and the connect log chokepoint).
745
+
746
+ /**
747
+ * The single forward seam: a thunk that calls the upstream's real tool. Shared by the
748
+ * curated-direct path (resolveExposedExecution, transparent envelope passthrough) and the lean
749
+ * path (resolveLeanExecution, which unwraps). Kept tiny so both routes execute identically.
750
+ * @param {object} client a connected McpClient
751
+ * @param {string} realTool the upstream's own tool name
752
+ * @param {any} args
753
+ * @returns {() => Promise<any>}
754
+ */
755
+ _forwardExecutor(client, realTool, args, meta) {
756
+ return () => {
757
+ this._warnIfPinned(client.id, realTool);
758
+ return client.callTool(realTool, args, meta);
759
+ };
760
+ }
761
+
762
+ /**
763
+ * Legacy shim PER-CALL warning (the pin's contract: warn on every forwarded call, naming the
764
+ * upstream + version). Called from BOTH forward paths - curated-direct (_forwardExecutor) and
765
+ * lean (resolveLeanExecution's own closure; it cannot use _forwardExecutor because it needs
766
+ * ensureConnected + envelope unwrapping). Checked at CALL time so a live expose.json edit takes
767
+ * effect immediately; stderr + activity log, never the content. NEVER throws.
768
+ * @param {string} upstreamId
769
+ * @param {string} realTool the upstream's own tool name
770
+ */
771
+ _warnIfPinned(upstreamId, realTool) {
772
+ try {
773
+ const upstream = this._store.getUpstream(upstreamId);
774
+ if (upstream && upstream.legacyPin === true) {
775
+ process.stderr.write(
776
+ `[toolfunnel] legacy shim: forwarding "${realTool}" to pinned legacy upstream ` +
777
+ `"${upstreamId}" (MCP ${LEGACY_UPSTREAM_VERSION})\n`
778
+ );
779
+ logger.log({ type: 'mcp', event: 'legacy_shim_call', upstream: upstreamId, tool: realTool, protocolVersion: LEGACY_UPSTREAM_VERSION });
780
+ }
781
+ } catch (_e) { /* the warning must never affect the call */ }
782
+ }
783
+
784
+ /**
785
+ * Legacy-shim info for a surfaced OR original call name: { upstream, protocolVersion } when the
786
+ * tool belongs to a PINNED (legacyPin) upstream, else null. Used by the modern era to tag
787
+ * results with _meta["io.toolfunnel/legacyShim"] - metadata, never content. NEVER throws.
788
+ * @param {string} name the name a client called (surfaced `<upstream>_<tool>`/`as`, or the
789
+ * ORIGINAL tool name under passthrough)
790
+ * @returns {{ upstream: string, protocolVersion: string } | null}
791
+ */
792
+ legacyShimInfo(name) {
793
+ if (typeof name !== 'string' || name.length === 0) return null;
794
+ try {
795
+ const d = this.leanToolDefinitions().find((x) => x && (x.name === name || x.tool === name));
796
+ if (!d) return null;
797
+ const upstream = this._store.getUpstream(d.upstream);
798
+ if (!upstream || upstream.legacyPin !== true) return null;
799
+ return { upstream: d.upstream, protocolVersion: LEGACY_UPSTREAM_VERSION };
800
+ } catch (_e) {
801
+ return null;
802
+ }
803
+ }
804
+
805
+ /**
806
+ * The wrapped upstream's OWN identity, extracted from its captured `initialize` result - so a
807
+ * passthrough WRAP can present the wrapped server verbatim (indistinguishable from connecting to
808
+ * it directly, save for the ToolFunnel process). Returns { protocolVersion, serverInfo,
809
+ * capabilities, instructions } with best-effort extraction (some servers nest capabilities /
810
+ * instructions inside serverInfo), or null if the upstream isn't connected. NEVER throws.
811
+ * @param {string} upstreamId
812
+ * @returns {{ protocolVersion?:string, serverInfo?:object, capabilities?:object, instructions?:string } | null}
813
+ */
814
+ wrappedIdentity(upstreamId) {
815
+ try {
816
+ const client = this._clients.get(upstreamId);
817
+ if (!client || typeof client.initializeResult === 'undefined') return null;
818
+ const init = client.initializeResult;
819
+ if (!init || typeof init !== 'object') return null;
820
+ const si = init.serverInfo && typeof init.serverInfo === 'object' ? init.serverInfo : {};
821
+ // serverInfo passes VERBATIM minus the two fields some servers wrongly nest inside it
822
+ // (capabilities/instructions - extracted separately below). A whitelist here ate the spec's
823
+ // own serverInfo.title (2025-06-18) through the wrap - proven against the real
824
+ // server-everything (found wire-testing the wrap, 2026-07-17). Blocklist, so future identity fields survive.
825
+ const serverInfo = {};
826
+ for (const k of Object.keys(si)) {
827
+ if (k === 'capabilities' || k === 'instructions') continue;
828
+ serverInfo[k] = cloneJson(si[k]);
829
+ }
830
+ return {
831
+ protocolVersion: typeof init.protocolVersion === 'string' ? init.protocolVersion : undefined,
832
+ serverInfo: Object.keys(serverInfo).length ? serverInfo : undefined,
833
+ // capabilities / instructions: prefer top-level (standard), fall back to serverInfo-nested.
834
+ capabilities: (init.capabilities && typeof init.capabilities === 'object') ? init.capabilities
835
+ : (si.capabilities && typeof si.capabilities === 'object') ? si.capabilities : undefined,
836
+ instructions: typeof init.instructions === 'string' ? init.instructions
837
+ : typeof si.instructions === 'string' ? si.instructions : undefined,
838
+ };
839
+ } catch (_e) {
840
+ return null;
841
+ }
842
+ }
843
+
844
+ /**
845
+ * Forward a RAW JSON-RPC method to a connected upstream and return its result - the passthrough
846
+ * WRAP's relay for non-tool methods (resources/*, prompts/*, logging/*, ping...) so a wrapped
847
+ * server behaves exactly as it would connected directly. Uses a LIVE client only (no synchronous
848
+ * (re)connect - same rule as the lean run path). Throws if the upstream is not connected or the
849
+ * call fails; the caller shapes that into a JSON-RPC error.
850
+ * @param {string} upstreamId
851
+ * @param {string} method
852
+ * @param {any} params
853
+ * @returns {Promise<any>}
854
+ */
855
+ async forwardRaw(upstreamId, method, params, out) {
856
+ const client = await this.ensureConnected(upstreamId, { allowConnect: false });
857
+ if (!client || typeof client.request !== 'function') {
858
+ throw new Error(`upstream "${upstreamId}" is not connected`);
859
+ }
860
+ return await client.request(method, params, out);
861
+ }
862
+
863
+ /** The clientInfo to PRESENT to `upstreamId`, or null for the built-in. Fresh provider read per
864
+ * call (per-connect, same as the wrap-target check); NEVER throws. */
865
+ _readClientInfo(upstreamId) {
866
+ try {
867
+ const info = this._clientInfoProvider ? this._clientInfoProvider(upstreamId) : null;
868
+ return info && typeof info === 'object' &&
869
+ typeof info.name === 'string' && info.name.length ? info : null;
870
+ } catch (_e) {
871
+ return null; // a broken provider degrades to the built-in identity
872
+ }
873
+ }
874
+
875
+ /** Is `upstreamId` the currently-wrapped upstream? Fresh provider read per call; NEVER throws.
876
+ * Drives the per-connect isolation exemption (transparent-wrapper mode). */
877
+ _isWrapTarget(upstreamId) {
878
+ try {
879
+ return !!(this._wrapTargetProvider && isNonEmptyString(upstreamId) &&
880
+ this._wrapTargetProvider() === upstreamId);
881
+ } catch (_e) {
882
+ return false; // a broken provider must degrade to the SAFE default (guard applies)
883
+ }
884
+ }
885
+
886
+ /** Set the wrap-chatter scope. When the passthrough TARGET CHANGES (A->B, A->null, null->A) the
887
+ * recorded resource subscriptions belong to the OLD context and must not replay onto the new
888
+ * one - a client of wrap B never subscribed to wrap A's URIs. A
889
+ * same-value re-arm (every message does one) clears nothing. */
890
+ setWrapChatterUpstream(v) {
891
+ const next = v == null ? null : v;
892
+ if (next !== this.wrapChatterUpstream && this._subscribedUris instanceof Set) {
893
+ this._subscribedUris.clear();
894
+ if (this._subscribedRefs instanceof Map) this._subscribedRefs.clear(); // 5.4: refs go with the context
895
+ }
896
+ this.wrapChatterUpstream = next;
897
+ }
898
+
899
+ /** Relay a client NOTIFICATION to an upstream (wrap passthrough: notifications/cancelled,
900
+ * notifications/progress ...). Fire-and-forget; never throws, never answers. */
901
+ notifyRaw(upstreamId, method, params) {
902
+ try {
903
+ const client = this._clients.get(upstreamId);
904
+ if (client && typeof client.notify === 'function') client.notify(method, params);
905
+ } catch (_e) { /* fire-and-forget */ }
906
+ }
907
+
908
+ /** TRUE if a connected upstream can ACTUALLY deliver per-URI resource updates. That means a
909
+ * LEGACY upstream advertising resources.subscribe (we forward the subscribes; it emits
910
+ * resources/updated). A MODERN upstream does NOT count: its only notification channel is a
911
+ * subscriptions/listen stream this client does not yet open (28-July item), so counting it
912
+ * agreed a channel nothing would ever feed. Under a wrap only the WRAPPED
913
+ * upstream counts - delivery is filtered to it, so another upstream honouring the channel
914
+ * would be agreed-then-dropped. The ack must never promise a dead channel. */
915
+ canHonourResourceSubscriptions() {
916
+ for (const [id, client] of this._clients) {
917
+ if (!client) continue;
918
+ if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
919
+ if (client.era === 'modern') continue;
920
+ const caps = client.initializeResult && client.initializeResult.capabilities;
921
+ if (caps && caps.resources && caps.resources.subscribe) return true;
922
+ }
923
+ return false;
924
+ }
925
+
926
+ /** Best-effort: forward resources/subscribe for each URI to every subscribe-capable legacy
927
+ * upstream in scope (the wrapped one only, under a wrap). The URIs are RECORDED
928
+ * so every later (re)connect replays them: a reconnected upstream is a fresh process that
929
+ * remembers no subscriptions, and without the replay the client's agreed channel went silently
930
+ * dead forever. Fire-and-forget; never throws. */
931
+ subscribeResources(uris) {
932
+ try {
933
+ const list = Array.isArray(uris) ? uris.filter((u) => typeof u === 'string' && u.length) : [];
934
+ if (!list.length) return;
935
+ // 5.4: refcount per LISTEN-owned uri; the upstream subscribe is sent on the 0->1 crossing
936
+ // only (a re-listen agreeing the same uri must not spam idempotent subscribes).
937
+ const fresh = [];
938
+ for (const uri of list) {
939
+ const n = this._subscribedRefs.get(uri) || 0;
940
+ this._subscribedRefs.set(uri, n + 1);
941
+ if (!this._subscribedUris.has(uri)) { this._subscribedUris.add(uri); fresh.push(uri); }
942
+ }
943
+ if (!fresh.length) return;
944
+ for (const [id, client] of this._clients) {
945
+ if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
946
+ if (!client || client.era !== 'legacy' || typeof client.request !== 'function') continue;
947
+ const caps = client.initializeResult && client.initializeResult.capabilities;
948
+ if (!(caps && caps.resources && caps.resources.subscribe)) continue;
949
+ for (const uri of fresh) client.request('resources/subscribe', { uri }).catch(() => {});
950
+ }
951
+ } catch (_e) { /* never throw */ }
952
+ }
953
+
954
+ /**
955
+ * Release LISTEN-owned resource subscriptions (the refcount leak's fix). Decrements each
956
+ * uri's refcount; on the 1->0 crossing the uri leaves the replay set and the upstream is told
957
+ * resources/unsubscribe (same scope rules as subscribeResources). ACCEPTED EDGE, documented:
958
+ * a uri subscribed BOTH by a listen agreement and a client's own wrap-forwarded
959
+ * resources/subscribe shares one upstream channel - when the last listen releases, the
960
+ * upstream unsubscribe fires and the recorded Set entry goes with it (the exotic overlap is
961
+ * not disambiguated). NEVER throws.
962
+ * @param {string[]} uris
963
+ */
964
+ releaseResources(uris) {
965
+ try {
966
+ const list = Array.isArray(uris) ? uris.filter((u) => typeof u === 'string' && u.length) : [];
967
+ if (!list.length) return;
968
+ const gone = [];
969
+ for (const uri of list) {
970
+ const n = this._subscribedRefs.get(uri) || 0;
971
+ if (n <= 1) {
972
+ if (n === 1) this._subscribedRefs.delete(uri);
973
+ if (this._subscribedUris.has(uri)) { this._subscribedUris.delete(uri); gone.push(uri); }
974
+ } else {
975
+ this._subscribedRefs.set(uri, n - 1);
976
+ }
977
+ }
978
+ if (!gone.length) return;
979
+ for (const [id, client] of this._clients) {
980
+ if (this.wrapChatterUpstream && id !== this.wrapChatterUpstream) continue;
981
+ if (!client || client.era !== 'legacy' || typeof client.request !== 'function') continue;
982
+ const caps = client.initializeResult && client.initializeResult.capabilities;
983
+ if (!(caps && caps.resources && caps.resources.subscribe)) continue;
984
+ for (const uri of gone) client.request('resources/unsubscribe', { uri }).catch(() => {});
985
+ }
986
+ } catch (_e) { /* never throw */ }
987
+ }
988
+
989
+ /** Replay the recorded resource subscriptions onto ONE freshly-connected upstream.
990
+ * Called from _connectOne so every connect path - startup, lazy, background reconnect, reload -
991
+ * re-arms the channel. Same scope rules as subscribeResources. Never throws. */
992
+ _replaySubscriptions(upstreamId) {
993
+ try {
994
+ if (!this._subscribedUris || this._subscribedUris.size === 0) return;
995
+ if (this.wrapChatterUpstream && upstreamId !== this.wrapChatterUpstream) return;
996
+ const client = this._clients.get(upstreamId);
997
+ if (!client || client.era !== 'legacy' || typeof client.request !== 'function') return;
998
+ const caps = client.initializeResult && client.initializeResult.capabilities;
999
+ if (!(caps && caps.resources && caps.resources.subscribe)) return;
1000
+ for (const uri of this._subscribedUris) client.request('resources/subscribe', { uri }).catch(() => {});
1001
+ } catch (_e) { /* never throw */ }
1002
+ }
1003
+
1004
+ /** The change-notification methods an upstream may push that we bridge to modern clients. Anything
1005
+ * else from the upstream is ignored (request-scoped notifications ride their own request stream). */
1006
+ static get BRIDGED_NOTIFICATIONS() {
1007
+ return new Set([
1008
+ 'notifications/tools/list_changed',
1009
+ 'notifications/prompts/list_changed',
1010
+ 'notifications/resources/list_changed',
1011
+ 'notifications/resources/updated',
1012
+ ]);
1013
+ }
1014
+
1015
+ /**
1016
+ * Forward a server-initiated CHANGE notification from an upstream to the transport hook, which
1017
+ * fans it to modern subscriptions/listen streams. Only the known change methods pass; the
1018
+ * notification is handed on verbatim (the transport tags + filters per subscription). NEVER
1019
+ * throws (an onNotification handler must never break the client's read loop).
1020
+ * @param {string} upstreamId
1021
+ * @param {object} notification a JSON-RPC notification { method, params? }
1022
+ */
1023
+ _forwardUpstreamNotification(upstreamId, notification) {
1024
+ try {
1025
+ if (this._closed) return; // a swapped-out/torn-down aggregator must never fan out (F9)
1026
+ if (!notification || typeof notification.method !== 'string') return;
1027
+ // tasks/status rides the wrap-chatter lane too: the wrap advertises the
1028
+ // upstream's `tasks` capability verbatim, so a task started through the wrap owes its
1029
+ // status notifications exactly as a direct connection would deliver them (2025-11-25
1030
+ // schema TaskStatusNotification). Wrapped upstream only - same scope as message/progress.
1031
+ const isWrapChatter = this.wrapChatterUpstream === upstreamId &&
1032
+ (notification.method === 'notifications/message' || notification.method === 'notifications/progress' ||
1033
+ notification.method === 'notifications/tasks/status');
1034
+ if (!Aggregator.BRIDGED_NOTIFICATIONS.has(notification.method) && !isWrapChatter) return;
1035
+ // Forward a CLEAN notification only - a misbehaving upstream's stray `id: null` field would
1036
+ // read as a response to downstream parsers (F7).
1037
+ const clean = { jsonrpc: '2.0', method: notification.method };
1038
+ if (notification.params !== undefined) clean.params = notification.params;
1039
+ const emit = () => {
1040
+ try {
1041
+ if (typeof this.onUpstreamNotification === 'function') {
1042
+ this.onUpstreamNotification(upstreamId, clean);
1043
+ }
1044
+ } catch (_e) { /* never let a bridged notification break the read loop */ }
1045
+ };
1046
+ if (notification.method === 'notifications/tools/list_changed') {
1047
+ // A relayed "tools changed" must not point the client at a STALE surface - refresh the
1048
+ // cache FIRST, then relay, so the re-fetch sees the new list (F2/B-M4). onToolsChanged
1049
+ // fires too so ToolFunnel's own funnel-mode signal stays coherent.
1050
+ this._listAndCache(upstreamId)
1051
+ .then(() => { try { if (typeof this.onToolsChanged === 'function') this.onToolsChanged(); } catch (_e) { /* ignore */ } })
1052
+ .catch(() => { /* refresh failed - still relay; the old cache is the best we have */ })
1053
+ .then(emit);
1054
+ return;
1055
+ }
1056
+ emit();
1057
+ } catch (_e) { /* never let a bridged notification break the read loop */ }
1058
+ }
1059
+
1060
+ /**
1061
+ * The downstream name a discovered upstream tool is surfaced under: its ENABLED expose `as` if one
1062
+ * exists for (upstream, tool), else the namespaced default `<upstream>_<tool>` (matching
1063
+ * expose-store.exposedName). Keeps the lean list and the lean run path in agreement, and aligns an
1064
+ * exposed tool's lean name with its curated-direct top-level name. Never throws.
1065
+ * @param {string} upstreamId
1066
+ * @param {string} toolName
1067
+ * @returns {string}
1068
+ */
1069
+ _surfacedName(upstreamId, toolName) {
1070
+ try {
1071
+ const exposed = this._store.listExposed({ enabledOnly: true });
1072
+ for (const e of exposed) {
1073
+ if (e && e.upstream === upstreamId && e.tool === toolName) return this._store.exposedName(e);
1074
+ }
1075
+ } catch (_e) {
1076
+ /* fall through to the default */
1077
+ }
1078
+ return `${upstreamId}_${toolName}`;
1079
+ }
1080
+
1081
+ /**
1082
+ * Connect an upstream ON DEMAND and return the ready McpClient. Idempotent + race-safe:
1083
+ * - fast path: a cached client that is still `connected` is returned as-is; a cached-but-DEAD
1084
+ * client (its child exited - McpClient flips `connected` but stays in the cache) is logged as a
1085
+ * disconnect, discarded, and reconnected - a reconnect re-lists the upstream's tools.
1086
+ * - concurrent callers JOIN the same in-flight connect promise (keyed in _connecting), which
1087
+ * resolves only AFTER _listAndCache - so no caller ever sees the half-built client that
1088
+ * _connectOne caches BEFORE the handshake completes (the double-spawn / mid-handshake race).
1089
+ * Throws on an unknown/disabled upstream or a connect/list failure (the caller surfaces it; for the
1090
+ * lean run path that means gatedRun returns { ok:false, error } cleanly).
1091
+ * @param {string} upstreamId
1092
+ * @returns {Promise<object>} the connected McpClient
1093
+ */
1094
+ async ensureConnected(upstreamId, opts) {
1095
+ if (!isNonEmptyString(upstreamId)) throw new Error('Aggregator.ensureConnected: upstreamId (non-empty string) is required');
1096
+ // allowConnect:false (the RUN path) must NEVER block on a connect - not a from-scratch connect
1097
+ // (connect()+tools/list, ~20s, would freeze the serialized stdio chain and starve pings) AND not
1098
+ // by JOINING an in-flight background reconnect (same latency). It returns a live client or fails
1099
+ // clean; the background reconnect owns recovery and the run retries next turn. allowConnect
1100
+ // defaults true (the background path connects / joins).
1101
+ const allowConnect = !(opts && opts.allowConnect === false);
1102
+ // allowDisabled: ONLY the discover path sets it - inspecting a disabled upstream's tools
1103
+ // ("what does this have, before I enable it?") is a deliberate operator action that worked
1104
+ // before discover was routed through here for the race fix. Every
1105
+ // other caller keeps the refusal; a disabled upstream's tools stay OFF the lean surface
1106
+ // regardless (leanToolDefinitions checks enabled itself).
1107
+ const allowDisabled = !!(opts && opts.allowDisabled === true);
1108
+ // A torn-down aggregator (swapped out by reloadExpose, then closeAll'd) must NEVER (re)connect:
1109
+ // a captured execute thunk holding this instance would otherwise spawn an ORPHAN child on it.
1110
+ if (this._closed) throw new Error('Aggregator.ensureConnected: aggregator is closed (config reloaded) - retry on the current one');
1111
+
1112
+ // A connect already in flight: JOIN it - BEFORE inspecting the cache. _connectOne caches the
1113
+ // client pre-handshake, so a concurrent caller inspecting first sees `connected:false`, reads
1114
+ // it as stale, closes it (killing the in-flight child mid-handshake) and then joins the
1115
+ // connect it just doomed - both callers fail. The RUN path
1116
+ // (allowConnect:false) still never joins - joining would block the stdio chain.
1117
+ if (this._connecting.has(upstreamId)) {
1118
+ if (!(opts && opts.allowConnect === false)) return this._connecting.get(upstreamId);
1119
+ throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background)`);
1120
+ }
1121
+
1122
+ // Fast path: a live cached client serves BOTH modes immediately.
1123
+ const existing = this._clients.get(upstreamId);
1124
+ if (existing) {
1125
+ const alive = typeof existing.connected === 'boolean' ? existing.connected : true;
1126
+ if (alive) return existing;
1127
+ // Dead cached client. The RUN path fails clean (never (re)connects); the background path drops
1128
+ // it and reconnects below.
1129
+ if (!allowConnect) {
1130
+ throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" disconnected (reconnecting in background)`);
1131
+ }
1132
+ logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'stale' });
1133
+ this._discard(upstreamId);
1134
+ } else if (!allowConnect) {
1135
+ // Not connected. The RUN path neither connects NOR joins an in-flight connect - joining would
1136
+ // block the stdio chain for the connect+list latency (the head-of-line freeze this mode avoids).
1137
+ throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background)`);
1138
+ }
1139
+
1140
+ // Background path only (allowConnect): connect from scratch. In-flight connects were joined
1141
+ // at the top of the method (before cache inspection - the mid-connect discard race); nothing
1142
+ // between there and here awaits, so no new entry can have appeared.
1143
+ const upstream = this._store.getUpstream(upstreamId);
1144
+ if (!upstream) throw new Error(`Aggregator.ensureConnected: unknown upstream "${upstreamId}"`);
1145
+ if (upstream.enabled !== true && !allowDisabled) throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is disabled`);
1146
+
1147
+ const promise = (async () => {
1148
+ await this._connectOne(upstream); // caches client + tools, logs the connect
1149
+ return this._clients.get(upstreamId);
1150
+ })();
1151
+ this._connecting.set(upstreamId, promise);
1152
+ try {
1153
+ return await promise;
1154
+ } catch (err) {
1155
+ this._discard(upstreamId); // reap the half-spawned child; leave no zombie/cache entry
1156
+ throw err;
1157
+ } finally {
1158
+ this._connecting.delete(upstreamId);
1159
+ }
1160
+ }
1161
+
1162
+ /**
1163
+ * Force a fresh connect of `upstreamId` presenting the CURRENT provider-supplied identity -
1164
+ * the wrap identity-mirror's inline path. Settles any in-flight connect FIRST: discarding a
1165
+ * half-built client and then joining its doomed promise leaves the upstream wedged with no
1166
+ * background reconnect and no self-heal. Rejects
1167
+ * like ensureConnected on failure.
1168
+ */
1169
+ async reconnect(upstreamId) {
1170
+ if (!isNonEmptyString(upstreamId)) throw new Error('Aggregator.reconnect: upstreamId (non-empty string) is required');
1171
+ while (this._connecting.has(upstreamId)) {
1172
+ try { await this._connecting.get(upstreamId); } catch (_e) { /* settled is all we need */ }
1173
+ }
1174
+ this._discard(upstreamId);
1175
+ // A MANUAL kick deliberately re-runs the full era negotiation - the operator may have just
1176
+ // upgraded the server, and "reconnect" should observe reality, not replay a memo of it.
1177
+ this._eraMemo.delete(upstreamId);
1178
+ try {
1179
+ return await this.ensureConnected(upstreamId);
1180
+ } catch (err) {
1181
+ // This path DESTROYED a (possibly healthy) connection, and the deliberate close means
1182
+ // onClose never fires - without this, one transient connect failure leaves no client, no
1183
+ // in-flight connect AND no retry timer: the upstream is wedged until an external kick
1184
+ // while ensureConnected's "reconnecting in background" message lies. The destroyer owns the retry. A stale PENDING timer (a prior death's slow
1185
+ // 30s keepalive) must be cleared first - _scheduleReconnect's one-timer guard would
1186
+ // otherwise no-op and leave the upstream on the long window instead of the immediate
1187
+ // retry this path promises.
1188
+ const stale = this._reconnectTimers.get(upstreamId);
1189
+ if (stale) { clearTimeout(stale); this._reconnectTimers.delete(upstreamId); }
1190
+ this._scheduleReconnect(upstreamId, 0);
1191
+ throw err;
1192
+ }
1193
+ }
1194
+
1195
+ /**
1196
+ * UNEXPECTED upstream death (fired by McpClient.onClose). Log it, drop the dead client so it
1197
+ * leaves the lean list, tell the client the tool set shrank, then schedule a BACKGROUND reconnect.
1198
+ * Keeping recovery off the serialized stdio chain is what closes the head-of-line regression: a run
1199
+ * during the down-window fails clean (ensureConnected allowConnect:false) instead of blocking.
1200
+ * @param {string} upstreamId
1201
+ */
1202
+ _handleUpstreamDown(upstreamId) {
1203
+ if (this._closed) return;
1204
+ logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'died' });
1205
+ this._discard(upstreamId); // remove the dead client + its tools -> drops from the lean list
1206
+ this._signalToolsChanged(upstreamId); // list_changed only if the upstream is on the top-level surface
1207
+ // If a reconnect ATTEMPT is already executing, it owns the next backoff step (its catch escalates
1208
+ // attempt+1). Seeding attempt-0 here would reset the delay to 1s on every mid-attempt death, and
1209
+ // the one-timer guard would then swallow the attempt's escalation - a permanent 1s respawn loop.
1210
+ // Only start a fresh cycle when no attempt is in flight (the ordinary first death of a live upstream).
1211
+ if (this._reconnecting.has(upstreamId)) return;
1212
+ this._scheduleReconnect(upstreamId, 0);
1213
+ }
1214
+
1215
+ /**
1216
+ * Does this upstream contribute to the TOP-LEVEL tools/list (i.e. has an ENABLED expose[] entry)?
1217
+ * Only then does its death/recovery change tools/list and warrant a notifications/tools/list_changed.
1218
+ * A lean-only upstream's tools live behind toolfunnel_list_tools (pulled per call), so notifying for
1219
+ * one is spurious (the client re-fetches and sees no diff). Never throws.
1220
+ * @param {string} upstreamId
1221
+ * @returns {boolean}
1222
+ */
1223
+ _affectsTopLevel(upstreamId) {
1224
+ try {
1225
+ // Under a WRAP the wrapped upstream IS the entire top-level surface - its death/recovery
1226
+ // changes tools/list even with an empty expose[] (a typical wrap has no expose
1227
+ // entries, so death/recovery was silent and clients held a stale surface forever).
1228
+ // wrapChatterUpstream mirrors the live wrap state (armed at boot, per message, on reload).
1229
+ if (this.wrapChatterUpstream && this.wrapChatterUpstream === upstreamId) return true;
1230
+ // Hot-promoted upstream tools sit on the funnel's top-level surface without an expose[]
1231
+ // entry; the host injects this predicate (armWrapChatter) because promotion lives in the
1232
+ // tool-state overlay the aggregator has no business reading directly.
1233
+ if (typeof this.topLevelUpstreamExtra === 'function' && this.topLevelUpstreamExtra(upstreamId)) return true;
1234
+ return this._store.listExposed({ enabledOnly: true }).some((e) => e && e.upstream === upstreamId);
1235
+ } catch (_e) {
1236
+ return false;
1237
+ }
1238
+ }
1239
+
1240
+ /** Fire onToolsChanged ONLY when the upstream is actually on the top-level surface (else the
1241
+ * notification is spurious - its lean tools refresh on the next toolfunnel_list_tools). Never throws. */
1242
+ _signalToolsChanged(upstreamId) {
1243
+ if (!this._affectsTopLevel(upstreamId)) return;
1244
+ try { this.onToolsChanged(); } catch (_e) { /* never throw out of an event handler */ }
1245
+ }
1246
+
1247
+ /**
1248
+ * Schedule ONE background reconnect attempt with exponential backoff: 1s,2s,4s,8s,16s, then capped
1249
+ * at 30s. After the fast phase it does NOT give up - it falls back to a slow 30s keepalive so a long
1250
+ * transient outage still self-heals (a detached/disabled upstream stops it via getUpstream; closeAll
1251
+ * stops it via _closed). On success the tools are re-listed and a top-level change is signalled. One
1252
+ * timer per upstream (no pile-up); the timer is unref'd so it never keeps the process alive. NEVER throws.
1253
+ * @param {string} upstreamId
1254
+ * @param {number} attempt
1255
+ */
1256
+ _scheduleReconnect(upstreamId, attempt) {
1257
+ if (this._closed) return;
1258
+ if (this._reconnectTimers.has(upstreamId)) return; // a reconnect is already pending for this id
1259
+ const upstream = this._store.getUpstream(upstreamId);
1260
+ if (!upstream || upstream.enabled !== true) return; // detached/disabled - stop trying
1261
+ const MAX_FAST_ATTEMPTS = 6; // exponential phase; beyond it, a slow keepalive at the 30s cap
1262
+ const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
1263
+ const timer = setTimeout(async () => {
1264
+ this._reconnectTimers.delete(upstreamId);
1265
+ if (this._closed) return;
1266
+ const u = this._store.getUpstream(upstreamId);
1267
+ if (!u || u.enabled !== true) return;
1268
+ // Mark the attempt in flight so a death DURING ensureConnected's connect/list (onClose ->
1269
+ // _handleUpstreamDown) does not re-seed attempt-0: THIS attempt's catch owns the escalation.
1270
+ this._reconnecting.add(upstreamId);
1271
+ try {
1272
+ await this.ensureConnected(upstreamId); // allowConnect default - connects + re-lists, off-chain
1273
+ logger.log({ type: 'mcp', event: 'reconnect', upstream: upstreamId, attempt });
1274
+ this._signalToolsChanged(upstreamId);
1275
+ } catch (err) {
1276
+ logger.log({ type: 'mcp', event: 'reconnect_failed', upstream: upstreamId, attempt, error: errMsg(err) });
1277
+ // Log the fast->slow transition exactly ONCE - when the counter FIRST reaches the cap. The
1278
+ // old guard sat on the re-entry side, where the capped counter re-satisfied it every 30s
1279
+ // forever against a permanently-dead upstream (log spam, not information).
1280
+ if (attempt + 1 === MAX_FAST_ATTEMPTS) {
1281
+ logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: MAX_FAST_ATTEMPTS });
1282
+ }
1283
+ // Keep escalating to the cap, then keep retrying slowly (cap the counter so the delay stays
1284
+ // at 30s) - never permanently give up while the upstream stays enabled. The escalation is
1285
+ // scheduled while still in flight (the guard below checks _reconnectTimers, which is empty
1286
+ // because a mid-attempt death was skipped) - the finally then releases the marker.
1287
+ this._scheduleReconnect(upstreamId, Math.min(attempt + 1, MAX_FAST_ATTEMPTS));
1288
+ } finally {
1289
+ this._reconnecting.delete(upstreamId);
1290
+ }
1291
+ }, delay);
1292
+ if (timer && typeof timer.unref === 'function') timer.unref();
1293
+ this._reconnectTimers.set(upstreamId, timer);
1294
+ }
1295
+
1296
+ /**
1297
+ * The LEAN tool definitions: every discovered tool of every CONNECTED + enabled upstream, surfaced
1298
+ * for toolfunnel_list_tools. Each: { name, description, inputSchema, upstream, tool }. The FULL
1299
+ * discovered set (not just expose[]) - attaching an MCP makes its tools appear leanly with no
1300
+ * curation. Only connected upstreams contribute (we need their real schemas); a not-yet-connected
1301
+ * upstream's tools simply appear once it connects (the next list call reflects it - no
1302
+ * notification needed, the lean surface is delivered inside a per-call meta-tool). Never throws.
1303
+ * @returns {Array<{name:string, description:string, inputSchema:object, upstream:string, tool:string}>}
1304
+ */
1305
+ leanToolDefinitions() {
1306
+ const defs = [];
1307
+ const seen = new Set(); // surfaced-name collision guard - the list must agree with resolve (first wins)
1308
+ try {
1309
+ for (const [upstreamId, tools] of this._tools) {
1310
+ if (!this._clients.has(upstreamId)) continue; // connected only
1311
+ const upstream = this._store.getUpstream(upstreamId);
1312
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
1313
+ if (!Array.isArray(tools)) continue;
1314
+ for (const t of tools) {
1315
+ if (!t || !isNonEmptyString(t.name)) continue;
1316
+ const name = this._surfacedName(upstreamId, t.name);
1317
+ if (seen.has(name)) {
1318
+ // Two tools collapse to one surfaced name (underscore ambiguity, or a duplicate `as`).
1319
+ // resolveLeanExecution returns the FIRST match, so advertise only the first - the list
1320
+ // must never show a name that resolves to a different tool. Log the shadowing.
1321
+ logger.log({ type: 'mcp', event: 'name_collision', upstream: upstreamId, tool: t.name, surfaced: name });
1322
+ continue;
1323
+ }
1324
+ seen.add(name);
1325
+ const schema = t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : { type: 'object' };
1326
+ defs.push(withForwardedToolMeta({
1327
+ name,
1328
+ description: isNonEmptyString(t.description) ? t.description : '',
1329
+ inputSchema: sanitiseXMcpHeaders(cloneJson(schema)),
1330
+ upstream: upstreamId,
1331
+ tool: t.name,
1332
+ }, t));
1333
+ }
1334
+ }
1335
+ } catch (_e) {
1336
+ /* never throw out of a definition build - return what we have */
1337
+ }
1338
+ return defs;
1339
+ }
1340
+
1341
+ /**
1342
+ * Resolve a lean downstream name + args into a forwarding executor for toolfunnel_run_tool. Matches
1343
+ * by ITERATION over discovered tools (computing each tool's surfaced name) - never by parsing the
1344
+ * name (underscore-ambiguous). The execute thunk lazy-(re)connects via ensureConnected and UNWRAPS
1345
+ * the upstream MCP envelope to a clean payload, so run_tool returns the upstream's content (e.g.
1346
+ * "pong"), not a stringified envelope. The connect is INSIDE the thunk -> a PreToolUse deny spawns
1347
+ * nothing. The gate matches `toolName` (the surfaced name). Returns null if no connected + enabled
1348
+ * upstream advertises a tool with this surfaced name (-> protocol's clean "not runnable" error) -
1349
+ * the same connected/enabled predicate leanToolDefinitions lists by, so hidden means not runnable.
1350
+ * @param {string} name surfaced downstream name
1351
+ * @param {any} args
1352
+ * @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
1353
+ */
1354
+ resolveLeanExecution(name, args) {
1355
+ if (!isNonEmptyString(name)) return null;
1356
+ for (const [upstreamId, tools] of this._tools) {
1357
+ if (!this._clients.has(upstreamId)) continue;
1358
+ const upstream = this._store.getUpstream(upstreamId);
1359
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
1360
+ if (!Array.isArray(tools)) continue;
1361
+ for (const t of tools) {
1362
+ if (!t || !isNonEmptyString(t.name)) continue;
1363
+ if (this._surfacedName(upstreamId, t.name) !== name) continue;
1364
+ const realTool = t.name;
1365
+ const self = this;
1366
+ const execute = async () => {
1367
+ // allowConnect:false - the run path NEVER synchronously (re)connects (that would freeze the
1368
+ // stdio loop, starving pings). It uses a live client or joins an in-flight background
1369
+ // reconnect, else fails clean; the onClose-driven background reconnect owns recovery.
1370
+ const client = await self.ensureConnected(upstreamId, { allowConnect: false });
1371
+ self._warnIfPinned(upstreamId, realTool);
1372
+ const envelope = await client.callTool(realTool, args);
1373
+ // Preserve the upstream's failure signal. callTool RESOLVES even on isError:true, so a
1374
+ // failed upstream call must become a THROW -> gatedRun returns { ok:false } -> run_tool
1375
+ // surfaces isError:true. Otherwise a failed call would be reported as success - and the
1376
+ // lean path must agree with curated-direct's isError passthrough.
1377
+ const payload = unwrapEnvelope(envelope);
1378
+ if (envelope && typeof envelope === 'object' && envelope.isError === true) {
1379
+ const text = typeof payload === 'string' ? payload : safeStringify(payload);
1380
+ const err = new Error(text && text.length ? text : 'upstream tool reported an error');
1381
+ err.upstreamToolError = true;
1382
+ throw err;
1383
+ }
1384
+ return payload;
1385
+ };
1386
+ return { execute, toolName: name, upstream: upstreamId };
1387
+ }
1388
+ }
1389
+ return null;
1390
+ }
1391
+
1392
+ /**
1393
+ * resolveRawExecution - the passthrough (WRAP) twin of resolveLeanExecution. Its execute returns
1394
+ * the upstream's tools/call result VERBATIM - multi-block content, isError, structuredContent
1395
+ * and any future fields untouched. A wrapped client must receive exactly what a direct client
1396
+ * would; the lean unwrap (single text block, throw-on-isError) destroys that. No unwrap, no
1397
+ * throw-on-isError: the envelope IS the product. Uses client.request() so a modern upstream
1398
+ * gets its per-request _meta trio.
1399
+ * @param {string} name surfaced downstream name
1400
+ * @param {any} args
1401
+ * @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
1402
+ */
1403
+ resolveRawExecution(name, args, meta, out) {
1404
+ if (!isNonEmptyString(name)) return null;
1405
+ for (const [upstreamId, tools] of this._tools) {
1406
+ if (!this._clients.has(upstreamId)) continue;
1407
+ const upstream = this._store.getUpstream(upstreamId);
1408
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
1409
+ if (!Array.isArray(tools)) continue;
1410
+ for (const t of tools) {
1411
+ if (!t || !isNonEmptyString(t.name)) continue;
1412
+ if (this._surfacedName(upstreamId, t.name) !== name) continue;
1413
+ const realTool = t.name;
1414
+ const self = this;
1415
+ const execute = async () => {
1416
+ // allowConnect:false - same freeze-avoidance rule as the lean path.
1417
+ const client = await self.ensureConnected(upstreamId, { allowConnect: false });
1418
+ self._warnIfPinned(upstreamId, realTool);
1419
+ const params = { name: realTool, arguments: args == null ? {} : args };
1420
+ // The caller's request-scoped _meta (progressToken, trace keys - already stripped of
1421
+ // protocol keys) rides the forward: a direct server would receive it, so the wrapped
1422
+ // one must too - without it the upstream never emits progress for the call.
1423
+ if (meta && typeof meta === 'object' && Object.keys(meta).length) params._meta = meta;
1424
+ // `out.rpcId` is set synchronously at issue time - the caller's cancel-translation
1425
+ // hook (the same contract as forwardRaw's).
1426
+ return await client.request('tools/call', params, out);
1427
+ };
1428
+ return { execute, toolName: name, upstream: upstreamId };
1429
+ }
1430
+ }
1431
+ return null;
1432
+ }
1433
+
1434
+ /**
1435
+ * Close every cached client and clear the caches. Idempotent - safe to call twice and
1436
+ * safe when nothing was ever connected. NEVER throws (close() on McpClient never throws).
1437
+ * @returns {Promise<void>}
1438
+ */
1439
+ async closeAll() {
1440
+ this._closed = true; // latch BEFORE teardown so any in-flight execute thunk refuses to re-spawn
1441
+ for (const [, timer] of this._reconnectTimers) {
1442
+ try { clearTimeout(timer); } catch (_e) { /* ignore */ }
1443
+ }
1444
+ this._reconnectTimers.clear();
1445
+ this._reconnecting.clear(); // no in-flight attempt survives teardown (its finally is a no-op then)
1446
+ for (const [id, client] of this._clients) {
1447
+ try {
1448
+ if (client && typeof client.close === 'function') client.close();
1449
+ // Activity log (self-gating): the disconnect half. A reload closes the old aggregator here,
1450
+ // so a reconnect reads as a disconnect line followed by a connect line for the same id.
1451
+ logger.log({ type: 'mcp', event: 'disconnect', upstream: id });
1452
+ } catch (_e) {
1453
+ /* McpClient.close never throws, but never let teardown sink the loop */
1454
+ }
1455
+ }
1456
+ this._clients.clear();
1457
+ this._tools.clear();
1458
+ }
1459
+
1460
+ // ── internals ──────────────────────────────────────────────────────────────
1461
+
1462
+ /**
1463
+ * Connect a single upstream entry and cache the client + its discovered tools.
1464
+ * Throws on factory (isolation), connect, or listTools failure - callers decide
1465
+ * whether to collect (connectAll) or surface (discover) the error. On throw, the
1466
+ * caller is responsible for _discard()-ing the id.
1467
+ * @param {object} upstream a normalised ExposeStore upstream
1468
+ */
1469
+ async _connectOne(upstream) {
1470
+ // The factory may throw (isolation guard) - that propagates out as a connect failure. Pass an
1471
+ // onClose so an UNEXPECTED upstream death triggers a BACKGROUND reconnect (not a lazy one in the
1472
+ // run path, which would freeze the stdio chain).
1473
+ const client = this._clientFactory(upstream, () => this._handleUpstreamDown(upstream.id));
1474
+ if (!client || typeof client.connect !== 'function') {
1475
+ throw new Error(`clientFactory returned an invalid client for "${upstream.id}"`);
1476
+ }
1477
+ // Bridge this upstream's server-initiated change-notifications to the transport (which fans them
1478
+ // out to modern subscriptions/listen streams). Filtered to the known change methods so arbitrary
1479
+ // upstream chatter never reaches a client. NEVER throws.
1480
+ client.onNotification = (n) => this._forwardUpstreamNotification(upstream.id, n);
1481
+ // Elicitation bridge: surface server-initiated requests with the upstream id + the live client (the
1482
+ // handler answers on it). Guarded so a handler bug can never break the client's read loop.
1483
+ client.onServerRequest = (m) => {
1484
+ try {
1485
+ if (this._closed) { client.respondToServerError(m.id, -32603, 'shutting down'); return; }
1486
+ this.onUpstreamServerRequest(upstream.id, m, client);
1487
+ } catch (_e) {
1488
+ try { client.respondToServerError(m.id, -32603, 'request failed'); } catch (_e2) { /* ignore */ }
1489
+ }
1490
+ };
1491
+ // Cache the client BEFORE connect so a connect-failure path can still _discard() and
1492
+ // close any half-spawned child.
1493
+ this._clients.set(upstream.id, client);
1494
+ try {
1495
+ await client.connect();
1496
+ // The LIST is part of the connect contract: a handshake that cannot list its tools is a
1497
+ // FAILED connect to every caller, and the era observation must not outlive it either.
1498
+ await this._listAndCache(upstream.id);
1499
+ } catch (err) {
1500
+ // The failed attempt may have been era-HINTED (probe skipped on the memo). Whatever the
1501
+ // cause - handshake or the post-handshake list - a failure invalidates the observation:
1502
+ // clear it so the NEXT attempt re-runs the full negotiation. An upstream upgraded to
1503
+ // modern-only across a restart recovers on the following backoff retry instead of failing
1504
+ // hinted forever.
1505
+ this._eraMemo.delete(upstream.id);
1506
+ throw err;
1507
+ }
1508
+ // Remember the negotiated era so a death-driven reconnect can skip the probe (eraHint) - but
1509
+ // only a DEFINITIVE observation (the probe was ANSWERED). A probe that timed out proves
1510
+ // nothing about the upstream's eras, and memoising that fallback would pin a merely-SLOW
1511
+ // dual-era upstream to legacy on every future reconnect. A hinted connect (probe skipped)
1512
+ // leaves the existing memo in place.
1513
+ if (client.eraDefinitive !== false) this._eraMemo.set(upstream.id, client.era);
1514
+ // Re-arm any agreed resource subscriptions on the fresh process - every connect
1515
+ // path funnels through here, so reconnects and reloads replay uniformly.
1516
+ this._replaySubscriptions(upstream.id);
1517
+ // Activity log (self-gating; no-op unless logging is enabled): the upstream half of connect
1518
+ // logging. EVERY connect flows through here - startup connectAll, a live reload, a lazy
1519
+ // discover, and (later) a health-driven reconnect - so all of them are logged uniformly.
1520
+ logger.log({
1521
+ type: 'mcp',
1522
+ event: 'connect',
1523
+ upstream: upstream.id,
1524
+ command: upstream.command,
1525
+ tools: (this._tools.get(upstream.id) || []).length,
1526
+ });
1527
+ // Legacy shim STARTUP warning (the pin's contract: warn at startup AND per call, naming the
1528
+ // version). stderr, so the operator sees it without the client's content ever growing.
1529
+ if (upstream.legacyPin === true) {
1530
+ try {
1531
+ process.stderr.write(
1532
+ `[toolfunnel] legacy shim: upstream "${upstream.id}" is PINNED to MCP ${LEGACY_UPSTREAM_VERSION} ` +
1533
+ '(legacyPin) - this gateway keeps it working for modern clients; it cannot speak 2026-07-28 itself.\n'
1534
+ );
1535
+ } catch (_e) { /* never let a warning sink a connect */ }
1536
+ logger.log({ type: 'mcp', event: 'legacy_shim_connect', upstream: upstream.id, protocolVersion: LEGACY_UPSTREAM_VERSION });
1537
+ }
1538
+ }
1539
+
1540
+ /** tools/list one connected upstream and cache the result; returns a clone. */
1541
+ async _listAndCache(upstreamId) {
1542
+ const client = this._clients.get(upstreamId);
1543
+ if (!client) throw new Error(`no connected client for "${upstreamId}"`);
1544
+ const tools = await client.listTools();
1545
+ const safe = Array.isArray(tools) ? tools.filter((t) => t && isNonEmptyString(t.name)) : [];
1546
+ this._tools.set(upstreamId, safe);
1547
+ return cloneTools(safe);
1548
+ }
1549
+
1550
+ /** Tear down + forget a cached client (used on a failed connect). Never throws. */
1551
+ _discard(upstreamId) {
1552
+ const client = this._clients.get(upstreamId);
1553
+ if (client && typeof client.close === 'function') {
1554
+ try { client.close(); } catch (_e) { /* never throw */ }
1555
+ }
1556
+ this._clients.delete(upstreamId);
1557
+ this._tools.delete(upstreamId);
1558
+ }
1559
+
1560
+ /**
1561
+ * Shared lookup behind isExposed / resolveExposedExecution: find the ENABLED expose
1562
+ * entry whose downstream name === `name`, whose upstream is connected AND enabled, and
1563
+ * whose real tool the upstream advertises. Returns the resolution context or null.
1564
+ * @param {string} name
1565
+ * @returns {{ client:object, realTool:string, upstream:string, exposedName:string } | null}
1566
+ */
1567
+ _findExposedExecution(name) {
1568
+ if (!isNonEmptyString(name)) return null;
1569
+ let exposed;
1570
+ try {
1571
+ exposed = this._store.listExposed({ enabledOnly: true });
1572
+ } catch (_err) {
1573
+ return null;
1574
+ }
1575
+ for (const e of exposed) {
1576
+ if (!e) continue;
1577
+ if (this._store.exposedName(e) !== name) continue;
1578
+ const client = this._clients.get(e.upstream);
1579
+ if (!client) continue; // upstream not connected
1580
+ const upstream = this._store.getUpstream(e.upstream);
1581
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
1582
+ const upstreamTools = this._tools.get(e.upstream) || [];
1583
+ if (!upstreamTools.some((t) => t && t.name === e.tool)) continue; // tool not advertised
1584
+ return { client, realTool: e.tool, upstream: e.upstream, exposedName: name };
1585
+ }
1586
+ return null;
1587
+ }
1588
+ }
1589
+
1590
+ // ── small helpers (no external deps) ───────────────────────────────────────────
1591
+
1592
+ /** Extract a clean message from any thrown value. */
1593
+ function errMsg(err) {
1594
+ return (err && err.message) || String(err);
1595
+ }
1596
+
1597
+ /** JSON.stringify that never throws (circular/oddball values fall back to String). */
1598
+ function safeStringify(value) {
1599
+ try { return JSON.stringify(value); } catch (_e) { return String(value); }
1600
+ }
1601
+
1602
+ /**
1603
+ * Turn an upstream MCP tools/call envelope { content:[...], isError? } into a clean payload for the
1604
+ * LEAN run path. A single text block -> its string (so toolfunnel_run_tool returns "pong", matching
1605
+ * what the curated-direct content passthrough surfaces); otherwise the content array; otherwise the
1606
+ * value as-is when it isn't an envelope. This is what avoids double-wrapping the envelope as JSON
1607
+ * inside wrapProtocolResult (the curated-direct path passes the envelope through transparently
1608
+ * instead; the lean path goes through protocol.runTool -> wrapProtocolResult, so it unwraps here).
1609
+ * NOTE: an upstream isError:true is surfaced as its text content with ok:true (homogeneous
1610
+ * with how local tools surface error text) - mapping isError -> ok:false is a later concern.
1611
+ * @param {*} envelope
1612
+ * @returns {*}
1613
+ */
1614
+ function unwrapEnvelope(envelope) {
1615
+ if (!envelope || typeof envelope !== 'object') return envelope;
1616
+ const content = envelope.content;
1617
+ if (Array.isArray(content)) {
1618
+ if (content.length === 1 && content[0] && content[0].type === 'text' && typeof content[0].text === 'string') {
1619
+ return content[0].text;
1620
+ }
1621
+ return content;
1622
+ }
1623
+ return envelope;
1624
+ }
1625
+
1626
+ /** Plain-JSON deep clone (schemas are JSON; structuredClone may be absent on old node). */
1627
+ function cloneJson(value) {
1628
+ return JSON.parse(JSON.stringify(value));
1629
+ }
1630
+
1631
+ /** The upstream tool-def metadata the funnel FORWARDS onto advertised defs.
1632
+ * ICONS ARE WITHHELD by policy: 2025-11-25 and 2026-07-28 both require icon consumers to
1633
+ * verify icon URIs are SAME-ORIGIN with the server, so gateway-forwarded icons are third-party
1634
+ * URLs a conforming client must reject - or worse, trust as ours. Reversible (adding a field
1635
+ * later is non-breaking); the decision is open - forwarding them later flips it here. */
1636
+ const FORWARDED_TOOL_META = Object.freeze(['title', 'annotations', 'outputSchema', '_meta', 'execution']);
1637
+
1638
+ /** Selectively copy the forwardable metadata from a RAW discovered def onto an advertised def.
1639
+ * NEVER a spread: lean defs carry internal routing ids (upstream/tool), and a spread would
1640
+ * also inherit unknown future fields sight-unseen (the explicit selective-add rule). Mutates + returns def. */
1641
+ function withForwardedToolMeta(def, raw) {
1642
+ if (!raw || typeof raw !== 'object') return def;
1643
+ for (const k of FORWARDED_TOOL_META) {
1644
+ if (raw[k] !== undefined && def[k] === undefined) def[k] = cloneJson(raw[k]);
1645
+ }
1646
+ return def;
1647
+ }
1648
+
1649
+ /** HTTP field-name token syntax (RFC 9110 §5.1) - the constraint on x-mcp-header values. */
1650
+ const XMCP_TCHAR = /^[!#$%&'*+.^_`|~0-9A-Za-z-]+$/;
1651
+
1652
+ /**
1653
+ * Strip INVALID `x-mcp-header` annotations from an advertised inputSchema, in place (0.7.0
1654
+ * ingest hygiene). The spec makes a single malformed annotation invalidate the WHOLE tool -
1655
+ * conforming Streamable-HTTP clients MUST drop it from tools/list - so forwarding a broken
1656
+ * upstream annotation verbatim makes the tool vanish client-side. Valid = on a pure
1657
+ * `properties` chain from the root, a non-empty tchar token, on a string/integer/boolean
1658
+ * param, case-insensitively unique among the valid set. Everything else is deleted (the TOOL
1659
+ * survives; the annotation was the contaminant). The WRAP path stays raw-verbatim - a wrapped
1660
+ * server's brokenness is presented faithfully, exactly as a direct connection would.
1661
+ * @param {object} schema the advertised (already-cloned) inputSchema - mutated
1662
+ * @returns {object} schema
1663
+ */
1664
+ function sanitiseXMcpHeaders(schema) {
1665
+ if (!schema || typeof schema !== 'object') return schema;
1666
+ const valid = []; // { node, name } - candidates that pass every per-node rule
1667
+ const strip = []; // nodes whose annotation is deleted
1668
+ (function walk(node, onChain) {
1669
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
1670
+ if (Object.prototype.hasOwnProperty.call(node, 'x-mcp-header')) {
1671
+ const v = node['x-mcp-header'];
1672
+ const okType = node.type === 'string' || node.type === 'integer' || node.type === 'boolean';
1673
+ if (onChain && typeof v === 'string' && XMCP_TCHAR.test(v) && okType) valid.push({ node, name: v.toLowerCase() });
1674
+ else strip.push(node);
1675
+ }
1676
+ for (const k of Object.keys(node)) {
1677
+ const child = node[k];
1678
+ if (!child || typeof child !== 'object') continue;
1679
+ if (k === 'properties' && !Array.isArray(child)) {
1680
+ for (const sub of Object.values(child)) walk(sub, onChain); // the chain continues
1681
+ } else if (Array.isArray(child)) {
1682
+ for (const el of child) walk(el, false); // items/oneOf/... break the chain
1683
+ } else {
1684
+ walk(child, false);
1685
+ }
1686
+ }
1687
+ })(schema, true);
1688
+ const seen = new Map();
1689
+ for (const e of valid) seen.set(e.name, (seen.get(e.name) || 0) + 1);
1690
+ for (const e of valid) if (seen.get(e.name) > 1) strip.push(e.node); // ambiguous duplicates: all go
1691
+ for (const n of strip) delete n['x-mcp-header'];
1692
+ return schema;
1693
+ }
1694
+
1695
+ /** Clone an array of tool defs so callers can't mutate the aggregator's cache. */
1696
+ function cloneTools(tools) {
1697
+ return Array.isArray(tools) ? tools.map((t) => cloneJson(t)) : [];
1698
+ }
1699
+
1700
+ module.exports = {
1701
+ Aggregator,
1702
+ // exported for unit tests / reuse
1703
+ defaultClientFactory,
1704
+ looksLikePath,
1705
+ isInside,
1706
+ unwrapEnvelope,
1707
+ DEFAULT_ROOT,
1708
+ // 3.7: the forwardable def metadata + the selective-add - server.js's hot-promotion
1709
+ // projection uses the same list so the two advertised paths can never drift apart again.
1710
+ FORWARDED_TOOL_META,
1711
+ withForwardedToolMeta,
1712
+ // 4.3: ingest hygiene for x-mcp-header annotations (exported for tests).
1713
+ sanitiseXMcpHeaders,
1714
+ // A3: the wrap-time SECURITY NOTICE (bin/toolfunnel.js) enumerates the same classes the guard
1715
+ // judges - shared lists, so the two surfaces can never classify differently.
1716
+ isDriveRelative,
1717
+ CODE_OPT_ENV,
1718
+ CODE_PATH_ENV,
1719
+ };