toolfunnel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,802 @@
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 } = require('./mcp-client');
50
+ const logger = require('../core/logger');
51
+
52
+ // The sandbox root, resolved exactly the way server.js does (this file lives in
53
+ // src/mcp/, so two levels up is the gateway root). Used as the isolation boundary the
54
+ // default clientFactory enforces. A caller may override via opts.v3Root for tests.
55
+ const DEFAULT_ROOT = path.resolve(__dirname, '..', '..');
56
+
57
+ /** True for a non-empty string. */
58
+ function isNonEmptyString(v) {
59
+ return typeof v === 'string' && v.length > 0;
60
+ }
61
+
62
+ /**
63
+ * Does this argv token LOOK like a file path? An absolute path (path.isAbsolute) or
64
+ * any token containing a path separator ('/' or '\\') is treated as a path reference
65
+ * and must be confined to v3Root. A bare token like 'node', 'npx', 'server' or a flag
66
+ * like '--port' is NOT a path and is always allowed.
67
+ * @param {string} arg
68
+ * @returns {boolean}
69
+ */
70
+ function looksLikePath(arg) {
71
+ if (!isNonEmptyString(arg)) return false;
72
+ if (path.isAbsolute(arg)) return true;
73
+ return arg.indexOf('/') !== -1 || arg.indexOf('\\') !== -1;
74
+ }
75
+
76
+ /**
77
+ * Is `target` inside `root` (or equal to it)? Compares fully-resolved, normalised
78
+ * absolute paths. A relative `target` is resolved AGAINST root (so 'vendor/x/server.js'
79
+ * checks the in-sandbox location). Returns false for anything that escapes root via
80
+ * '..' or an absolute path elsewhere on disk.
81
+ * @param {string} root absolute sandbox root
82
+ * @param {string} target path token (absolute or relative)
83
+ * @returns {boolean}
84
+ */
85
+ function isInside(root, target) {
86
+ const resolvedRoot = path.resolve(root);
87
+ // An absolute target is checked as-is; a relative one is resolved within the root so
88
+ // a vendored relative path ('vendor/foo/server.js') is judged at its in-sandbox spot.
89
+ const resolvedTarget = path.isAbsolute(target)
90
+ ? path.resolve(target)
91
+ : path.resolve(resolvedRoot, target);
92
+ const rel = path.relative(resolvedRoot, resolvedTarget);
93
+ // Inside iff the relative path neither starts with '..' nor is itself absolute
94
+ // (an absolute `rel` happens on win32 across drive letters). '' (equal) counts as inside.
95
+ return rel === '' || (!rel.startsWith('..' + path.sep) && rel !== '..' && !path.isAbsolute(rel));
96
+ }
97
+
98
+ /**
99
+ * The DEFAULT clientFactory. Constructs a McpClient for an upstream entry AFTER
100
+ * enforcing the isolation guard on the ARGS: every arg that looks like a file path
101
+ * (an absolute path, or any token containing a path separator) must resolve inside
102
+ * `v3Root`. Throws a clear 'isolation: …' error otherwise — connectAll catches that
103
+ * into failed[].
104
+ *
105
+ * The COMMAND slot is intentionally NOT guarded: it names the interpreter/executable
106
+ * (e.g. 'node', 'npx', or an absolute path to a node binary such as process.execPath /
107
+ * a system node.exe) which by definition lives OUTSIDE the project. Isolation is about
108
+ * WHAT CODE the spawned process runs — that is the args (the server script + any data
109
+ * files it loads) — not which interpreter binary launches it. Guarding the command too
110
+ * would wrongly reject every vendored upstream the moment it is run by the system node.
111
+ *
112
+ * @param {object} upstream a normalised ExposeStore upstream { id, command, args, env, … }
113
+ * @param {string} v3Root the sandbox root the upstream's path-shaped ARGS must stay inside
114
+ * @returns {McpClient}
115
+ */
116
+ function defaultClientFactory(upstream, v3Root, onClose) {
117
+ const id = isNonEmptyString(upstream && upstream.id) ? upstream.id : '(unknown)';
118
+ const args = Array.isArray(upstream && upstream.args) ? upstream.args : [];
119
+
120
+ // Guard every path-shaped arg (server scripts, db files, …). The command (interpreter)
121
+ // is NOT guarded — see the function doc: it is the executable, not the code being run.
122
+ for (const arg of args) {
123
+ if (looksLikePath(arg) && !isInside(v3Root, arg)) {
124
+ throw new Error(`isolation: upstream ${id} references a path outside the gateway root`);
125
+ }
126
+ }
127
+
128
+ return new McpClient({
129
+ id: upstream.id,
130
+ command: upstream.command,
131
+ args: upstream.args,
132
+ env: upstream.env,
133
+ onClose, // unexpected death → the aggregator schedules a background reconnect
134
+ });
135
+ }
136
+
137
+ /**
138
+ * Aggregator — owns the live upstream connections and the curated-expose computation.
139
+ *
140
+ * Lifecycle: `new Aggregator({ store, v3Root, clientFactory })`
141
+ * → `await connectAll()` (or `await discover(id)` lazily)
142
+ * → `exposedToolDefinitions()` / `resolveExposedExecution(name, args)`
143
+ * → `await closeAll()` (idempotent).
144
+ */
145
+ class Aggregator {
146
+ /**
147
+ * @param {object} opts
148
+ * @param {object} opts.store an ExposeStore (the on-disk MCP config).
149
+ * @param {string} [opts.v3Root] sandbox root for the isolation guard (default: the gateway root).
150
+ * @param {Function} [opts.clientFactory] (upstreamEntry, v3Root) -> McpClient. Default enforces isolation.
151
+ */
152
+ constructor({ store, v3Root, clientFactory, onToolsChanged } = {}) {
153
+ if (!store || typeof store !== 'object') {
154
+ throw new Error('Aggregator: a `store` (ExposeStore) is required');
155
+ }
156
+ this._store = store;
157
+ this._v3Root = isNonEmptyString(v3Root) ? v3Root : DEFAULT_ROOT;
158
+ // Called when the LIVE tool set changes OUT OF BAND (a background reconnect recovers or finally
159
+ // loses an upstream) so the transport can emit notifications/tools/list_changed. Default no-op;
160
+ // the server wires it to emitToolsListChanged(send). Settable so reloadExpose can re-wire the
161
+ // new instance.
162
+ this.onToolsChanged = typeof onToolsChanged === 'function' ? onToolsChanged : () => {};
163
+ // The factory receives (upstream, v3Root, onClose). A caller-supplied factory may ignore the
164
+ // extra args; the default uses v3Root for isolation and onClose for death-driven reconnect.
165
+ this._clientFactory =
166
+ typeof clientFactory === 'function'
167
+ ? (upstream, onClose) => clientFactory(upstream, this._v3Root, onClose)
168
+ : (upstream, onClose) => defaultClientFactory(upstream, this._v3Root, onClose);
169
+
170
+ /** @type {Map<string, object>} upstreamId -> connected McpClient */
171
+ this._clients = new Map();
172
+ /** @type {Map<string, Array>} upstreamId -> discovered tool defs [{name,description,inputSchema}] */
173
+ this._tools = new Map();
174
+ /** @type {Map<string, Promise>} upstreamId -> in-flight connect promise (race guard for ensureConnected) */
175
+ this._connecting = new Map();
176
+ /** @type {Map<string, any>} upstreamId -> pending background-reconnect timer (one per upstream) */
177
+ this._reconnectTimers = new Map();
178
+ /** Set true by closeAll(). A torn-down aggregator (e.g. swapped out by reloadExpose) must NEVER
179
+ * (re)connect — a captured execute thunk holding this instance would otherwise spawn an orphan
180
+ * upstream child on it that nothing ever closes. ensureConnected/connectAll check this. */
181
+ this._closed = false;
182
+ }
183
+
184
+ /**
185
+ * Connect every ENABLED upstream in the store, caching each connected client and its
186
+ * discovered tools. NEVER throws — a per-upstream failure (isolation guard, spawn
187
+ * failure, handshake timeout, listTools failure) is collected into failed[]. An
188
+ * upstream already connected (cached) is treated as connected without reconnecting.
189
+ *
190
+ * @returns {Promise<{ connected: string[], failed: Array<{id:string, error:string}> }>}
191
+ */
192
+ async connectAll() {
193
+ const connected = [];
194
+ const failed = [];
195
+ if (this._closed) return { connected, failed }; // a closed (torn-down) aggregator never connects
196
+
197
+ let upstreams;
198
+ try {
199
+ upstreams = this._store.listUpstreams();
200
+ } catch (err) {
201
+ // listUpstreams() shouldn't throw (it clones), but never let it sink connectAll.
202
+ return { connected, failed: [{ id: '(store)', error: errMsg(err) }] };
203
+ }
204
+
205
+ for (const upstream of upstreams) {
206
+ if (!upstream || upstream.enabled !== true) continue; // only ENABLED upstreams
207
+
208
+ const id = upstream.id;
209
+ if (this._clients.has(id)) {
210
+ // Already connected this session — keep it; don't double-spawn.
211
+ connected.push(id);
212
+ continue;
213
+ }
214
+
215
+ try {
216
+ await this._connectOne(upstream);
217
+ connected.push(id);
218
+ } catch (err) {
219
+ // Clean up any half-built client so a failure leaves no zombie/cache entry.
220
+ this._discard(id);
221
+ const error = errMsg(err);
222
+ logger.log({ type: 'mcp', event: 'connect_failed', upstream: id, error });
223
+ failed.push({ id, error });
224
+ }
225
+ }
226
+
227
+ return { connected, failed };
228
+ }
229
+
230
+ /**
231
+ * Discover one upstream's tool surface. Connects the upstream if it isn't already
232
+ * (lazily, honouring the isolation guard), runs tools/list, caches and returns the
233
+ * raw tool definitions [{name, description, inputSchema}]. This is what the MCP
234
+ * Manager 'discover' button calls.
235
+ *
236
+ * Unlike connectAll this MAY reject (it is a direct user action, so the UI wants the
237
+ * error) — but it cleans up a failed connection so no zombie child is left.
238
+ *
239
+ * @param {string} upstreamId
240
+ * @returns {Promise<Array<{name:string, description?:string, inputSchema?:object}>>}
241
+ */
242
+ async discover(upstreamId) {
243
+ if (!isNonEmptyString(upstreamId)) {
244
+ throw new Error('Aggregator.discover: upstreamId (non-empty string) is required');
245
+ }
246
+
247
+ // If connected already, re-list so the discover button always reflects live state.
248
+ if (this._clients.has(upstreamId)) {
249
+ const tools = await this._listAndCache(upstreamId);
250
+ return tools;
251
+ }
252
+
253
+ const upstream = this._store.getUpstream(upstreamId);
254
+ if (!upstream) {
255
+ throw new Error(`Aggregator.discover: unknown upstream "${upstreamId}"`);
256
+ }
257
+
258
+ try {
259
+ await this._connectOne(upstream);
260
+ } catch (err) {
261
+ this._discard(upstreamId);
262
+ throw new Error(`Aggregator.discover: cannot connect "${upstreamId}": ${errMsg(err)}`);
263
+ }
264
+ // _connectOne already cached the tools; return them (clone so callers can't mutate).
265
+ return cloneTools(this._tools.get(upstreamId) || []);
266
+ }
267
+
268
+ /**
269
+ * @returns {Object<string, Array>} a clone map of upstreamId -> cached discovered tools.
270
+ * Only includes upstreams that have been connected/discovered this session.
271
+ */
272
+ toolsByUpstream() {
273
+ const out = {};
274
+ for (const [id, tools] of this._tools) {
275
+ out[id] = cloneTools(tools);
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /**
281
+ * The CURATED-DIRECT tool definitions to advertise downstream. For each ENABLED
282
+ * expose[] entry whose upstream is CONNECTED and ACTUALLY advertises the named tool:
283
+ * - name = store.exposedName(entry) (the downstream `as` name)
284
+ * - description = '[' + upstream + '] ' + (upstream tool description || 'tool')
285
+ * - inputSchema = the upstream tool's inputSchema || { type:'object' }
286
+ *
287
+ * Entries whose upstream isn't connected, or whose named tool the upstream doesn't
288
+ * advertise, are silently skipped (you can't expose a tool that isn't there).
289
+ *
290
+ * @returns {Array<{name:string, description:string, inputSchema:object}>}
291
+ */
292
+ exposedToolDefinitions() {
293
+ const defs = [];
294
+ let exposed;
295
+ try {
296
+ exposed = this._store.listExposed({ enabledOnly: true });
297
+ } catch (_err) {
298
+ return defs; // never throw out of a definition build
299
+ }
300
+
301
+ for (const e of exposed) {
302
+ if (!e || !this._clients.has(e.upstream)) continue; // upstream not connected
303
+ const upstreamTools = this._tools.get(e.upstream) || [];
304
+ const upstreamTool = upstreamTools.find((t) => t && t.name === e.tool);
305
+ if (!upstreamTool) continue; // upstream doesn't actually advertise this tool
306
+
307
+ const name = this._store.exposedName(e);
308
+ const desc =
309
+ isNonEmptyString(upstreamTool.description) ? upstreamTool.description : 'tool';
310
+ const inputSchema =
311
+ upstreamTool.inputSchema && typeof upstreamTool.inputSchema === 'object'
312
+ ? upstreamTool.inputSchema
313
+ : { type: 'object' };
314
+
315
+ defs.push({
316
+ name,
317
+ description: `[${e.upstream}] ${desc}`,
318
+ inputSchema: cloneJson(inputSchema),
319
+ });
320
+ }
321
+ return defs;
322
+ }
323
+
324
+ /**
325
+ * Is `name` a downstream `as` name currently exposed (enabled) AND backed by a
326
+ * connected upstream that advertises the underlying tool? Mirrors the set
327
+ * exposedToolDefinitions() advertises.
328
+ * @param {string} name
329
+ * @returns {boolean}
330
+ */
331
+ isExposed(name) {
332
+ return this._findExposedExecution(name) !== null;
333
+ }
334
+
335
+ /**
336
+ * Resolve a downstream `as` name + args into an executor. Returns
337
+ * { execute, toolName, upstream } | null
338
+ * where:
339
+ * - execute() invokes the connected client's callTool(<real upstream tool name>, args)
340
+ * - toolName = the downstream `as` (what PreToolUse matchers see — the gate matches
341
+ * the downstream name, execution hits the real upstream tool)
342
+ * - upstream = the upstream id
343
+ * Returns null if `name` is not exposed/enabled or its upstream isn't connected.
344
+ *
345
+ * @param {string} name downstream `as` name
346
+ * @param {any} args structured arguments forwarded verbatim to callTool
347
+ * @returns {{ execute: Function, toolName: string, upstream: string } | null}
348
+ */
349
+ resolveExposedExecution(name, args) {
350
+ const hit = this._findExposedExecution(name);
351
+ if (!hit) return null;
352
+ const { client, realTool, upstream, exposedName } = hit;
353
+ return {
354
+ // The execute thunk is the seam the server calls AFTER the PreToolUse gate allows it.
355
+ execute: this._forwardExecutor(client, realTool, args),
356
+ toolName: exposedName, // downstream name — the gate's matcher target
357
+ upstream,
358
+ };
359
+ }
360
+
361
+ // ── lean register forwarding (slice 2) ──────────────────────────────────────
362
+ // The LEAN path surfaces an attached upstream's tools through the 4 meta-tools (toolfunnel_list_tools
363
+ // / _tool_instructions / _run_tool) instead of injecting them top-level every turn. The full
364
+ // discovered set of every CONNECTED + enabled upstream is surfaced (attaching an MCP makes its
365
+ // tools appear with no per-tool curation); expose[]/hot governs only the SEPARATE top-level
366
+ // (curated-direct) promotion. A lean tool is surfaced under its enabled expose `as` if one exists,
367
+ // else the namespaced default `<upstream>_<tool>` — so the lean name equals the curated-direct name
368
+ // for an exposed tool and ONE PreToolUse rule gates both routes. All of this lives here in the
369
+ // aggregator (it owns the connections, the _clients/_tools caches, and the connect log chokepoint).
370
+
371
+ /**
372
+ * The single forward seam: a thunk that calls the upstream's real tool. Shared by the
373
+ * curated-direct path (resolveExposedExecution, transparent envelope passthrough) and the lean
374
+ * path (resolveLeanExecution, which unwraps). Kept tiny so both routes execute identically.
375
+ * @param {object} client a connected McpClient
376
+ * @param {string} realTool the upstream's own tool name
377
+ * @param {any} args
378
+ * @returns {() => Promise<any>}
379
+ */
380
+ _forwardExecutor(client, realTool, args) {
381
+ return () => client.callTool(realTool, args);
382
+ }
383
+
384
+ /**
385
+ * The downstream name a discovered upstream tool is surfaced under: its ENABLED expose `as` if one
386
+ * exists for (upstream, tool), else the namespaced default `<upstream>_<tool>` (matching
387
+ * expose-store.exposedName). Keeps the lean list and the lean run path in agreement, and aligns an
388
+ * exposed tool's lean name with its curated-direct top-level name. Never throws.
389
+ * @param {string} upstreamId
390
+ * @param {string} toolName
391
+ * @returns {string}
392
+ */
393
+ _surfacedName(upstreamId, toolName) {
394
+ try {
395
+ const exposed = this._store.listExposed({ enabledOnly: true });
396
+ for (const e of exposed) {
397
+ if (e && e.upstream === upstreamId && e.tool === toolName) return this._store.exposedName(e);
398
+ }
399
+ } catch (_e) {
400
+ /* fall through to the default */
401
+ }
402
+ return `${upstreamId}_${toolName}`;
403
+ }
404
+
405
+ /**
406
+ * Connect an upstream ON DEMAND and return the ready McpClient. Idempotent + race-safe:
407
+ * - fast path: a cached client that is still `connected` is returned as-is; a cached-but-DEAD
408
+ * client (its child exited — McpClient flips `connected` but stays in the cache) is logged as a
409
+ * disconnect, discarded, and reconnected — a reconnect re-lists the upstream's tools.
410
+ * - concurrent callers JOIN the same in-flight connect promise (keyed in _connecting), which
411
+ * resolves only AFTER _listAndCache — so no caller ever sees the half-built client that
412
+ * _connectOne caches BEFORE the handshake completes (the double-spawn / mid-handshake race).
413
+ * Throws on an unknown/disabled upstream or a connect/list failure (the caller surfaces it; for the
414
+ * lean run path that means gatedRun returns { ok:false, error } cleanly).
415
+ * @param {string} upstreamId
416
+ * @returns {Promise<object>} the connected McpClient
417
+ */
418
+ async ensureConnected(upstreamId, opts) {
419
+ if (!isNonEmptyString(upstreamId)) throw new Error('Aggregator.ensureConnected: upstreamId (non-empty string) is required');
420
+ // allowConnect:false (the RUN path) must NEVER block on a connect — not a from-scratch connect
421
+ // (connect()+tools/list, ~20s, would freeze the serialized stdio chain and starve pings) AND not
422
+ // by JOINING an in-flight background reconnect (same latency). It returns a live client or fails
423
+ // clean; the background reconnect owns recovery and the run retries next turn. allowConnect
424
+ // defaults true (the background path connects / joins).
425
+ const allowConnect = !(opts && opts.allowConnect === false);
426
+ // A torn-down aggregator (swapped out by reloadExpose, then closeAll'd) must NEVER (re)connect:
427
+ // a captured execute thunk holding this instance would otherwise spawn an ORPHAN child on it.
428
+ if (this._closed) throw new Error('Aggregator.ensureConnected: aggregator is closed (config reloaded) — retry on the current one');
429
+
430
+ // Fast path: a live cached client serves BOTH modes immediately.
431
+ const existing = this._clients.get(upstreamId);
432
+ if (existing) {
433
+ const alive = typeof existing.connected === 'boolean' ? existing.connected : true;
434
+ if (alive) return existing;
435
+ // Dead cached client. The RUN path fails clean (never (re)connects); the background path drops
436
+ // it and reconnects below.
437
+ if (!allowConnect) {
438
+ throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" disconnected (reconnecting in background)`);
439
+ }
440
+ logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'stale' });
441
+ this._discard(upstreamId);
442
+ } else if (!allowConnect) {
443
+ // Not connected. The RUN path neither connects NOR joins an in-flight connect — joining would
444
+ // block the stdio chain for the connect+list latency (the head-of-line freeze this mode avoids).
445
+ throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is not connected (reconnecting in background)`);
446
+ }
447
+
448
+ // Background path only (allowConnect): join an in-flight connect, else connect from scratch. The
449
+ // _connecting promise resolves only AFTER _listAndCache, so _connectOne caching the client before
450
+ // its handshake completes never exposes a half-built client to a joiner.
451
+ if (this._connecting.has(upstreamId)) return this._connecting.get(upstreamId);
452
+
453
+ const upstream = this._store.getUpstream(upstreamId);
454
+ if (!upstream) throw new Error(`Aggregator.ensureConnected: unknown upstream "${upstreamId}"`);
455
+ if (upstream.enabled !== true) throw new Error(`Aggregator.ensureConnected: upstream "${upstreamId}" is disabled`);
456
+
457
+ const promise = (async () => {
458
+ await this._connectOne(upstream); // caches client + tools, logs the connect
459
+ return this._clients.get(upstreamId);
460
+ })();
461
+ this._connecting.set(upstreamId, promise);
462
+ try {
463
+ return await promise;
464
+ } catch (err) {
465
+ this._discard(upstreamId); // reap the half-spawned child; leave no zombie/cache entry
466
+ throw err;
467
+ } finally {
468
+ this._connecting.delete(upstreamId);
469
+ }
470
+ }
471
+
472
+ /**
473
+ * UNEXPECTED upstream death (fired by McpClient.onClose). Log it, drop the dead client so it
474
+ * leaves the lean list, tell the client the tool set shrank, then schedule a BACKGROUND reconnect.
475
+ * Keeping recovery off the serialized stdio chain is what closes the head-of-line regression: a run
476
+ * during the down-window fails clean (ensureConnected allowConnect:false) instead of blocking.
477
+ * @param {string} upstreamId
478
+ */
479
+ _handleUpstreamDown(upstreamId) {
480
+ if (this._closed) return;
481
+ logger.log({ type: 'mcp', event: 'disconnect', upstream: upstreamId, reason: 'died' });
482
+ this._discard(upstreamId); // remove the dead client + its tools → drops from the lean list
483
+ this._signalToolsChanged(upstreamId); // list_changed only if the upstream is on the top-level surface
484
+ this._scheduleReconnect(upstreamId, 0);
485
+ }
486
+
487
+ /**
488
+ * Does this upstream contribute to the TOP-LEVEL tools/list (i.e. has an ENABLED expose[] entry)?
489
+ * Only then does its death/recovery change tools/list and warrant a notifications/tools/list_changed.
490
+ * A lean-only upstream's tools live behind toolfunnel_list_tools (pulled per call), so notifying for
491
+ * one is spurious (the client re-fetches and sees no diff). Never throws.
492
+ * @param {string} upstreamId
493
+ * @returns {boolean}
494
+ */
495
+ _affectsTopLevel(upstreamId) {
496
+ try {
497
+ return this._store.listExposed({ enabledOnly: true }).some((e) => e && e.upstream === upstreamId);
498
+ } catch (_e) {
499
+ return false;
500
+ }
501
+ }
502
+
503
+ /** Fire onToolsChanged ONLY when the upstream is actually on the top-level surface (else the
504
+ * notification is spurious — its lean tools refresh on the next toolfunnel_list_tools). Never throws. */
505
+ _signalToolsChanged(upstreamId) {
506
+ if (!this._affectsTopLevel(upstreamId)) return;
507
+ try { this.onToolsChanged(); } catch (_e) { /* never throw out of an event handler */ }
508
+ }
509
+
510
+ /**
511
+ * Schedule ONE background reconnect attempt with exponential backoff: 1s,2s,4s,8s,16s, then capped
512
+ * at 30s. After the fast phase it does NOT give up — it falls back to a slow 30s keepalive so a long
513
+ * transient outage still self-heals (a detached/disabled upstream stops it via getUpstream; closeAll
514
+ * stops it via _closed). On success the tools are re-listed and a top-level change is signalled. One
515
+ * timer per upstream (no pile-up); the timer is unref'd so it never keeps the process alive. NEVER throws.
516
+ * @param {string} upstreamId
517
+ * @param {number} attempt
518
+ */
519
+ _scheduleReconnect(upstreamId, attempt) {
520
+ if (this._closed) return;
521
+ if (this._reconnectTimers.has(upstreamId)) return; // a reconnect is already pending for this id
522
+ const upstream = this._store.getUpstream(upstreamId);
523
+ if (!upstream || upstream.enabled !== true) return; // detached/disabled — stop trying
524
+ const MAX_FAST_ATTEMPTS = 6; // exponential phase; beyond it, a slow keepalive at the 30s cap
525
+ if (attempt === MAX_FAST_ATTEMPTS) {
526
+ logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: attempt });
527
+ }
528
+ const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
529
+ const timer = setTimeout(async () => {
530
+ this._reconnectTimers.delete(upstreamId);
531
+ if (this._closed) return;
532
+ const u = this._store.getUpstream(upstreamId);
533
+ if (!u || u.enabled !== true) return;
534
+ try {
535
+ await this.ensureConnected(upstreamId); // allowConnect default — connects + re-lists, off-chain
536
+ logger.log({ type: 'mcp', event: 'reconnect', upstream: upstreamId, attempt });
537
+ this._signalToolsChanged(upstreamId);
538
+ } catch (err) {
539
+ logger.log({ type: 'mcp', event: 'reconnect_failed', upstream: upstreamId, attempt, error: errMsg(err) });
540
+ // Keep escalating to the cap, then keep retrying slowly (cap the counter so the delay stays
541
+ // at 30s) — never permanently give up while the upstream stays enabled.
542
+ this._scheduleReconnect(upstreamId, Math.min(attempt + 1, MAX_FAST_ATTEMPTS));
543
+ }
544
+ }, delay);
545
+ if (timer && typeof timer.unref === 'function') timer.unref();
546
+ this._reconnectTimers.set(upstreamId, timer);
547
+ }
548
+
549
+ /**
550
+ * The LEAN tool definitions: every discovered tool of every CONNECTED + enabled upstream, surfaced
551
+ * for toolfunnel_list_tools. Each: { name, description, inputSchema, upstream, tool }. The FULL
552
+ * discovered set (not just expose[]) — attaching an MCP makes its tools appear leanly with no
553
+ * curation. Only connected upstreams contribute (we need their real schemas); a not-yet-connected
554
+ * upstream's tools simply appear once it connects (the next list call reflects it — no
555
+ * notification needed, the lean surface is delivered inside a per-call meta-tool). Never throws.
556
+ * @returns {Array<{name:string, description:string, inputSchema:object, upstream:string, tool:string}>}
557
+ */
558
+ leanToolDefinitions() {
559
+ const defs = [];
560
+ const seen = new Set(); // surfaced-name collision guard — the list must agree with resolve (first wins)
561
+ try {
562
+ for (const [upstreamId, tools] of this._tools) {
563
+ if (!this._clients.has(upstreamId)) continue; // connected only
564
+ const upstream = this._store.getUpstream(upstreamId);
565
+ if (upstream && upstream.enabled === false) continue; // skip an explicitly-disabled upstream
566
+ if (!Array.isArray(tools)) continue;
567
+ for (const t of tools) {
568
+ if (!t || !isNonEmptyString(t.name)) continue;
569
+ const name = this._surfacedName(upstreamId, t.name);
570
+ if (seen.has(name)) {
571
+ // Two tools collapse to one surfaced name (underscore ambiguity, or a duplicate `as`).
572
+ // resolveLeanExecution returns the FIRST match, so advertise only the first — the list
573
+ // must never show a name that resolves to a different tool. Log the shadowing.
574
+ logger.log({ type: 'mcp', event: 'name_collision', upstream: upstreamId, tool: t.name, surfaced: name });
575
+ continue;
576
+ }
577
+ seen.add(name);
578
+ const schema = t.inputSchema && typeof t.inputSchema === 'object' ? t.inputSchema : { type: 'object' };
579
+ defs.push({
580
+ name,
581
+ description: isNonEmptyString(t.description) ? t.description : '',
582
+ inputSchema: cloneJson(schema),
583
+ upstream: upstreamId,
584
+ tool: t.name,
585
+ });
586
+ }
587
+ }
588
+ } catch (_e) {
589
+ /* never throw out of a definition build — return what we have */
590
+ }
591
+ return defs;
592
+ }
593
+
594
+ /**
595
+ * Resolve a lean downstream name + args into a forwarding executor for toolfunnel_run_tool. Matches
596
+ * by ITERATION over discovered tools (computing each tool's surfaced name) — never by parsing the
597
+ * name (underscore-ambiguous). The execute thunk lazy-(re)connects via ensureConnected and UNWRAPS
598
+ * the upstream MCP envelope to a clean payload, so run_tool returns the upstream's content (e.g.
599
+ * "pong"), not a stringified envelope. The connect is INSIDE the thunk → a PreToolUse deny spawns
600
+ * nothing. The gate matches `toolName` (the surfaced name). Returns null if no connected upstream
601
+ * advertises a tool with this surfaced name (→ protocol's clean "not runnable" error).
602
+ * @param {string} name surfaced downstream name
603
+ * @param {any} args
604
+ * @returns {{ execute: () => Promise<any>, toolName: string, upstream: string } | null}
605
+ */
606
+ resolveLeanExecution(name, args) {
607
+ if (!isNonEmptyString(name)) return null;
608
+ for (const [upstreamId, tools] of this._tools) {
609
+ if (!this._clients.has(upstreamId)) continue;
610
+ if (!Array.isArray(tools)) continue;
611
+ for (const t of tools) {
612
+ if (!t || !isNonEmptyString(t.name)) continue;
613
+ if (this._surfacedName(upstreamId, t.name) !== name) continue;
614
+ const realTool = t.name;
615
+ const self = this;
616
+ const execute = async () => {
617
+ // allowConnect:false — the run path NEVER synchronously (re)connects (that would freeze the
618
+ // stdio loop, starving pings). It uses a live client or joins an in-flight background
619
+ // reconnect, else fails clean; the onClose-driven background reconnect owns recovery.
620
+ const client = await self.ensureConnected(upstreamId, { allowConnect: false });
621
+ const envelope = await client.callTool(realTool, args);
622
+ // Preserve the upstream's failure signal. callTool RESOLVES even on isError:true, so a
623
+ // failed upstream call must become a THROW → gatedRun returns { ok:false } → run_tool
624
+ // surfaces isError:true. Otherwise a failed call would be reported as success — and the
625
+ // lean path must agree with curated-direct's isError passthrough.
626
+ const payload = unwrapEnvelope(envelope);
627
+ if (envelope && typeof envelope === 'object' && envelope.isError === true) {
628
+ const text = typeof payload === 'string' ? payload : safeStringify(payload);
629
+ const err = new Error(text && text.length ? text : 'upstream tool reported an error');
630
+ err.upstreamToolError = true;
631
+ throw err;
632
+ }
633
+ return payload;
634
+ };
635
+ return { execute, toolName: name, upstream: upstreamId };
636
+ }
637
+ }
638
+ return null;
639
+ }
640
+
641
+ /**
642
+ * Close every cached client and clear the caches. Idempotent — safe to call twice and
643
+ * safe when nothing was ever connected. NEVER throws (close() on McpClient never throws).
644
+ * @returns {Promise<void>}
645
+ */
646
+ async closeAll() {
647
+ this._closed = true; // latch BEFORE teardown so any in-flight execute thunk refuses to re-spawn
648
+ for (const [, timer] of this._reconnectTimers) {
649
+ try { clearTimeout(timer); } catch (_e) { /* ignore */ }
650
+ }
651
+ this._reconnectTimers.clear();
652
+ for (const [id, client] of this._clients) {
653
+ try {
654
+ if (client && typeof client.close === 'function') client.close();
655
+ // Activity log (self-gating): the disconnect half. A reload closes the old aggregator here,
656
+ // so a reconnect reads as a disconnect line followed by a connect line for the same id.
657
+ logger.log({ type: 'mcp', event: 'disconnect', upstream: id });
658
+ } catch (_e) {
659
+ /* McpClient.close never throws, but never let teardown sink the loop */
660
+ }
661
+ }
662
+ this._clients.clear();
663
+ this._tools.clear();
664
+ }
665
+
666
+ // ── internals ──────────────────────────────────────────────────────────────
667
+
668
+ /**
669
+ * Connect a single upstream entry and cache the client + its discovered tools.
670
+ * Throws on factory (isolation), connect, or listTools failure — callers decide
671
+ * whether to collect (connectAll) or surface (discover) the error. On throw, the
672
+ * caller is responsible for _discard()-ing the id.
673
+ * @param {object} upstream a normalised ExposeStore upstream
674
+ */
675
+ async _connectOne(upstream) {
676
+ // The factory may throw (isolation guard) — that propagates out as a connect failure. Pass an
677
+ // onClose so an UNEXPECTED upstream death triggers a BACKGROUND reconnect (not a lazy one in the
678
+ // run path, which would freeze the stdio chain).
679
+ const client = this._clientFactory(upstream, () => this._handleUpstreamDown(upstream.id));
680
+ if (!client || typeof client.connect !== 'function') {
681
+ throw new Error(`clientFactory returned an invalid client for "${upstream.id}"`);
682
+ }
683
+ // Cache the client BEFORE connect so a connect-failure path can still _discard() and
684
+ // close any half-spawned child.
685
+ this._clients.set(upstream.id, client);
686
+ await client.connect();
687
+ await this._listAndCache(upstream.id);
688
+ // Activity log (self-gating; no-op unless logging is enabled): the upstream half of connect
689
+ // logging. EVERY connect flows through here — startup connectAll, a live reload, a lazy
690
+ // discover, and (later) a health-driven reconnect — so all of them are logged uniformly.
691
+ logger.log({
692
+ type: 'mcp',
693
+ event: 'connect',
694
+ upstream: upstream.id,
695
+ command: upstream.command,
696
+ tools: (this._tools.get(upstream.id) || []).length,
697
+ });
698
+ }
699
+
700
+ /** tools/list one connected upstream and cache the result; returns a clone. */
701
+ async _listAndCache(upstreamId) {
702
+ const client = this._clients.get(upstreamId);
703
+ if (!client) throw new Error(`no connected client for "${upstreamId}"`);
704
+ const tools = await client.listTools();
705
+ const safe = Array.isArray(tools) ? tools.filter((t) => t && isNonEmptyString(t.name)) : [];
706
+ this._tools.set(upstreamId, safe);
707
+ return cloneTools(safe);
708
+ }
709
+
710
+ /** Tear down + forget a cached client (used on a failed connect). Never throws. */
711
+ _discard(upstreamId) {
712
+ const client = this._clients.get(upstreamId);
713
+ if (client && typeof client.close === 'function') {
714
+ try { client.close(); } catch (_e) { /* never throw */ }
715
+ }
716
+ this._clients.delete(upstreamId);
717
+ this._tools.delete(upstreamId);
718
+ }
719
+
720
+ /**
721
+ * Shared lookup behind isExposed / resolveExposedExecution: find the ENABLED expose
722
+ * entry whose downstream name === `name`, whose upstream is connected, and whose real
723
+ * tool the upstream advertises. Returns the resolution context or null.
724
+ * @param {string} name
725
+ * @returns {{ client:object, realTool:string, upstream:string, exposedName:string } | null}
726
+ */
727
+ _findExposedExecution(name) {
728
+ if (!isNonEmptyString(name)) return null;
729
+ let exposed;
730
+ try {
731
+ exposed = this._store.listExposed({ enabledOnly: true });
732
+ } catch (_err) {
733
+ return null;
734
+ }
735
+ for (const e of exposed) {
736
+ if (!e) continue;
737
+ if (this._store.exposedName(e) !== name) continue;
738
+ const client = this._clients.get(e.upstream);
739
+ if (!client) continue; // upstream not connected
740
+ const upstreamTools = this._tools.get(e.upstream) || [];
741
+ if (!upstreamTools.some((t) => t && t.name === e.tool)) continue; // tool not advertised
742
+ return { client, realTool: e.tool, upstream: e.upstream, exposedName: name };
743
+ }
744
+ return null;
745
+ }
746
+ }
747
+
748
+ // ── small helpers (no external deps) ───────────────────────────────────────────
749
+
750
+ /** Extract a clean message from any thrown value. */
751
+ function errMsg(err) {
752
+ return (err && err.message) || String(err);
753
+ }
754
+
755
+ /** JSON.stringify that never throws (circular/oddball values fall back to String). */
756
+ function safeStringify(value) {
757
+ try { return JSON.stringify(value); } catch (_e) { return String(value); }
758
+ }
759
+
760
+ /**
761
+ * Turn an upstream MCP tools/call envelope { content:[...], isError? } into a clean payload for the
762
+ * LEAN run path. A single text block -> its string (so toolfunnel_run_tool returns "pong", matching
763
+ * what the curated-direct content passthrough surfaces); otherwise the content array; otherwise the
764
+ * value as-is when it isn't an envelope. This is what avoids double-wrapping the envelope as JSON
765
+ * inside wrapProtocolResult (the curated-direct path passes the envelope through transparently
766
+ * instead; the lean path goes through protocol.runTool -> wrapProtocolResult, so it unwraps here).
767
+ * NOTE (slice 2): an upstream isError:true is surfaced as its text content with ok:true (homogeneous
768
+ * with how local tools surface error text) — mapping isError -> ok:false is a later concern.
769
+ * @param {*} envelope
770
+ * @returns {*}
771
+ */
772
+ function unwrapEnvelope(envelope) {
773
+ if (!envelope || typeof envelope !== 'object') return envelope;
774
+ const content = envelope.content;
775
+ if (Array.isArray(content)) {
776
+ if (content.length === 1 && content[0] && content[0].type === 'text' && typeof content[0].text === 'string') {
777
+ return content[0].text;
778
+ }
779
+ return content;
780
+ }
781
+ return envelope;
782
+ }
783
+
784
+ /** Plain-JSON deep clone (schemas are JSON; structuredClone may be absent on old node). */
785
+ function cloneJson(value) {
786
+ return JSON.parse(JSON.stringify(value));
787
+ }
788
+
789
+ /** Clone an array of tool defs so callers can't mutate the aggregator's cache. */
790
+ function cloneTools(tools) {
791
+ return Array.isArray(tools) ? tools.map((t) => cloneJson(t)) : [];
792
+ }
793
+
794
+ module.exports = {
795
+ Aggregator,
796
+ // exported for unit tests / reuse
797
+ defaultClientFactory,
798
+ looksLikePath,
799
+ isInside,
800
+ unwrapEnvelope,
801
+ DEFAULT_ROOT,
802
+ };