toolfunnel 0.5.0 → 0.6.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 (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  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 +112 -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 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
package/src/mcp/server.js CHANGED
@@ -1,1405 +1,2688 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * server.js — the HAND-ROLLED minimal MCP server over stdio (see the architecture notes §7).
6
- *
7
- * Runnable as: node src/mcp/server.js
8
- *
9
- * This is the THIN transport. It speaks JSON-RPC 2.0 over stdin/stdout and delegates ALL logic
10
- * to the pure protocol layer (src/mcp/protocol.js). It is NOT an SDK wrapper — there is no
11
- * @modelcontextprotocol/sdk and no new npm dependency; everything here is Node built-ins.
12
- *
13
- * JSON-RPC methods handled (MCP, the subset our test client speaks):
14
- * - "initialize" -> { protocolVersion, serverInfo, capabilities: { tools: {} } }
15
- * - "tools/list" -> { tools: [ ...protocol.toolDefinitions() ] } (the 4 meta-tools;
16
- * a clear seam is left for curated-direct tools — Phase 2)
17
- * - "tools/call" { name, arguments } -> protocol.dispatch(name, arguments), wrapped in the
18
- * MCP tools/call result shape: { content:[{ type:"text", text: JSON }],
19
- * isError? }. On a protocol error result we set isError:true.
20
- * - notifications (no `id`) are acknowledged silently and never answered.
21
- * - any bad / unknown message NEVER crashes the process — it gets a JSON-RPC error object.
22
- *
23
- * Framing: we READ both LSP-style `Content-Length:` framing AND newline-delimited JSON (the test
24
- * client writes Content-Length with a trailing newline; a simple client may send line-delimited),
25
- * and we WRITE newline-delimited JSON (the test reader accepts either framing on the wire). One
26
- * framing on the way out keeps the transport trivial and matches "newline-delimited JSON is fine
27
- * for our test client".
28
- *
29
- * Wiring (see the architecture notes §8 — Phase 1): a real Registry (loaded from
30
- * src/tools/tools.register.json — the canonical tool register), a real HookEngine over the hooks
31
- * manifest, the real gatedRun, and the real howto. A small ADAPTER bridges the register's
32
- * resolveExecution({type,run}) shape to the { execute, toolName, args } shape protocol.runTool
33
- * injects into gatedRun — keeping the pure modules untouched while making `toolfunnel_run_tool`
34
- * actually gate + execute end to end.
35
- *
36
- * CommonJS only. Node built-ins only. No transport SDK.
37
- */
38
-
39
- const fs = require('node:fs');
40
- const path = require('node:path');
41
- const { spawnSync } = require('node:child_process');
42
-
43
- const { makeProtocol } = require('./protocol');
44
- const { gatedRun } = require('./gated-run');
45
- const { Aggregator } = require('./aggregator');
46
- const { loadExposeStore } = require('./expose-store');
47
- const { loadRegistry } = require('../tools/registry');
48
- const { loadToolState, isToolEnabled, isToolHot } = require('../tools/tool-state');
49
- const { loadManifest } = require('../core/hook-loader');
50
- const { HookEngine } = require('../core/hook-engine');
51
- const { howto } = require('../extend/howto');
52
- const logger = require('../core/logger');
53
- const metrics = require('../core/metrics');
54
-
55
- // ── Path anchors (everything stays under the host root) ───────────────────────────────────────
56
- // The CODE lives in the package; the mutable CONFIG lives in the resolved config home — the same
57
- // directory in a git clone, distinct when TOOLFUNNEL_HOME / --config-dir points elsewhere (npm
58
- // installs, npm-wrapped MCPs — an `npm update` must never eat the user's tools). Every anchor
59
- // below derives from the HOME.
60
- const { PKG_ROOT, resolveConfigHome, ensureConfigHome } = require('../core/config-home');
61
- const ROOT = resolveConfigHome(); // <…>/<config home>
62
- // The process-tree contract for child tools (defaultRunScript spawns with process.env): a
63
- // management script SEEDED into an external home finds the engine code via TOOLFUNNEL_PKG, and
64
- // the resolved ABSOLUTE home is written back so no child re-resolves a relative value against a
65
- // different cwd.
66
- process.env.TOOLFUNNEL_PKG = PKG_ROOT;
67
- if (ROOT !== PKG_ROOT) process.env.TOOLFUNNEL_HOME = ROOT;
68
- const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
69
- // The ACTIVE/DISABLED overlay (tool-state.js). toolfunnel_list_tools filters DISABLED tools out so
70
- // they are not surfaced to the client. Read FRESH per list() call so UI toggles take effect with no
71
- // restart. HIDDEN is a manager-list-only axis — it is NEVER consulted here (it must not affect the
72
- // client's view).
73
- const TOOL_STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
74
- const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
75
- const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
76
- // Phase 2: the persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
77
- // aggregator connects to nothing and the curated-direct surface is empty — the server is
78
- // behaviourally identical to Phase 1 until an upstream + expose entry is added.
79
- const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
80
-
81
- const PROTOCOL_VERSION = '2024-11-05';
82
- // Identity comes from the OPTIONAL toolfunnel.json at the root (absent → name "toolfunnel",
83
- // version from package.json — serverInfo can never drift from the released version). The config
84
- // seam is what lets a wrapped MCP introduce itself as ITSELF in the initialize handshake and
85
- // /health. Loaded once at module init: clients cache serverInfo from initialize, so identity is
86
- // deliberately NOT hot-reloaded (a mid-session identity swap would lie to connected clients).
87
- const { loadServerConfig } = require('../core/server-config');
88
- const SERVER_CONFIG = loadServerConfig(ROOT);
89
- const SERVER_INFO = { name: SERVER_CONFIG.serverName, version: SERVER_CONFIG.serverVersion };
90
-
91
- // ── Diagnostics → stderr only (stdout is the JSON-RPC channel; never pollute it). ─────────────
92
- function logErr(...parts) {
93
- try {
94
- process.stderr.write('[toolfunnel] ' + parts.join(' ') + '\n');
95
- } catch (_e) {
96
- /* never let logging throw */
97
- }
98
- }
99
-
100
- // ─────────────────────────────────────────────────────────────────────────────────────────────
101
- // Registry adapter — bridge the real Registry to what protocol.runTool consumes.
102
- //
103
- // protocol.runTool expects: registry.resolveExecution(name, args) -> { execute, toolName, args }
104
- // The real Registry returns: resolveExecution(id, args) -> { type, run }
105
- // - it looks up by `id` only (throws on unknown id),
106
- // - returns `run` (not `execute`),
107
- // - for a SHELL invoke it returns a DEFERRED descriptor instead of running (the gated runner
108
- // is meant to own shell execution — registry.js keeps un-gated shell out of itself by design).
109
- //
110
- // The adapter therefore:
111
- // - resolves the meta-tool `name` to a register id (accepting id OR display name),
112
- // - wraps `run` into an `execute` thunk that returns a clean tool output,
113
- // - for the shell-deferred case, performs the actual shell spawn INSIDE the execute thunk — so
114
- // it only ever runs after gatedRun's PreToolUse gate has allowed it.
115
- // list()/instructions() pass straight through (with the same name→id resolution for instructions).
116
- // ─────────────────────────────────────────────────────────────────────────────────────────────
117
- function makeRegistryAdapter(registry, opts) {
118
- // opts.toolStatePath: when set, list() filters DISABLED tools out (the enabled-filter for
119
- // toolfunnel_list_tools). When absent (the Tool Manager's runOnce adapter, tests) list() is unfiltered.
120
- const toolStatePath = (opts && opts.toolStatePath) || null;
121
- // opts.getAggregator: an OPTIONAL live getter () => Aggregator|null. NEVER a captured instance —
122
- // reloadExpose swaps build.aggregator, so the adapter must read it fresh each call. When absent the
123
- // adapter is local-only, byte-for-byte its pre-slice-2 behaviour (preserves the runOnce adapter + tests).
124
- const getAggregator = (opts && typeof opts.getAggregator === 'function') ? opts.getAggregator : null;
125
-
126
- /** The live aggregator, or null. Defensive: a throwing/absent getter yields null (local-only). */
127
- function liveAggregator() {
128
- if (!getAggregator) return null;
129
- try { const a = getAggregator(); return a && typeof a === 'object' ? a : null; } catch (_e) { return null; }
130
- }
131
-
132
- /** Resolve a meta-tool name (which may be a display name) to a register id. */
133
- function resolveId(nameOrId) {
134
- if (typeof nameOrId !== 'string' || nameOrId.length === 0) return null;
135
- if (registry.has(nameOrId)) return nameOrId; // it's already an id
136
- // Fall back to matching the display name.
137
- const hit = registry.list().find((b) => b.name === nameOrId);
138
- return hit ? hit.id : null;
139
- }
140
-
141
- /** Run a shell command synchronously, normalising to { ok, code, stdout, stderr }. */
142
- function runShell(command, args) {
143
- const res = spawnSync(command, {
144
- shell: true,
145
- cwd: ROOT,
146
- env: Object.assign({}, process.env, { TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args == null ? null : args) }),
147
- encoding: 'utf8',
148
- windowsHide: true,
149
- });
150
- if (res.error) throw new Error(`shell spawn failed: ${res.error.message}`);
151
- const code = typeof res.status === 'number' ? res.status : -1;
152
- return {
153
- ok: code === 0,
154
- code,
155
- stdout: res.stdout != null ? String(res.stdout) : '',
156
- stderr: res.stderr != null ? String(res.stderr) : '',
157
- };
158
- }
159
-
160
- return {
161
- // Briefs only (protocol forwards { filter, category }). When a tool-state overlay is configured,
162
- // DISABLED (✗) tools are dropped so toolfunnel_list_tools never surfaces them to the client —
163
- // read FRESH each call so a UI toggle takes effect immediately, no host restart. HIDDEN (👁) is
164
- // UI-only and is deliberately NOT consulted here (it must never change what the client sees).
165
- list(listOpts) {
166
- const o = listOpts || {};
167
- let local = registry.list(o);
168
- const state = toolStatePath ? loadToolState(toolStatePath) : null;
169
- if (state) local = local.filter((b) => isToolEnabled(state, b.id));
170
-
171
- const agg = liveAggregator();
172
- if (!agg || typeof agg.leanToolDefinitions !== 'function') return local;
173
- // Append the LEAN upstream tools (the full discovered set of connected upstreams), then:
174
- // - drop any whose name is a local tool (local-register WINS — matches resolveExecution),
175
- // - apply the SAME {filter,category} registry.list applied (parity), and
176
- // - apply the SAME tool-state enable filter local tools get, so the lean list is CURATABLE
177
- // per-tool: disable an upstream tool by its surfaced id, exactly like a local tool.
178
- let upstream = [];
179
- try {
180
- upstream = agg.leanToolDefinitions()
181
- .map((d) => ({ id: d.name, name: d.name, summary: d.description || '', category: `mcp:${d.upstream}` }))
182
- .filter((b) => resolveId(b.id) === null)
183
- .filter((b) => briefMatches(b, o))
184
- .filter((b) => !state || isToolEnabled(state, b.id));
185
- } catch (_e) {
186
- upstream = []; // a throwing aggregator must never sink toolfunnel_list_tools — local-only fallback
187
- }
188
- return local.concat(upstream);
189
- },
190
-
191
- // Full instructions for one tool (by id or display name). Registry.instructions throws on an
192
- // unknown id; protocol.toolInstructions catches that and returns a clean error result.
193
- instructions(nameOrId) {
194
- const id = resolveId(nameOrId);
195
- if (id) return registry.instructions(id);
196
- // Not a local tool — synthesise instructions for a lean upstream tool from its discovered def.
197
- const agg = liveAggregator();
198
- if (!agg || typeof agg.leanToolDefinitions !== 'function') return null;
199
- try {
200
- const def = agg.leanToolDefinitions().find((d) => d.name === nameOrId);
201
- return def ? renderUpstreamInstructions(def) : null; // null → protocol's "no tool named …"
202
- } catch (_e) {
203
- return null;
204
- }
205
- },
206
-
207
- // Build the { execute, toolName, args, mode } resolution protocol.runTool hands to gatedRun.
208
- resolveExecution(nameOrId, args) {
209
- const id = resolveId(nameOrId);
210
- if (id) {
211
- // LOCAL tool — byte-for-byte the pre-slice-2 path. A local name that resolves but isn't
212
- // runnable returns null and does NOT fall through to upstream (local-register WINS: a local
213
- // name never silently resolves to a remote upstream).
214
- const entry = registry.getEntry(id); // { id, name, summary, category, instructions, invoke, mode? }
215
- const desc = registry.resolveExecution(id, args); // { type, mode, run? } | reference: { type, mode, instructions }
216
- if (!desc) return null;
217
-
218
- // Reference mode: nothing executes here. Hand back the instructions (no execute thunk)
219
- // so protocol.runTool takes the no-spawn path (execute() is never called). Note this is
220
- // NOT ungated: protocol.runTool still fires PreToolUse on the instruction handoff, so a
221
- // deny withholds the instructions. What's absent is server-side EXECUTION, not the gate.
222
- if (desc.mode === 'reference') {
223
- return {
224
- mode: 'reference',
225
- toolName: entry.name || id,
226
- instructions: typeof desc.instructions === 'string' ? desc.instructions : (entry.instructions || ''),
227
- };
228
- }
229
-
230
- if (typeof desc.run !== 'function') return null;
231
-
232
- const execute = async () => {
233
- const out = await desc.run();
234
- // Shell invokes come back deferred — the registry hands shell execution to the gated path.
235
- if (out && out.deferred === true && out.type === 'shell') {
236
- return runShell(out.command, out.args);
237
- }
238
- return out;
239
- };
240
-
241
- return { execute, toolName: entry.name || id, args: args == null ? {} : args, mode: desc.mode || 'gateway' };
242
- }
243
-
244
- // No local tool by this name — try the LEAN upstream forward. resolveLeanExecution returns a
245
- // resolution whose execute thunk lazy-(re)connects + unwraps; the gate matches fwd.toolName (the
246
- // surfaced name). mode is 'gateway' so it goes THROUGH gatedRun — never the reference handoff.
247
- const agg = liveAggregator();
248
- if (!agg || typeof agg.resolveLeanExecution !== 'function') return null;
249
- let fwd = null;
250
- try { fwd = agg.resolveLeanExecution(nameOrId, args); } catch (_e) { fwd = null; }
251
- if (!fwd || typeof fwd.execute !== 'function') return null;
252
- return { execute: fwd.execute, toolName: fwd.toolName, args: args == null ? {} : args, mode: 'gateway' };
253
- },
254
- };
255
- }
256
-
257
- // ── Build the protocol + aggregator (the meta-tool logic + the curated-direct seam). ──────────
258
- // All wiring failures are caught so the server still starts and reports a clean JSON-RPC error
259
- // instead of dying — a dead server hangs the client (and the test).
260
- //
261
- // Returns an OBJECT, not just the protocol, because the Phase-2 curated-direct call path needs
262
- // more than the protocol:
263
- // { protocol, aggregator, engine, ctx }
264
- // - protocol : the 4 meta-tools (unchanged Phase-1 logic) — callers needing just the
265
- // protocol read it off `.protocol` (backward compat).
266
- // - aggregator : the upstream-MCP connection + curated-expose set (built from expose.json).
267
- // Default expose.json is EMPTY so it connects to nothing.
268
- // - engine, ctx: threaded into gatedRun for the curated-direct path (a curated tool runs
269
- // THROUGH the same PreToolUse gate as toolfunnel_run_tool — the safety invariant).
270
- function buildProtocol() {
271
- // Make the config home real before anything reads from it: seeds an external home's register/
272
- // scripts/expose/hooks from the shipped defaults on first use. A no-op when home === package
273
- // root; idempotent and never-overwriting afterwards. A failure falls through to loadRegistry's
274
- // own clear error.
275
- try { ensureConfigHome(ROOT); } catch (err) { logErr('config-home init failed:', (err && err.message) || String(err)); }
276
- const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
277
- const loader = loadManifest(MANIFEST_PATH);
278
-
279
- // The hook context every gated run carries (the common hook fields).
280
- const ctx = {
281
- session_id: `toolfunnel-mcp-${process.pid}`,
282
- transcript_path: '',
283
- cwd: ROOT,
284
- };
285
-
286
- // The LIVE build holder, created FIRST so both the adapter and the protocol read the CURRENT
287
- // aggregator/engine after a hot-reload swaps them in place (reloadExpose mutates build.aggregator,
288
- // reloadHooks mutates build.engine). main() threads this SAME object into the stdio loop and the
289
- // watchers, so nothing below captures a stale instance.
290
- // - registry : the raw Registry, so handleToolsList can build a full MCP def (with the
291
- // entry's inputSchema) for a LOCAL tool promoted hot to the top-level surface.
292
- // - toolStatePath : the per-tool overlay path, read FRESH per tools/list & tools/call so a UI
293
- // hot/enabled toggle takes effect with no restart (the visibility MATRIX).
294
- const build = { protocol: null, aggregator: null, engine: null, ctx, registry, toolStatePath: TOOL_STATE_PATH };
295
-
296
- // Engine PROXY: protocol/gatedRun only need engine.fire. Delegating to build.engine means a
297
- // reloadHooks swap gates the lean-forward (and local run_tool) path with the NEW engine, exactly
298
- // like curated-direct (which reads build.engine live via deps). Without this the protocol would
299
- // capture the engine at construction and a runtime hook change would gate one route, not the other.
300
- const engineProxy = { fire: (event, hookCtx, extra) => build.engine.fire(event, hookCtx, extra) };
301
-
302
- // The adapter reads the aggregator through a LIVE getter (never a captured instance).
303
- const registryAdapter = makeRegistryAdapter(registry, {
304
- toolStatePath: TOOL_STATE_PATH,
305
- getAggregator: () => build.aggregator,
306
- });
307
-
308
- build.protocol = makeProtocol({
309
- registry: registryAdapter,
310
- gatedRun,
311
- engine: engineProxy,
312
- ctx,
313
- howto,
314
- });
315
- build.engine = new HookEngine(loader, { cwd: ROOT });
316
-
317
- // Phase 2: the aggregator over expose.json (EMPTY by default → nothing connects).
318
- // loadExposeStore on a missing/empty file returns an empty store; Aggregator over an empty store
319
- // advertises no curated-direct tools and connectAll() is an instant no-op.
320
- const store = loadExposeStore(EXPOSE_PATH);
321
- build.aggregator = new Aggregator({ store, v3Root: ROOT });
322
-
323
- return build;
324
- }
325
-
326
- // ─────────────────────────────────────────────────────────────────────────────────────────────
327
- // JSON-RPC 2.0 helpers.
328
- // ─────────────────────────────────────────────────────────────────────────────────────────────
329
- const JSONRPC = '2.0';
330
- // Standard JSON-RPC error codes.
331
- const ERR = Object.freeze({
332
- PARSE: -32700,
333
- INVALID_REQUEST: -32600,
334
- METHOD_NOT_FOUND: -32601,
335
- INVALID_PARAMS: -32602,
336
- INTERNAL: -32603,
337
- });
338
-
339
- function makeResult(id, result) {
340
- return { jsonrpc: JSONRPC, id, result };
341
- }
342
- function makeError(id, code, message, data) {
343
- const error = { code, message };
344
- if (data !== undefined) error.data = data;
345
- // A null id is valid for errors where the request id couldn't be determined.
346
- return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
347
- }
348
-
349
- // ── MCP method handlers. ──────────────────────────────────────────────────────────────────────
350
- function handleInitialize(params) {
351
- // protocolVersion + serverInfo + capabilities { tools:{} } (see the architecture notes §7,
352
- // mcp-server.test.js asserts a `tools` capability + a protocolVersion).
353
- // Activity log (self-gating; no-op unless logging is enabled): the CLIENT-connected half of
354
- // connect logging — the aggregator logs the upstream half. Wrapped though logger.log never throws.
355
- try {
356
- const info = params && params.clientInfo;
357
- logger.log({
358
- type: 'client',
359
- event: 'connect',
360
- client: info && typeof info.name === 'string' ? info.name : 'unknown',
361
- protocolVersion: params && typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined,
362
- });
363
- } catch (_e) { /* never let logging affect the handshake */ }
364
- return {
365
- protocolVersion: PROTOCOL_VERSION,
366
- serverInfo: SERVER_INFO,
367
- capabilities: {
368
- // We are a tool host. `tools: {}` declares the capability. Phase 2 wires curated-direct
369
- // hot-updates, so we now advertise listChanged:true — the server may emit
370
- // notifications/tools/list_changed when the curated-direct expose set changes (see
371
- // emitToolsListChanged). The mcp-server.test.js assertion only requires a `tools`
372
- // capability key + a protocolVersion, both still present.
373
- tools: { listChanged: true },
374
- },
375
- };
376
- }
377
-
378
- function handleToolsList(protocol, aggregator, opts) {
379
- // The TOP-LEVEL surface (tools/list — injected EVERY turn) is assembled from the per-tool `hot`
380
- // axis of the visibility MATRIX, deduped by name with a fixed precedence (first writer wins):
381
- //
382
- // 1. META-TOOLS hot defaults TRUE — the 4 management tools; a meta-tool can be HIDDEN
383
- // from the top level (state[name].hot === false).
384
- // 2. LOCAL hot tools hot defaults FALSE — a register tool promoted hot (and enabled); its def
385
- // carries the entry's inputSchema (or {type:object}).
386
- // 3. CURATED-DIRECT the existing expose[] path (an enabled expose entry == top-level).
387
- // 4. UPSTREAM hot tools hot defaults FALSE — a connected upstream tool promoted hot by its
388
- // surfaced name (lean run semantics; not via expose[]).
389
- //
390
- // Precedence is local-register-wins over an upstream of the same name (matches the lean rule), and
391
- // a META-tool can NEVER be shadowed by a local/upstream tool of the same name (safety). With the
392
- // default EMPTY state + EMPTY expose.json this is exactly the 4 meta-tools — byte-identical to the
393
- // pre-matrix surface. `opts` is OPTIONAL: a Phase-1 / bare-protocol caller (no registry/state) gets
394
- // meta-tools (all hot) + curated-direct, the prior behaviour. NEVER throws.
395
- const o = opts || {};
396
- const registry = o.registry || null;
397
- const statePath = typeof o.toolStatePath === 'string' && o.toolStatePath ? o.toolStatePath : null;
398
- let state = {};
399
- if (statePath) { try { state = loadToolState(statePath); } catch (_e) { state = {}; } }
400
-
401
- const byName = new Map(); // name -> MCP tool def; first writer wins (the precedence above)
402
-
403
- // The 4 meta-tool names are RESERVED: nothing else may be advertised under them, even when a meta
404
- // is hidden (else a curated-direct/upstream tool aliased onto a meta name would be advertised yet
405
- // uncallable — handleToolsCall routes a meta name to the meta gate first).
406
- const metaNames = new Set(protocol.toolDefinitions().map((d) => d && d.name));
407
-
408
- // 1. META-TOOLS — hot by default; hideable via state[name].hot === false (footgun: hiding all is
409
- // the "ordinary tools as an MCP" pattern — warned below + at the write site).
410
- for (const def of protocol.toolDefinitions()) {
411
- if (def && isToolHot(state, def.name, true) && !byName.has(def.name)) byName.set(def.name, def);
412
- }
413
-
414
- // 2. LOCAL hot tools — opt-in (hot:true) AND enabled. A real MCP def from the register entry.
415
- if (registry) {
416
- for (const def of localHotDefinitions(registry, state)) {
417
- if (def && !byName.has(def.name)) byName.set(def.name, def);
418
- }
419
- }
420
-
421
- // 3. CURATED-DIRECT (expose[]) — unchanged. exposedToolDefinitions never throws, but guard anyway.
422
- // Skip any def colliding with a RESERVED meta name (never advertise a phantom uncallable tool).
423
- if (aggregator && typeof aggregator.exposedToolDefinitions === 'function') {
424
- try {
425
- const defs = aggregator.exposedToolDefinitions();
426
- if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name)) byName.set(def.name, def);
427
- } catch (err) {
428
- logErr('exposedToolDefinitions failed:', (err && err.message) || String(err));
429
- }
430
- }
431
-
432
- // 4. UPSTREAM hot tools — a connected upstream tool promoted hot by its surfaced name (and enabled),
433
- // not already surfaced. Skip a name that is RESERVED (meta) or TAKEN by a local tool's id/display
434
- // name — local-register-wins, AND it keeps the advertised name resolving to what actually runs
435
- // (a local display-name collision would otherwise advertise the upstream but run the local tool).
436
- if (aggregator && typeof aggregator.leanToolDefinitions === 'function') {
437
- try {
438
- for (const d of aggregator.leanToolDefinitions()) {
439
- if (!d || byName.has(d.name) || metaNames.has(d.name) || localNameTaken(registry, d.name)) continue;
440
- if (isToolHot(state, d.name, false) && isToolEnabled(state, d.name)) {
441
- byName.set(d.name, { name: d.name, description: d.description || '', inputSchema: d.inputSchema || { type: 'object' } });
442
- }
443
- }
444
- } catch (err) {
445
- logErr('leanToolDefinitions (hot) failed:', (err && err.message) || String(err));
446
- }
447
- }
448
-
449
- // Footgun warning (server-side, throttled): if NO meta-tool is on the surface, in-band tool
450
- // discovery/run is gone — recovery is the --ui console or hand-editing tools/tools.state.json.
451
- const metaPresent = [...metaNames].some((n) => byName.has(n));
452
- if (!metaPresent && !_noMetaWarned) {
453
- logErr('WARNING: all meta-tools are hidden (hot:false) — no in-band tool discovery/run remains. Recover via `--ui` or by editing tools/tools.state.json.');
454
- _noMetaWarned = true;
455
- } else if (metaPresent) {
456
- _noMetaWarned = false;
457
- }
458
-
459
- return { tools: [...byName.values()] };
460
- }
461
-
462
- /** Module flag so the all-metas-hidden warning logs ONCE per episode (not on every tools/list). */
463
- let _noMetaWarned = false;
464
-
465
- /** Is `name` already a LOCAL register tool's id OR display name? Used to keep the lean-rule
466
- * "local-register wins" consistent on the top-level surface (and so an advertised upstream name is
467
- * never run as a local tool via the run path's display-name resolution). NEVER throws. */
468
- function localNameTaken(registry, name) {
469
- if (!registry) return false;
470
- try {
471
- return registry.list().some((b) => b && (b.id === name || b.name === name));
472
- } catch (_e) {
473
- return false;
474
- }
475
- }
476
-
477
- /**
478
- * The MCP tool definitions for every LOCAL register tool promoted HOT (and enabled). Each is built
479
- * from the register entry so a directly-callable top-level tool carries a real input schema:
480
- * - name = the register `id` (an identifier-clean MCP tool name; the human `name` may carry
481
- * spaces, which is a poor MCP tool name). The id is also what toolfunnel_run_tool
482
- * resolves, and the PreToolUse gate fires on `entry.name || id` for BOTH the direct
483
- * call and a run_tool call (the adapter's resolveExecution sets that toolName), so a
484
- * gate authored via the UI — which keys the matcher on that same `entry.name || id`
485
- * — gates both routes identically. No new gate-naming footgun vs run_tool today.
486
- * - description = the entry summary (falls back to the human name, then the id).
487
- * - inputSchema = the entry's optional `inputSchema` object, else a free-form {type:object}.
488
- * NEVER throws.
489
- * @param {object} registry the raw Registry
490
- * @param {object} state the loaded tool-state overlay
491
- * @returns {Array<{name:string, description:string, inputSchema:object}>}
492
- */
493
- function localHotDefinitions(registry, state) {
494
- const out = [];
495
- let briefs;
496
- try { briefs = registry.list(); } catch (_e) { return out; }
497
- for (const b of briefs) {
498
- if (!b || typeof b.id !== 'string') continue;
499
- if (!isToolHot(state, b.id, false)) continue; // opt-in only (default off)
500
- if (!isToolEnabled(state, b.id)) continue; // a disabled tool is never hot
501
- let entry;
502
- try { entry = registry.getEntry(b.id); } catch (_e) { continue; }
503
- out.push(localToolDefinition(entry));
504
- }
505
- return out;
506
- }
507
-
508
- /** Build one top-level MCP def from a register entry. name = the id (clean identifier). description
509
- * = summary || name || id. inputSchema = entry.inputSchema when an object, else free-form {type:object}. */
510
- function localToolDefinition(entry) {
511
- const schema = entry && entry.inputSchema && typeof entry.inputSchema === 'object' && !Array.isArray(entry.inputSchema)
512
- ? entry.inputSchema
513
- : { type: 'object' };
514
- const description = (entry && typeof entry.summary === 'string' && entry.summary)
515
- ? entry.summary
516
- : ((entry && typeof entry.name === 'string' && entry.name) ? entry.name : entry.id);
517
- return { name: entry.id, description, inputSchema: schema };
518
- }
519
-
520
- async function handleToolsCall(protocol, aggregator, deps, params) {
521
- const p = params || {};
522
- const name = p.name;
523
- const args = p.arguments;
524
- if (typeof name !== 'string' || name.length === 0) {
525
- // Shape the bad-params case as an MCP tools/call error result (isError) rather than a
526
- // protocol-level JSON-RPC error, so the model sees a usable message.
527
- return {
528
- content: [{ type: 'text', text: 'tools/call requires a string "name".' }],
529
- isError: true,
530
- };
531
- }
532
-
533
- // ── Routing (see the architecture notes §2; the curated-direct invariant in §9; the MATRIX) ────
534
- // 1. A meta-tool name → protocol.dispatch (unchanged; a meta-tool can NEVER be shadowed).
535
- // 2. ELSE a LOCAL tool promoted HOT, called directly → run it exactly like toolfunnel_run_tool
536
- // {name,args}: ONE path, reusing the PreToolUse gate + reference-mode + register resolution.
537
- // Local-register wins over a curated-direct of the same name (the lean rule + list precedence).
538
- // 3. ELSE a curated-direct tool (expose[]) → run THROUGH gatedRun with transparent upstream-
539
- // envelope passthrough. THE INVARIANT: it can NEVER reach the upstream without the gate.
540
- // 4. ELSE an UPSTREAM tool promoted HOT (by surfaced name, not via expose[]), called directly →
541
- // run it like toolfunnel_run_tool: the adapter falls through to the aggregator's lean forward,
542
- // so the gate fires on the surfaced name and the upstream envelope is unwrapped.
543
- // 5. ELSE → protocol.dispatch, which returns the clean unknown-tool error.
544
- // Only tools ACTUALLY advertised hot are directly callable (the advertised surface == the callable
545
- // surface — a non-promoted tool called directly gets the clean unknown-tool error, not a silent run).
546
- // Load the tool-state overlay ONCE for the routing checks — fresh per call so a UI toggle is live.
547
- const d0 = deps || {};
548
- let state = {};
549
- if (d0.toolStatePath) { try { state = loadToolState(d0.toolStatePath); } catch (_e) { state = {}; } }
550
-
551
- // 1. A meta-tool — but callable ONLY while it is on the advertised top-level surface (hot, default
552
- // true). A meta HIDDEN via hot:false is dropped from tools/list AND becomes uncallable, so the
553
- // callable surface == the advertised surface even for the meta-tools — the "ordinary tools as an
554
- // MCP" lockdown is REAL, not cosmetic. (A meta-tool is still never SHADOWED by a same-named
555
- // local/upstream tool: this check runs FIRST, so a hot meta always wins precedence.)
556
- if (isMetaTool(protocol, name)) {
557
- if (isToolHot(state, name, true)) {
558
- return wrapProtocolResult(await protocol.dispatch(name, args || {}));
559
- }
560
- // Hidden meta-tool: not advertised, so not callable — the clean unknown-tool error (never run it).
561
- return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
562
- }
563
-
564
- if (d0.registry && isPromotedLocal(d0.registry, state, name)) {
565
- return wrapProtocolResult(await protocol.runTool({ name, args: args == null ? {} : args }));
566
- }
567
-
568
- if (aggregator && typeof aggregator.isExposed === 'function' && aggregator.isExposed(name)) {
569
- return await runCuratedDirect(aggregator, deps, name, args);
570
- }
571
-
572
- if (isPromotedUpstream(aggregator, d0.registry, state, name)) {
573
- return wrapProtocolResult(await protocol.runTool({ name, args: args == null ? {} : args }));
574
- }
575
-
576
- // Not a meta-tool, not promoted, not exposed → let the protocol return its clean unknown-tool error.
577
- return wrapProtocolResult(await protocol.dispatch(name, args || {}));
578
- }
579
-
580
- /**
581
- * Is `name` a LOCAL register tool that is promoted HOT and enabled (so it's on the top-level surface
582
- * AND therefore directly callable)? Resolves the advertised name (entry.name || id) back to the
583
- * register entry, then checks the hot+enabled flags by the tool's id (the state key). NEVER throws.
584
- */
585
- function isPromotedLocal(registry, state, name) {
586
- const entry = localEntryByAdvertisedName(registry, name);
587
- if (!entry) return false;
588
- return isToolHot(state, entry.id, false) && isToolEnabled(state, entry.id);
589
- }
590
-
591
- /** Resolve a top-level call name back to its register brief. The advertised top-level name is the
592
- * `id`; the human `name` is accepted too (forgiving callers). Returns the brief {id,name,...} or
593
- * null. NEVER throws. */
594
- function localEntryByAdvertisedName(registry, name) {
595
- try {
596
- const briefs = registry.list();
597
- let hit = briefs.find((b) => b && b.id === name);
598
- if (!hit) hit = briefs.find((b) => b && b.name === name);
599
- return hit || null;
600
- } catch (_e) {
601
- return null;
602
- }
603
- }
604
-
605
- /**
606
- * Is `name` a connected UPSTREAM tool (by surfaced name) promoted HOT and enabled? Only reached for a
607
- * name that is NOT a meta-tool, NOT a promoted local tool, and NOT curated-direct (those are routed
608
- * first), so this is the "hot upstream, not via expose[]" case. NEVER throws.
609
- */
610
- function isPromotedUpstream(aggregator, registry, state, name) {
611
- if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') return false;
612
- // local-register wins: a name that is a local tool's id/display name is NOT an upstream-hot route,
613
- // even if an upstream surfaces that name (it would otherwise run the LOCAL tool via the run path's
614
- // display-name resolution — advertised-upstream / executed-local). Matches handleToolsList step 4.
615
- if (localNameTaken(registry, name)) return false;
616
- try {
617
- const d = aggregator.leanToolDefinitions().find((x) => x && x.name === name);
618
- if (!d) return false;
619
- return isToolHot(state, d.name, false) && isToolEnabled(state, d.name);
620
- } catch (_e) {
621
- return false;
622
- }
623
- }
624
-
625
- /** Is `name` one of the 4 lean meta-tools the protocol owns? */
626
- function isMetaTool(protocol, name) {
627
- // protocol.META_TOOLS is { LIST, INSTRUCTIONS, RUN, HOWTO } → string values.
628
- const meta = protocol && protocol.META_TOOLS;
629
- if (!meta || typeof meta !== 'object') return false;
630
- for (const key of Object.keys(meta)) {
631
- if (meta[key] === name) return true;
632
- }
633
- return false;
634
- }
635
-
636
- /**
637
- * Run a CURATED-DIRECT tool through the hook gate. Resolve {execute} from the aggregator, then
638
- * hand it to gatedRun with the threaded engine + ctx so PreToolUse fires on the DOWNSTREAM name
639
- * (the gate's matcher target) BEFORE the upstream tool is ever invoked. The result is wrapped in
640
- * the MCP tools/call envelope: isError when !ok, surfacing reason (a PreToolUse block) or error
641
- * (a thrown upstream call). NEVER throws — a misbehaving aggregator/gate becomes a clean error
642
- * result so the loop survives.
643
- */
644
- async function runCuratedDirect(aggregator, deps, name, args) {
645
- const d = deps || {};
646
- // resolveExposedExecution → { execute, toolName, upstream } | null. It can change between the
647
- // isExposed() check and here only if the expose set mutated mid-call; guard for null.
648
- let resolution = null;
649
- try {
650
- resolution = aggregator.resolveExposedExecution(name, args);
651
- } catch (err) {
652
- return {
653
- content: [{ type: 'text', text: `curated tool "${name}" failed to resolve: ${(err && err.message) || String(err)}` }],
654
- isError: true,
655
- };
656
- }
657
- if (!resolution || typeof resolution.execute !== 'function') {
658
- // Raced away (or never really runnable) — surface a clean error rather than reaching upstream.
659
- return {
660
- content: [{ type: 'text', text: `curated tool "${name}" is no longer runnable` }],
661
- isError: true,
662
- };
663
- }
664
-
665
- // The gate matches on the DOWNSTREAM name (resolution.toolName === the `as` name). gatedRun is
666
- // contracted never to throw, but we still try/catch so a wiring bug can't crash the loop.
667
- let result;
668
- try {
669
- result = await gatedRun({
670
- engine: d.engine,
671
- ctx: d.ctx,
672
- toolName: resolution.toolName || name,
673
- args: args == null ? {} : args,
674
- execute: resolution.execute,
675
- });
676
- } catch (err) {
677
- return {
678
- content: [{ type: 'text', text: `gated execution of "${name}" failed: ${(err && err.message) || String(err)}` }],
679
- isError: true,
680
- };
681
- }
682
-
683
- // Gate-level failure (a PreToolUse block, or a thrown upstream call): surface the most
684
- // informative message — a block carries `reason`, a thrown call carries `error`.
685
- if (!(result && result.ok === true)) {
686
- const message = firstString(
687
- result && result.error && (result.error.message || String(result.error)),
688
- result && result.reason,
689
- 'curated tool call failed'
690
- );
691
- return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
692
- }
693
-
694
- // Success: result.output IS the upstream's own tools/call envelope ({ content, isError }).
695
- // Pass it THROUGH transparently so the client receives the upstream's real content blocks
696
- // (text / image / etc.) AND its isError flag — rather than the whole envelope JSON-stringified
697
- // into one text block (which also used to swallow the upstream's isError). runCuratedDirect only
698
- // ever runs FORWARDED upstream tools, so result.output is always an MCP envelope here.
699
- const out = result.output;
700
- if (out && typeof out === 'object' && Array.isArray(out.content)) {
701
- return { content: out.content, isError: out.isError === true };
702
- }
703
- // Defensive fallback: a non-standard upstream payload — wrap it as text so the client still gets
704
- // a well-formed envelope rather than an empty/invalid content array.
705
- return { content: [{ type: 'text', text: stringifyContent(out) }], isError: false };
706
- }
707
-
708
- /**
709
- * Wrap a protocol dispatch result ({ ok, output, error?, reason? }) in the MCP tools/call
710
- * envelope. Extracted so the meta-tool path and the unknown-tool path share one shaping.
711
- */
712
- function wrapProtocolResult(result) {
713
- const isError = !(result && result.ok === true);
714
- // On error surface the most informative message (a PreToolUse block carries `reason`, a thrown
715
- // tool carries `error`); on success serialise the output payload.
716
- let payload;
717
- if (isError) {
718
- payload = firstString(
719
- // gatedRun returns `error` as an Error INSTANCE on a thrown tool — extract its message
720
- // (firstString only takes strings, so a bare Error would be dropped → generic text).
721
- // Mirrors runCuratedDirect's error shaping so the two paths report identically.
722
- result && result.error && (result.error.message || String(result.error)),
723
- result && result.reason,
724
- 'tool call failed'
725
- );
726
- } else if (result && result.output !== undefined) {
727
- payload = result.output;
728
- } else if (result && result.mode === 'reference') {
729
- // A reference HANDOFF carries no `output` — its `instructions` ARE the payload. Surface them
730
- // (with the handoff message) so a reference tool returns usable text whether reached via
731
- // toolfunnel_run_tool OR called directly after being promoted hot (the matrix). Falls back to
732
- // the whole result object if neither field is a string.
733
- payload = firstString(result.instructions, result.message) || result;
734
- } else {
735
- payload = result && result.output; // undefined → stringifyContent guards it to a string
736
- }
737
-
738
- return {
739
- content: [{ type: 'text', text: stringifyContent(payload) }],
740
- isError,
741
- };
742
- }
743
-
744
- /** Return the first argument that is a non-empty string (used to pick the best error message). */
745
- function firstString(...candidates) {
746
- for (const c of candidates) {
747
- if (typeof c === 'string' && c.length > 0) return c;
748
- }
749
- return '';
750
- }
751
-
752
- /** Serialise a protocol payload into a text block. Strings pass through; everything else → JSON.
753
- * NEVER returns a non-string: JSON.stringify(undefined) / a function yields `undefined`, so guard it
754
- * (an MCP content block's `text` must be a string — an undefined text is a malformed envelope). */
755
- function stringifyContent(value) {
756
- if (typeof value === 'string') return value;
757
- try {
758
- const s = JSON.stringify(value);
759
- return typeof s === 'string' ? s : String(value);
760
- } catch (_e) {
761
- return String(value);
762
- }
763
- }
764
-
765
- /**
766
- * Filter predicate replicating Registry.list's brief filtering (src/tools/registry.js:211-228) so
767
- * LEAN upstream briefs filter IDENTICALLY to local briefs: exact `category` match + case-insensitive
768
- * substring `filter` over `id name summary`. Kept in lockstep with registry.list (a parity test covers
769
- * it); if registry.list's filter semantics ever change, change this too.
770
- */
771
- function briefMatches(b, opts) {
772
- const { filter, category } = opts || {};
773
- if (category && b.category !== category) return false;
774
- if (typeof filter === 'string' && filter.length) {
775
- const hay = `${b.id} ${b.name} ${b.summary || ''}`.toLowerCase();
776
- if (!hay.includes(filter.toLowerCase())) return false;
777
- }
778
- return true;
779
- }
780
-
781
- /**
782
- * Render a lean upstream tool's instructions as a STRING (homogeneous with registry.instructions),
783
- * synthesised from its discovered def { name, description, inputSchema, upstream, tool }: the exact
784
- * toolfunnel_run_tool invocation, provenance, description, the input schema + required fields, and a
785
- * note that the call passes the same PreToolUse gate as any local tool.
786
- */
787
- function renderUpstreamInstructions(def) {
788
- const lines = [];
789
- lines.push(`${def.name} — forwarded from upstream MCP "${def.upstream}" (upstream tool: ${def.tool}).`);
790
- if (def.description) lines.push('', def.description);
791
- lines.push('', 'Run it through the gateway with:', ` toolfunnel_run_tool { "name": "${def.name}", "args": { … } }`);
792
- lines.push('', 'Every call passes the same PreToolUse gate as a local tool (a hook may deny it).');
793
- let schemaStr;
794
- try { schemaStr = JSON.stringify(def.inputSchema || { type: 'object' }, null, 2); } catch (_e) { schemaStr = '{ "type": "object" }'; }
795
- lines.push('', 'Input schema:', schemaStr);
796
- const req = def.inputSchema && Array.isArray(def.inputSchema.required) ? def.inputSchema.required : [];
797
- if (req.length) lines.push('', 'Required: ' + req.join(', '));
798
- return lines.join('\n');
799
- }
800
-
801
- /**
802
- * Normalise the first argument of handleMessage / createStdioLoop into the canonical Phase-2
803
- * build shape { protocol, aggregator, engine, ctx }. Backward compat: a caller may still pass a
804
- * bare protocol (Phase-1 callers / older tests) — we wrap it with a null aggregator + no deps so
805
- * the meta-tool path is identical and the curated-direct path is simply never taken (isExposed
806
- * is unreachable without an aggregator). A protocol is detected by its dispatch() method.
807
- * @param {object} arg either { protocol, aggregator, engine, ctx } or a bare protocol
808
- * @returns {{ protocol:object, aggregator:(object|null), engine:(object|null), ctx:(object|null) }}
809
- */
810
- function normaliseBuild(arg) {
811
- const a = arg || {};
812
- if (a && typeof a.dispatch === 'function') {
813
- // It's a bare protocol — Phase-1 shape (no register/state → no hot promotion; meta + curated only).
814
- return { protocol: a, aggregator: null, engine: null, ctx: null, registry: null, toolStatePath: null };
815
- }
816
- return {
817
- protocol: a.protocol,
818
- aggregator: a.aggregator || null,
819
- engine: a.engine || null,
820
- ctx: a.ctx || null,
821
- // The MATRIX inputs: the raw register (for a hot LOCAL tool's full MCP def) + the state overlay
822
- // path (read fresh per call). Absent → the surface is meta + curated-direct, the prior behaviour.
823
- registry: a.registry || null,
824
- toolStatePath: a.toolStatePath || null,
825
- };
826
- }
827
-
828
- // ─────────────────────────────────────────────────────────────────────────────────────────────
829
- // Dispatch ONE parsed JSON-RPC message. Returns a response object to send, or null for
830
- // notifications / messages that take no reply. NEVER throws.
831
- //
832
- // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
833
- // still accepted for backward compat — see normaliseBuild). The aggregator + engine + ctx are
834
- // threaded into tools/list and tools/call so the curated-direct surface is advertised and runs
835
- // THROUGH the PreToolUse gate.
836
- // ─────────────────────────────────────────────────────────────────────────────────────────────
837
- async function handleMessage(build, msg) {
838
- const { protocol, aggregator, engine, ctx, registry, toolStatePath } = normaliseBuild(build);
839
-
840
- // Basic envelope validation — a non-object or wrong jsonrpc is an Invalid Request.
841
- if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
842
- return makeError(null, ERR.INVALID_REQUEST, 'Invalid Request: expected a JSON-RPC object');
843
- }
844
-
845
- const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
846
- const id = hasId ? msg.id : null;
847
- const method = msg.method;
848
-
849
- // A message with no method but with an id is a response/garbage to us — acknowledge nothing.
850
- if (typeof method !== 'string') {
851
- if (hasId) return makeError(id, ERR.INVALID_REQUEST, 'Invalid Request: missing "method"');
852
- return null; // notification-shaped junk: ignore
853
- }
854
-
855
- try {
856
- switch (method) {
857
- case 'initialize':
858
- return makeResult(id, handleInitialize(msg.params));
859
-
860
- case 'initialized':
861
- case 'notifications/initialized':
862
- // Client → server notification that init handshake is complete. No reply.
863
- return null;
864
-
865
- case 'tools/list':
866
- return makeResult(id, handleToolsList(protocol, aggregator, { registry, toolStatePath }));
867
-
868
- case 'tools/call': {
869
- const result = await handleToolsCall(protocol, aggregator, { engine, ctx, registry, toolStatePath }, msg.params);
870
- // Observability (in-memory counters; never throws). Count every tools/call by name + whether
871
- // it errored (an isError envelope = a tool failure OR a PreToolUse denial). Single chokepoint
872
- // → covers stdio AND the HTTP transport, which both route through handleMessage.
873
- metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
874
- return makeResult(id, result);
875
- }
876
-
877
- case 'ping':
878
- // MCP keep-alive: empty result.
879
- return makeResult(id, {});
880
-
881
- default:
882
- // Unknown notification (no id) → silently ignore. Unknown request → method-not-found.
883
- if (!hasId) {
884
- if (method.startsWith('notifications/')) return null;
885
- return null;
886
- }
887
- return makeError(id, ERR.METHOD_NOT_FOUND, `Method not found: ${method}`);
888
- }
889
- } catch (err) {
890
- // Any unexpected internal failure becomes a JSON-RPC error (request) or is swallowed
891
- // (notification) — the loop never crashes on one bad message.
892
- logErr('handler error for', method + ':', (err && err.stack) || String(err));
893
- if (hasId) {
894
- return makeError(id, ERR.INTERNAL, `Internal error handling "${method}"`, (err && err.message) || String(err));
895
- }
896
- return null;
897
- }
898
- }
899
-
900
- /**
901
- * emitToolsListChanged(send) — write the MCP `notifications/tools/list_changed` JSON-RPC
902
- * notification (NO id — it is a notification, not a request) via the provided `send` fn. The
903
- * server emits this when the curated-direct expose set hot-updates so a CLI that honours
904
- * listChanged re-fetches tools/list (see the architecture notes §1 "no restart for register
905
- * changes"; §7 the host runs the server). `send` is injected so this is unit-testable with a spy
906
- * and reused by both the stdio loop and (later) the host.
907
- *
908
- * @param {(obj: object) => void} send the transport's writer (stdio loop's send, or a spy)
909
- * @returns {void}
910
- */
911
- function emitToolsListChanged(send) {
912
- if (typeof send !== 'function') return; // never throw — a missing transport is a no-op
913
- send({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
914
- }
915
-
916
- // ─────────────────────────────────────────────────────────────────────────────────────────────
917
- // HOT-RELOAD — pick up on-disk config changes with NO restart, NO user intervention.
918
- //
919
- // The tf_* management tools (and the UI) write expose.json / the hooks files from a SEPARATE
920
- // process, so the running gateway's in-memory aggregator + hook engine never saw the change — that
921
- // was the root cause of "you have to restart to attach an MCP / change a hook". These reloaders
922
- // re-read the on-disk config and SWAP build.aggregator / build.engine IN PLACE. handleMessage reads
923
- // the build object fresh on every message, so the next tools/list or tools/call uses the new state.
924
- // startConfigWatchers wires them to fs.watch so the gateway heals/updates itself automatically.
925
- // Everything here is contracted NEVER to throw — a bad reload keeps the last-good state.
926
- // ─────────────────────────────────────────────────────────────────────────────────────────────
927
-
928
- /**
929
- * Re-read expose.json, rebuild + reconnect the aggregator, swap it into `build`, then emit
930
- * notifications/tools/list_changed so a listChanged-honouring client (Claude Code does) refreshes
931
- * its curated-direct surface. The new aggregator is connected BEFORE the old one is closed so there
932
- * is never a window with no upstreams. NEVER throws.
933
- *
934
- * @param {{aggregator?:object}} build the live build object (mutated in place)
935
- * @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
936
- * @returns {Promise<void>}
937
- */
938
- async function reloadExpose(build, send) {
939
- // NEVER-throws contract. A null/absent build means the caller (e.g. the HTTP host mid-teardown,
940
- // which nulls its build) has moved on — do nothing rather than deref null. (reloadHooks/
941
- // reloadRegister already guard this; expose was the one unguarded reloader — a proven crash.)
942
- if (!build || typeof build !== 'object') return;
943
- let store;
944
- try {
945
- store = loadExposeStore(EXPOSE_PATH);
946
- } catch (err) {
947
- logErr('reloadExpose: cannot read expose.json (keeping current upstreams):', (err && err.message) || String(err));
948
- return;
949
- }
950
- const next = new Aggregator({ store, v3Root: ROOT });
951
- next.onToolsChanged = () => emitToolsListChanged(send); // re-wire the background-reconnect → list_changed signal
952
- let res = null;
953
- try {
954
- res = await next.connectAll();
955
- if (res && Array.isArray(res.failed) && res.failed.length > 0) {
956
- logErr('reloadExpose: ' + res.failed.length + ' upstream(s) failed:',
957
- res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
958
- }
959
- } catch (err) {
960
- logErr('reloadExpose: connectAll threw (ignored):', (err && err.message) || String(err));
961
- }
962
- // The swap/close/notify is guarded too: even a surprise (a build torn down DURING the async
963
- // connectAll above) must be a caught no-op, not an escaped rejection that crashes the host.
964
- try {
965
- const prev = build.aggregator;
966
- build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
967
- if (prev && typeof prev.closeAll === 'function') {
968
- try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
969
- }
970
- emitToolsListChanged(send);
971
- } catch (err) {
972
- logErr('reloadExpose: swap failed (keeping last-good):', (err && err.message) || String(err));
973
- try { await next.closeAll(); } catch (_e) { /* orphan-avoidance; never throw */ }
974
- return;
975
- }
976
- // Activity log (self-gating): mark the reload trigger; the per-upstream connect/disconnect lines
977
- // (from the aggregator) carry the detail. Together they record the reconnect.
978
- logger.log({
979
- type: 'mcp',
980
- event: 'reload',
981
- connected: res && Array.isArray(res.connected) ? res.connected : [],
982
- failed: res && Array.isArray(res.failed) ? res.failed.map((f) => f.id) : [],
983
- });
984
- logErr('reloadExpose: expose.json reloaded — upstreams reconnected');
985
- }
986
-
987
- /**
988
- * Re-read tools.register.json and swap the ONE live Registry's index IN PLACE (Registry.reload —
989
- * last-good on any failure). In-place mutation is the point: the Registry instance is CAPTURED by
990
- * the protocol adapter at build time, so swapping internals means every reader — the lean
991
- * meta-tools, run_tool resolution, and the top-level hot surface — sees the new entries on its
992
- * next call with no rewiring. Emits list_changed: a hot-promoted local tool's definition may have
993
- * appeared, changed, or gone. NEVER throws.
994
- *
995
- * @param {{registry?:object}} build
996
- * @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
997
- * @returns {void}
998
- */
999
- function reloadRegister(build, send) {
1000
- try {
1001
- if (build && build.registry && typeof build.registry.reload === 'function' && build.registry.reload()) {
1002
- emitToolsListChanged(send);
1003
- logErr('reloadRegister: tools.register.json reloaded');
1004
- } else {
1005
- logErr('reloadRegister: reload failed (bad JSON / invalid entry / mid-write?) — keeping the last-good register');
1006
- }
1007
- } catch (err) {
1008
- logErr('reloadRegister: unexpected failure (keeping current register):', (err && err.message) || String(err));
1009
- }
1010
- }
1011
-
1012
- /**
1013
- * Re-read the hooks manifest + state overlay and swap a fresh HookEngine into `build`. The next
1014
- * gated call (toolfunnel_run_tool / curated-direct) uses the new engine, so an added/changed/toggled
1015
- * hook takes effect with no restart. NEVER throws (a bad manifest keeps the current engine).
1016
- *
1017
- * @param {{engine?:object}} build
1018
- * @returns {void}
1019
- */
1020
- function reloadHooks(build) {
1021
- try {
1022
- const loader = loadManifest(MANIFEST_PATH);
1023
- build.engine = new HookEngine(loader, { cwd: ROOT });
1024
- logErr('reloadHooks: hooks manifest/state reloaded');
1025
- } catch (err) {
1026
- logErr('reloadHooks: cannot reload hooks (keeping current engine):', (err && err.message) || String(err));
1027
- }
1028
- }
1029
-
1030
- /**
1031
- * Watch the config and hot-reload on change. TWO mechanisms, belt-and-braces:
1032
- * 1. fs.watch on the DIRECTORIES — fast, event-driven, filtered by basename.
1033
- * 2. fs.watchFile POLLING on each specific config file — the reliable fallback.
1034
- * The directory watch alone is NOT enough: fs.watch is event-based and on Windows frequently
1035
- * MISSES the atomic temp+rename writes the stores use (the target's inode is replaced without a
1036
- * usable change event on the directory), so a `tf_hook_set` / UI toggle could fail to hot-reload
1037
- * until restart. fs.watchFile polls each file's stat, so it catches every create/modify (including
1038
- * atomic rename) on every platform. Both paths feed the SAME debounced schedulers, so a change
1039
- * that trips both still collapses to one reload. Debounced (a temp+rename burst → one reload),
1040
- * with a re-entrancy guard so a mid-flight expose reload can't overlap itself. A watch failure (an
1041
- * fs that supports neither) is logged and ignored — the server still runs, just without
1042
- * auto-reload. NEVER throws.
1043
- *
1044
- * @param {object} build the live build object (mutated by the reloaders)
1045
- * @param {(obj:object)=>void} send the stdio writer
1046
- * @returns {void}
1047
- */
1048
- function startConfigWatchers(buildArg, send) {
1049
- // buildArg may be the build OBJECT (stdio: one stable object, mutated in place) OR a GETTER
1050
- // () => build. The HTTP host REBUILDS its build (reload()/stop() reassign it), so it passes a
1051
- // getter — the reloaders then always target the LIVE build and a rebuild can never desync the
1052
- // watchers. Backward-compatible: a plain object is wrapped in a getter, so stdio is unchanged.
1053
- const getBuild = typeof buildArg === 'function' ? buildArg : () => buildArg;
1054
- const DEBOUNCE_MS = 150;
1055
- const mcpDir = path.dirname(EXPOSE_PATH);
1056
- const hooksDir = path.dirname(MANIFEST_PATH);
1057
- const openWatchers = []; // fs.watch handles, closed by the returned stop()
1058
- const polledFiles = []; // fs.watchFile paths, unwatched by the returned stop()
1059
-
1060
- let exposeTimer = null;
1061
- let reloadingExpose = false;
1062
- function scheduleExpose() {
1063
- if (exposeTimer) clearTimeout(exposeTimer);
1064
- exposeTimer = setTimeout(async () => {
1065
- exposeTimer = null;
1066
- if (reloadingExpose) { scheduleExpose(); return; } // a reload is mid-flight; re-arm
1067
- reloadingExpose = true;
1068
- // reloadExpose is contracted never to throw, but a debounced setTimeout callback has no
1069
- // caller to catch an escaped rejection — so guard here too (an unhandled rejection would
1070
- // crash the host on default Node). Belt and braces.
1071
- try { await reloadExpose(getBuild(), send); }
1072
- catch (e) { logErr('scheduleExpose: reload rejected (should not):', (e && e.message) || String(e)); }
1073
- finally { reloadingExpose = false; }
1074
- }, DEBOUNCE_MS);
1075
- if (exposeTimer && typeof exposeTimer.unref === 'function') exposeTimer.unref(); // don't pin the loop
1076
- }
1077
-
1078
- let hooksTimer = null;
1079
- function scheduleHooks() {
1080
- if (hooksTimer) clearTimeout(hooksTimer);
1081
- hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(getBuild()); }, DEBOUNCE_MS);
1082
- if (hooksTimer && typeof hooksTimer.unref === 'function') hooksTimer.unref();
1083
- }
1084
-
1085
- let registerTimer = null;
1086
- function scheduleRegister() {
1087
- if (registerTimer) clearTimeout(registerTimer);
1088
- registerTimer = setTimeout(() => { registerTimer = null; reloadRegister(getBuild(), send); }, DEBOUNCE_MS);
1089
- if (registerTimer && typeof registerTimer.unref === 'function') registerTimer.unref();
1090
- }
1091
-
1092
- // tools.state.json needs NO reload (it is read fresh per tools/list & tools/call) — but a
1093
- // hot/hidden/enabled toggle CHANGES the top-level surface, so a connected client must be told
1094
- // to re-fetch. Notification only.
1095
- let stateTimer = null;
1096
- function scheduleStateNotify() {
1097
- if (stateTimer) clearTimeout(stateTimer);
1098
- stateTimer = setTimeout(() => { stateTimer = null; emitToolsListChanged(send); }, DEBOUNCE_MS);
1099
- if (stateTimer && typeof stateTimer.unref === 'function') stateTimer.unref();
1100
- }
1101
-
1102
- try {
1103
- const w = fs.watch(mcpDir, (_event, filename) => {
1104
- // null filename (some platforms don't report it) → reload to be safe; else only expose.json.
1105
- if (!filename || path.basename(String(filename)) === 'expose.json') scheduleExpose();
1106
- });
1107
- // unref so an FSWatcher never keeps the process alive after the client closes stdin — the
1108
- // gateway must still exit naturally when its transport closes (it did before this feature).
1109
- if (w && typeof w.unref === 'function') w.unref();
1110
- openWatchers.push(w);
1111
- logErr('startConfigWatchers: watching', mcpDir, 'for expose.json changes');
1112
- } catch (err) {
1113
- logErr('startConfigWatchers: cannot watch', mcpDir + ':', (err && err.message) || String(err));
1114
- }
1115
-
1116
- try {
1117
- const w = fs.watch(hooksDir, (_event, filename) => {
1118
- const base = filename ? path.basename(String(filename)) : '';
1119
- if (!filename || base === 'hooks.manifest.json' || base === 'hooks.state.json') scheduleHooks();
1120
- });
1121
- if (w && typeof w.unref === 'function') w.unref();
1122
- openWatchers.push(w);
1123
- logErr('startConfigWatchers: watching', hooksDir, 'for hook changes');
1124
- } catch (err) {
1125
- logErr('startConfigWatchers: cannot watch', hooksDir + ':', (err && err.message) || String(err));
1126
- }
1127
-
1128
- // The register + state overlay live in tools/. Register edits (tf_tool_add from its child
1129
- // process, the UI, a hand edit) were the ONE config surface with no watcher — a running gateway
1130
- // served its startup snapshot until restart, which broke the "add a tool live" story the moment
1131
- // the server outlived the edit. State toggles only need the list_changed nudge (read-fresh).
1132
- const toolsDir = path.dirname(REGISTER_PATH);
1133
- try {
1134
- const w = fs.watch(toolsDir, (_event, filename) => {
1135
- const base = filename ? path.basename(String(filename)) : '';
1136
- if (!filename || base === 'tools.register.json') scheduleRegister();
1137
- if (!filename || base === 'tools.state.json') scheduleStateNotify();
1138
- });
1139
- if (w && typeof w.unref === 'function') w.unref();
1140
- openWatchers.push(w);
1141
- logErr('startConfigWatchers: watching', toolsDir, 'for register/state changes');
1142
- } catch (err) {
1143
- logErr('startConfigWatchers: cannot watch', toolsDir + ':', (err && err.message) || String(err));
1144
- }
1145
-
1146
- // ── Reliable fallback: fs.watchFile POLLS each config file's stat, so it catches every
1147
- // create/modify — including the atomic temp+rename writes that fs.watch misses on Windows.
1148
- // A 1s interval is ample for config (no sub-second reload need); the schedulers debounce, so a
1149
- // change caught by BOTH the directory watch and the file poll still yields a single reload.
1150
- // The state overlays (hooks.state.json / tools.state.json) may not exist yet — watchFile fires
1151
- // when they first appear (a tf_hook_set / tf_tool_set toggle CREATES them), which is the case
1152
- // the directory watch was dropping.
1153
- const pollWatch = (file, schedule) => {
1154
- try {
1155
- // Retain the listener ref so stop() can unwatchFile(file, listener) — a bare
1156
- // unwatchFile(file) would remove ALL listeners on that path, clobbering a second host/watcher
1157
- // set in the same process (parallel tests, an embedder running two hosts).
1158
- const listener = (curr, prev) => {
1159
- if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) schedule();
1160
- };
1161
- const sw = fs.watchFile(file, { interval: 1000 }, listener);
1162
- if (sw && typeof sw.unref === 'function') sw.unref(); // never pin the event loop
1163
- polledFiles.push({ file, listener });
1164
- } catch (err) {
1165
- logErr('startConfigWatchers: cannot poll-watch', file + ':', (err && err.message) || String(err));
1166
- }
1167
- };
1168
- pollWatch(EXPOSE_PATH, scheduleExpose);
1169
- pollWatch(MANIFEST_PATH, scheduleHooks);
1170
- pollWatch(path.join(hooksDir, 'hooks.state.json'), scheduleHooks);
1171
- pollWatch(REGISTER_PATH, scheduleRegister);
1172
- pollWatch(path.join(toolsDir, 'tools.state.json'), scheduleStateNotify);
1173
- logErr('startConfigWatchers: fs.watchFile polling fallback armed (Windows-safe)');
1174
-
1175
- // Teardown handle. The stdio path never calls it (the process dies when stdin closes and the
1176
- // watchers are all unref'd); the HTTP host calls it in stop() so a started→stopped host leaves
1177
- // no dangling directory watchers or file pollers.
1178
- return function stopConfigWatchers() {
1179
- // Clear any PENDING debounced reload so a change caught just before teardown can't fire a
1180
- // reload against a torn-down build after the caller has moved on. (It would be a caught no-op,
1181
- // but a truly-stopped watcher must not schedule work.)
1182
- for (const t of [exposeTimer, hooksTimer, registerTimer, stateTimer]) {
1183
- if (t) { try { clearTimeout(t); } catch (_e) { /* ignore */ } }
1184
- }
1185
- for (const w of openWatchers) {
1186
- try { if (w && typeof w.close === 'function') w.close(); } catch (_e) { /* ignore */ }
1187
- }
1188
- for (const p of polledFiles) {
1189
- try { fs.unwatchFile(p.file, p.listener); } catch (_e) { /* ignore */ }
1190
- }
1191
- };
1192
- }
1193
-
1194
- // ─────────────────────────────────────────────────────────────────────────────────────────────
1195
- // stdin framing reader — accepts BOTH Content-Length framing and newline-delimited JSON.
1196
- // We write newline-delimited JSON on stdout.
1197
- //
1198
- // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
1199
- // accepted too — see normaliseBuild). It is threaded straight into handleMessage so tools/list
1200
- // advertises the curated-direct surface and tools/call gates curated-direct calls.
1201
- // ─────────────────────────────────────────────────────────────────────────────────────────────
1202
- function createStdioLoop(build) {
1203
- let buf = Buffer.alloc(0);
1204
- // Serialise message handling so responses are written in request order even though handlers
1205
- // are async (avoids interleaving partial writes on stdout).
1206
- let chain = Promise.resolve();
1207
-
1208
- function send(obj) {
1209
- if (obj == null) return;
1210
- let line;
1211
- try {
1212
- line = JSON.stringify(obj);
1213
- } catch (_e) {
1214
- line = JSON.stringify(makeError(null, ERR.INTERNAL, 'failed to serialise response'));
1215
- }
1216
- process.stdout.write(line + '\n');
1217
- }
1218
-
1219
- function enqueue(msg) {
1220
- chain = chain.then(async () => {
1221
- const response = await handleMessage(build, msg);
1222
- send(response);
1223
- });
1224
- }
1225
-
1226
- // Try to pull one Content-Length-framed message off the front of `buf`. Returns true if it
1227
- // consumed one (and enqueued it), false if there isn't a complete header-framed message yet.
1228
- function tryHeaderFramed() {
1229
- const headerEnd = buf.indexOf('\r\n\r\n');
1230
- if (headerEnd === -1) return false;
1231
- const header = buf.slice(0, headerEnd).toString('utf8');
1232
- const m = /Content-Length:\s*(\d+)/i.exec(header);
1233
- if (!m) return false; // header block without a Content-Length → not this framing
1234
- const len = parseInt(m[1], 10);
1235
- const bodyStart = headerEnd + 4;
1236
- if (buf.length < bodyStart + len) return false; // body not all here yet
1237
- const body = buf.slice(bodyStart, bodyStart + len).toString('utf8');
1238
- buf = buf.slice(bodyStart + len);
1239
- let obj = null;
1240
- try {
1241
- obj = JSON.parse(body);
1242
- } catch (_e) {
1243
- send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON body'));
1244
- return true;
1245
- }
1246
- enqueue(obj);
1247
- return true;
1248
- }
1249
-
1250
- // Try to pull one newline-delimited JSON message off the front of `buf`. Returns true if it
1251
- // consumed a line (whether or not it parsed), false if no complete line is buffered.
1252
- function tryLineFramed() {
1253
- const nl = buf.indexOf(0x0a); // '\n'
1254
- if (nl === -1) return false;
1255
- const line = buf.slice(0, nl).toString('utf8').trim();
1256
- buf = buf.slice(nl + 1);
1257
- if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
1258
- // A leftover header line ("Content-Length: …") that wasn't consumed by tryHeaderFramed means
1259
- // the body hasn't arrived; but since we always try header framing FIRST, a stray header line
1260
- // here is junk — ignore non-JSON lines rather than erroring on framing artifacts.
1261
- if (line[0] !== '{' && line[0] !== '[') return true;
1262
- let obj = null;
1263
- try {
1264
- obj = JSON.parse(line);
1265
- } catch (_e) {
1266
- send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON line'));
1267
- return true;
1268
- }
1269
- enqueue(obj);
1270
- return true;
1271
- }
1272
-
1273
- function drain() {
1274
- // Header framing first (it counts exact bytes and is unambiguous); fall back to line framing.
1275
- // Loop until neither can make progress.
1276
- for (;;) {
1277
- if (tryHeaderFramed()) continue;
1278
- if (tryLineFramed()) continue;
1279
- break;
1280
- }
1281
- }
1282
-
1283
- process.stdin.on('data', (chunk) => {
1284
- buf = Buffer.concat([buf, chunk]);
1285
- drain();
1286
- });
1287
- process.stdin.on('end', () => {
1288
- // Flush any complete trailing message, then let the process exit naturally.
1289
- drain();
1290
- });
1291
- process.stdin.on('error', (err) => {
1292
- logErr('stdin error:', (err && err.message) || String(err));
1293
- });
1294
-
1295
- // Begin reading.
1296
- process.stdin.resume();
1297
-
1298
- // Expose the writer so the entry point can wire hot-reload watchers (they emit
1299
- // notifications/tools/list_changed through this same send).
1300
- return { send };
1301
- }
1302
-
1303
- // ─────────────────────────────────────────────────────────────────────────────────────────────
1304
- // Entry point.
1305
- // ─────────────────────────────────────────────────────────────────────────────────────────────
1306
- async function main() {
1307
- // The Phase-2 build object { protocol, aggregator, engine, ctx }.
1308
- let build;
1309
- try {
1310
- build = buildProtocol();
1311
- } catch (err) {
1312
- // If wiring failed (e.g. a missing register/manifest), still start a server that answers every
1313
- // request with a clean internal error — so the client gets a message instead of a hang. The
1314
- // degraded build has a null aggregator (no curated-direct surface) but a working meta-tool
1315
- // path that reports the wiring failure.
1316
- logErr('FATAL wiring error:', (err && err.stack) || String(err));
1317
- const message = `MCP server failed to initialise: ${(err && err.message) || String(err)}`;
1318
- const protocol = makeProtocol({
1319
- registry: { list: () => { throw new Error(message); }, instructions: () => { throw new Error(message); }, resolveExecution: () => null },
1320
- gatedRun,
1321
- howto: () => { throw new Error(message); },
1322
- });
1323
- build = { protocol, aggregator: null, engine: null, ctx: null };
1324
- }
1325
-
1326
- // Connect every ENABLED upstream BEFORE the loop starts so tools/list advertises the curated
1327
- // surface from the first request. connectAll() NEVER throws (per-upstream failures land in
1328
- // failed[]) and with the default EMPTY expose.json it is an instant no-op — so this never
1329
- // delays or crashes startup. await-safe either way.
1330
- if (build.aggregator && typeof build.aggregator.connectAll === 'function') {
1331
- try {
1332
- const res = await build.aggregator.connectAll();
1333
- if (res && Array.isArray(res.failed) && res.failed.length > 0) {
1334
- logErr('aggregator.connectAll: ' + res.failed.length + ' upstream(s) failed:',
1335
- res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
1336
- }
1337
- } catch (err) {
1338
- // connectAll is contracted not to throw; guard anyway so startup never dies on it.
1339
- logErr('aggregator.connectAll threw (ignored):', (err && err.message) || String(err));
1340
- }
1341
- }
1342
-
1343
- // Keep the process alive on the stdio loop; never let an unhandled rejection kill it.
1344
- process.on('uncaughtException', (err) => logErr('uncaughtException:', (err && err.stack) || String(err)));
1345
- process.on('unhandledRejection', (err) => logErr('unhandledRejection:', (err && (err.stack || err.message)) || String(err)));
1346
-
1347
- const loop = createStdioLoop(build);
1348
-
1349
- // Wire the aggregator's out-of-band tool-set-change signal (a background reconnect recovering — or
1350
- // finally losing — an upstream) to notifications/tools/list_changed, so the client refreshes when a
1351
- // dead upstream heals itself. Set on the live instance; reloadExpose re-wires the swapped-in one.
1352
- if (build.aggregator) build.aggregator.onToolsChanged = () => emitToolsListChanged(loop.send);
1353
-
1354
- // Hot-reload: pick up expose.json / hook changes written by the tf_* tools or the UI (a separate
1355
- // process) with NO restart and NO user intervention — the missing primitive that previously made
1356
- // a live MCP attach or a hook change need a bounce. Auto is the default; the meta-tools/UI button
1357
- // are just optional manual levers on top of this.
1358
- startConfigWatchers(build, loop.send);
1359
-
1360
- logErr('ready —', SERVER_INFO.name, SERVER_INFO.version, '(pid', process.pid + ')');
1361
- }
1362
-
1363
- // Run only when invoked directly (so the module can also be required by tests). main() is async
1364
- // (it awaits aggregator.connectAll); a startup rejection is logged to stderr, never thrown so the
1365
- // process can't die unhandled before the loop is even up.
1366
- if (require.main === module) {
1367
- Promise.resolve()
1368
- .then(main)
1369
- .catch((err) => logErr('FATAL startup error:', (err && err.stack) || String(err)));
1370
- }
1371
-
1372
- module.exports = {
1373
- // Exported for unit tests / reuse (the transport is thin; these are the seams).
1374
- buildProtocol,
1375
- makeRegistryAdapter,
1376
- handleMessage,
1377
- handleInitialize,
1378
- // The stdio entry point — an external bin/ can require this module and call main() to start the
1379
- // server (build → connect upstreams → run the stdio loop). main() is async and takes no args.
1380
- main,
1381
- // Phase-2 + MATRIX handlers: handleToolsList(protocol, aggregator, { registry?, toolStatePath? })
1382
- // and handleToolsCall(protocol, aggregator, { engine, ctx, registry?, toolStatePath? }, params).
1383
- handleToolsList,
1384
- handleToolsCall,
1385
- // Matrix helpers (exported for unit tests / reuse).
1386
- localHotDefinitions,
1387
- localToolDefinition,
1388
- isPromotedLocal,
1389
- isPromotedUpstream,
1390
- // Phase-2: emit the hot-update notification (no-id notifications/tools/list_changed) via send.
1391
- emitToolsListChanged,
1392
- // Hot-reload: re-read on-disk config live (no restart). Wired to fs.watch in main().
1393
- reloadExpose,
1394
- reloadHooks,
1395
- startConfigWatchers,
1396
- // Helpers exported for tests / reuse.
1397
- isMetaTool,
1398
- runCuratedDirect,
1399
- wrapProtocolResult,
1400
- normaliseBuild,
1401
- stringifyContent,
1402
- firstString,
1403
- PROTOCOL_VERSION,
1404
- SERVER_INFO,
1405
- };
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * server.js - the HAND-ROLLED minimal MCP server over stdio (see the architecture notes §7).
6
+ *
7
+ * Runnable as: node src/mcp/server.js
8
+ *
9
+ * This is the THIN transport. It speaks JSON-RPC 2.0 over stdin/stdout and delegates ALL logic
10
+ * to the pure protocol layer (src/mcp/protocol.js). It is NOT an SDK wrapper - there is no
11
+ * @modelcontextprotocol/sdk and no new npm dependency; everything here is Node built-ins.
12
+ *
13
+ * JSON-RPC methods handled (MCP, the subset our test client speaks):
14
+ * - "initialize" -> { protocolVersion, serverInfo, capabilities: { tools: {} } }
15
+ * - "tools/list" -> { tools: [ ...protocol.toolDefinitions() ] } (the 4 meta-tools;
16
+ * a clear seam is left for curated-direct tools - Phase 2)
17
+ * - "tools/call" { name, arguments } -> protocol.dispatch(name, arguments), wrapped in the
18
+ * MCP tools/call result shape: { content:[{ type:"text", text: JSON }],
19
+ * isError? }. On a protocol error result we set isError:true.
20
+ * - notifications (no `id`) are acknowledged silently and never answered.
21
+ * - any bad / unknown message NEVER crashes the process - it gets a JSON-RPC error object.
22
+ *
23
+ * Framing: we READ both LSP-style `Content-Length:` framing AND newline-delimited JSON (the test
24
+ * client writes Content-Length with a trailing newline; a simple client may send line-delimited),
25
+ * and we WRITE newline-delimited JSON (the test reader accepts either framing on the wire). One
26
+ * framing on the way out keeps the transport trivial and matches "newline-delimited JSON is fine
27
+ * for our test client".
28
+ *
29
+ * Wiring (see the architecture notes §8 - Phase 1): a real Registry (loaded from
30
+ * src/tools/tools.register.json - the canonical tool register), a real HookEngine over the hooks
31
+ * manifest, the real gatedRun, and the real howto. A small ADAPTER bridges the register's
32
+ * resolveExecution({type,run}) shape to the { execute, toolName, args } shape protocol.runTool
33
+ * injects into gatedRun - keeping the pure modules untouched while making `toolfunnel_run_tool`
34
+ * actually gate + execute end to end.
35
+ *
36
+ * CommonJS only. Node built-ins only. No transport SDK.
37
+ */
38
+
39
+ const fs = require('node:fs');
40
+ const path = require('node:path');
41
+ const { spawn } = require('node:child_process');
42
+
43
+ const { makeProtocol } = require('./protocol');
44
+ const { gatedRun } = require('./gated-run');
45
+ const { Aggregator } = require('./aggregator');
46
+ const { loadExposeStore } = require('./expose-store');
47
+ const { loadRegistry } = require('../tools/registry');
48
+ const { loadToolState, loadToolStateResult, isToolEnabled, isToolHot, getPassthrough } = require('../tools/tool-state');
49
+ const { loadManifest } = require('../core/hook-loader');
50
+ const { HookEngine } = require('../core/hook-engine');
51
+ const { howto } = require('../extend/howto');
52
+ const logger = require('../core/logger');
53
+ const metrics = require('../core/metrics');
54
+
55
+ // ── Path anchors (everything stays under the host root) ───────────────────────────────────────
56
+ // The CODE lives in the package; the mutable CONFIG lives in the resolved config home - the same
57
+ // directory in a git clone, distinct when TOOLFUNNEL_HOME / --config-dir points elsewhere (npm
58
+ // installs, npm-wrapped MCPs - an `npm update` must never eat the user's tools). Every anchor
59
+ // below derives from the HOME.
60
+ const { PKG_ROOT, resolveConfigHome, ensureConfigHome } = require('../core/config-home');
61
+ const ROOT = resolveConfigHome(); // <...>/<config home>
62
+ // The process-tree contract for child tools (defaultRunScript spawns with process.env): a
63
+ // management script SEEDED into an external home finds the engine code via TOOLFUNNEL_PKG, and
64
+ // the resolved ABSOLUTE home is written back so no child re-resolves a relative value against a
65
+ // different cwd.
66
+ process.env.TOOLFUNNEL_PKG = PKG_ROOT;
67
+ if (ROOT !== PKG_ROOT) process.env.TOOLFUNNEL_HOME = ROOT;
68
+ const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
69
+ // The ACTIVE/DISABLED overlay (tool-state.js). toolfunnel_list_tools filters DISABLED tools out so
70
+ // they are not surfaced to the client. Read FRESH per list() call so UI toggles take effect with no
71
+ // restart. HIDDEN is a manager-list-only axis - it is NEVER consulted here (it must not affect the
72
+ // client's view).
73
+ const TOOL_STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
74
+ const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
75
+ const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
76
+ // Phase 2: the persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
77
+ // aggregator connects to nothing and the curated-direct surface is empty - the server is
78
+ // behaviourally identical to Phase 1 until an upstream + expose entry is added.
79
+ const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
80
+
81
+ const PROTOCOL_VERSION = '2024-11-05';
82
+ // The 2026-07-28 ("modern") era, as a pure module - era detection, validation, result decoration,
83
+ // server/discover and subscriptions/listen shapes. This server is DUAL-ERA: a request carrying
84
+ // modern per-request _meta is served under 2026-07-28 semantics; initialize selects THIS legacy
85
+ // path, byte-for-byte the 0.5.0 behaviour. modern.js needs the legacy version string only for
86
+ // server/discover's supportedVersions - seeded once here so it is never duplicated.
87
+ const modern = require('./modern');
88
+ modern.setLegacyVersion(PROTOCOL_VERSION);
89
+ // Identity comes from the OPTIONAL toolfunnel.json at the root (absent -> name "toolfunnel",
90
+ // version from package.json - serverInfo can never drift from the released version). The config
91
+ // seam is what lets a wrapped MCP introduce itself as ITSELF in the initialize handshake and
92
+ // /health. Loaded once at module init: clients cache serverInfo from initialize, so identity is
93
+ // deliberately NOT hot-reloaded (a mid-session identity swap would lie to connected clients).
94
+ const { loadServerConfig } = require('../core/server-config');
95
+ const SERVER_CONFIG = loadServerConfig(ROOT);
96
+ const SERVER_INFO = { name: SERVER_CONFIG.serverName, version: SERVER_CONFIG.serverVersion };
97
+
98
+ // ── Diagnostics -> stderr only (stdout is the JSON-RPC channel; never pollute it). ─────────────
99
+ function logErr(...parts) {
100
+ try {
101
+ process.stderr.write('[toolfunnel] ' + parts.join(' ') + '\n');
102
+ } catch (_e) {
103
+ /* never let logging throw */
104
+ }
105
+ }
106
+
107
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
108
+ // Registry adapter - bridge the real Registry to what protocol.runTool consumes.
109
+ //
110
+ // protocol.runTool expects: registry.resolveExecution(name, args) -> { execute, toolName, args }
111
+ // The real Registry returns: resolveExecution(id, args) -> { type, run }
112
+ // - it looks up by `id` only (throws on unknown id),
113
+ // - returns `run` (not `execute`),
114
+ // - for a SHELL invoke it returns a DEFERRED descriptor instead of running (the gated runner
115
+ // is meant to own shell execution - registry.js keeps un-gated shell out of itself by design).
116
+ //
117
+ // The adapter therefore:
118
+ // - resolves the meta-tool `name` to a register id (accepting id OR display name),
119
+ // - wraps `run` into an `execute` thunk that returns a clean tool output,
120
+ // - for the shell-deferred case, performs the actual shell spawn INSIDE the execute thunk - so
121
+ // it only ever runs after gatedRun's PreToolUse gate has allowed it.
122
+ // list()/instructions() pass straight through (with the same name->id resolution for instructions).
123
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
124
+ function makeRegistryAdapter(registry, opts) {
125
+ // opts.toolStatePath: when set, list() filters DISABLED tools out (the enabled-filter for
126
+ // toolfunnel_list_tools). When absent (the Tool Manager's runOnce adapter, tests) list() is unfiltered.
127
+ const toolStatePath = (opts && opts.toolStatePath) || null;
128
+ // opts.getAggregator: an OPTIONAL live getter () => Aggregator|null. NEVER a captured instance -
129
+ // reloadExpose swaps build.aggregator, so the adapter must read it fresh each call. When absent the
130
+ // adapter is local-only, byte-for-byte its pre-slice-2 behaviour (preserves the runOnce adapter + tests).
131
+ const getAggregator = (opts && typeof opts.getAggregator === 'function') ? opts.getAggregator : null;
132
+
133
+ /** The live aggregator, or null. Defensive: a throwing/absent getter yields null (local-only). */
134
+ function liveAggregator() {
135
+ if (!getAggregator) return null;
136
+ try { const a = getAggregator(); return a && typeof a === 'object' ? a : null; } catch (_e) { return null; }
137
+ }
138
+
139
+ /** Resolve a meta-tool name (which may be a display name) to a register id. */
140
+ function resolveId(nameOrId) {
141
+ if (typeof nameOrId !== 'string' || nameOrId.length === 0) return null;
142
+ if (registry.has(nameOrId)) return nameOrId; // it's already an id
143
+ // Fall back to matching the display name.
144
+ const hit = registry.list().find((b) => b.name === nameOrId);
145
+ return hit ? hit.id : null;
146
+ }
147
+
148
+ /**
149
+ * Run a shell command, normalising to { ok, code, stdout, stderr }. ASYNC spawn - the old
150
+ * spawnSync blocked the event loop for the tool's whole duration, stalling every concurrent
151
+ * HTTP client (and pings) behind one long shell tool (0.5.0 debt). Its
152
+ * only caller is the gated async execute thunk, so the promise is awaited naturally; the
153
+ * spawn-failure contract (throw/reject with the same message) is unchanged.
154
+ */
155
+ function runShell(command, args) {
156
+ return new Promise((resolve, reject) => {
157
+ let child;
158
+ try {
159
+ child = spawn(command, {
160
+ shell: true,
161
+ cwd: ROOT,
162
+ env: Object.assign({}, process.env, { TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args == null ? null : args) }),
163
+ windowsHide: true,
164
+ });
165
+ } catch (err) {
166
+ reject(new Error(`shell spawn failed: ${err.message}`));
167
+ return;
168
+ }
169
+ let stdout = '';
170
+ let stderr = '';
171
+ let settled = false;
172
+ if (child.stdout) child.stdout.on('data', (d) => { stdout += d.toString('utf8'); });
173
+ if (child.stderr) child.stderr.on('data', (d) => { stderr += d.toString('utf8'); });
174
+ child.on('error', (err) => {
175
+ if (settled) return;
176
+ settled = true;
177
+ reject(new Error(`shell spawn failed: ${err.message}`));
178
+ });
179
+ child.on('close', (code) => {
180
+ if (settled) return;
181
+ settled = true;
182
+ const c = typeof code === 'number' ? code : -1;
183
+ resolve({ ok: c === 0, code: c, stdout, stderr });
184
+ });
185
+ });
186
+ }
187
+
188
+ return {
189
+ // Briefs only (protocol forwards { filter, category }). When a tool-state overlay is configured,
190
+ // DISABLED (✗) tools are dropped so toolfunnel_list_tools never surfaces them to the client -
191
+ // read FRESH each call so a UI toggle takes effect immediately, no host restart. HIDDEN (👁) is
192
+ // UI-only and is deliberately NOT consulted here (it must never change what the client sees).
193
+ list(listOpts) {
194
+ const o = listOpts || {};
195
+ let local = registry.list(o);
196
+ const state = toolStatePath ? loadToolState(toolStatePath) : null;
197
+ if (state) local = local.filter((b) => isToolEnabled(state, b.id));
198
+
199
+ const agg = liveAggregator();
200
+ if (!agg || typeof agg.leanToolDefinitions !== 'function') return local;
201
+ // Append the LEAN upstream tools (the full discovered set of connected upstreams), then:
202
+ // - drop any whose name is a local tool (local-register WINS - matches resolveExecution),
203
+ // - apply the SAME {filter,category} registry.list applied (parity), and
204
+ // - apply the SAME tool-state enable filter local tools get, so the lean list is CURATABLE
205
+ // per-tool: disable an upstream tool by its surfaced id, exactly like a local tool.
206
+ let upstream = [];
207
+ try {
208
+ upstream = agg.leanToolDefinitions()
209
+ .map((d) => ({ id: d.name, name: d.name, summary: d.description || '', category: `mcp:${d.upstream}` }))
210
+ .filter((b) => resolveId(b.id) === null)
211
+ .filter((b) => briefMatches(b, o))
212
+ .filter((b) => !state || isToolEnabled(state, b.id));
213
+ } catch (_e) {
214
+ upstream = []; // a throwing aggregator must never sink toolfunnel_list_tools - local-only fallback
215
+ }
216
+ return local.concat(upstream);
217
+ },
218
+
219
+ // Full instructions for one tool (by id or display name). Registry.instructions throws on an
220
+ // unknown id; protocol.toolInstructions catches that and returns a clean error result.
221
+ instructions(nameOrId) {
222
+ const id = resolveId(nameOrId);
223
+ if (id) return registry.instructions(id);
224
+ // Not a local tool - synthesise instructions for a lean upstream tool from its discovered def.
225
+ const agg = liveAggregator();
226
+ if (!agg || typeof agg.leanToolDefinitions !== 'function') return null;
227
+ try {
228
+ const def = agg.leanToolDefinitions().find((d) => d.name === nameOrId);
229
+ return def ? renderUpstreamInstructions(def) : null; // null -> protocol's "no tool named ..."
230
+ } catch (_e) {
231
+ return null;
232
+ }
233
+ },
234
+
235
+ // Build the { execute, toolName, args, mode } resolution protocol.runTool hands to gatedRun.
236
+ resolveExecution(nameOrId, args) {
237
+ const id = resolveId(nameOrId);
238
+ if (id) {
239
+ // LOCAL tool - byte-for-byte the pre-slice-2 path. A local name that resolves but isn't
240
+ // runnable returns null and does NOT fall through to upstream (local-register WINS: a local
241
+ // name never silently resolves to a remote upstream).
242
+ const entry = registry.getEntry(id); // { id, name, summary, category, instructions, invoke, mode? }
243
+ const desc = registry.resolveExecution(id, args); // { type, mode, run? } | reference: { type, mode, instructions }
244
+ if (!desc) return null;
245
+
246
+ // Reference mode: nothing executes here. Hand back the instructions (no execute thunk)
247
+ // so protocol.runTool takes the no-spawn path (execute() is never called). Note this is
248
+ // NOT ungated: protocol.runTool still fires PreToolUse on the instruction handoff, so a
249
+ // deny withholds the instructions. What's absent is server-side EXECUTION, not the gate.
250
+ if (desc.mode === 'reference') {
251
+ return {
252
+ mode: 'reference',
253
+ toolName: entry.name || id,
254
+ instructions: typeof desc.instructions === 'string' ? desc.instructions : (entry.instructions || ''),
255
+ };
256
+ }
257
+
258
+ if (typeof desc.run !== 'function') return null;
259
+
260
+ const execute = async () => {
261
+ const out = await desc.run();
262
+ // Shell invokes come back deferred - the registry hands shell execution to the gated path.
263
+ if (out && out.deferred === true && out.type === 'shell') {
264
+ return runShell(out.command, out.args);
265
+ }
266
+ return out;
267
+ };
268
+
269
+ return { execute, toolName: entry.name || id, args: args == null ? {} : args, mode: desc.mode || 'gateway' };
270
+ }
271
+
272
+ // No local tool by this name - try the LEAN upstream forward. resolveLeanExecution returns a
273
+ // resolution whose execute thunk lazy-(re)connects + unwraps; the gate matches fwd.toolName (the
274
+ // surfaced name). mode is 'gateway' so it goes THROUGH gatedRun - never the reference handoff.
275
+ const agg = liveAggregator();
276
+ if (!agg || typeof agg.resolveLeanExecution !== 'function') return null;
277
+ let fwd = null;
278
+ try { fwd = agg.resolveLeanExecution(nameOrId, args); } catch (_e) { fwd = null; }
279
+ if (!fwd || typeof fwd.execute !== 'function') return null;
280
+ return { execute: fwd.execute, toolName: fwd.toolName, args: args == null ? {} : args, mode: 'gateway' };
281
+ },
282
+ };
283
+ }
284
+
285
+ // ── Build the protocol + aggregator (the meta-tool logic + the curated-direct seam). ──────────
286
+ // All wiring failures are caught so the server still starts and reports a clean JSON-RPC error
287
+ // instead of dying - a dead server hangs the client (and the test).
288
+ //
289
+ // Returns an OBJECT, not just the protocol, because the Phase-2 curated-direct call path needs
290
+ // more than the protocol:
291
+ // { protocol, aggregator, engine, ctx }
292
+ // - protocol : the 4 meta-tools (unchanged Phase-1 logic) - callers needing just the
293
+ // protocol read it off `.protocol` (backward compat).
294
+ // - aggregator : the upstream-MCP connection + curated-expose set (built from expose.json).
295
+ // Default expose.json is EMPTY so it connects to nothing.
296
+ // - engine, ctx: threaded into gatedRun for the curated-direct path (a curated tool runs
297
+ // THROUGH the same PreToolUse gate as toolfunnel_run_tool - the safety invariant).
298
+ function buildProtocol() {
299
+ // Make the config home real before anything reads from it: seeds an external home's register/
300
+ // scripts/expose/hooks from the shipped defaults on first use. A no-op when home === package
301
+ // root; idempotent and never-overwriting afterwards. A failure falls through to loadRegistry's
302
+ // own clear error.
303
+ try { ensureConfigHome(ROOT); } catch (err) { logErr('config-home init failed:', (err && err.message) || String(err)); }
304
+ const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
305
+ const loader = loadManifest(MANIFEST_PATH);
306
+
307
+ // The hook context every gated run carries (the common hook fields).
308
+ const ctx = {
309
+ session_id: `toolfunnel-mcp-${process.pid}`,
310
+ transcript_path: '',
311
+ cwd: ROOT,
312
+ };
313
+
314
+ // The LIVE build holder, created FIRST so both the adapter and the protocol read the CURRENT
315
+ // aggregator/engine after a hot-reload swaps them in place (reloadExpose mutates build.aggregator,
316
+ // reloadHooks mutates build.engine). main() threads this SAME object into the stdio loop and the
317
+ // watchers, so nothing below captures a stale instance.
318
+ // - registry : the raw Registry, so handleToolsList can build a full MCP def (with the
319
+ // entry's inputSchema) for a LOCAL tool promoted hot to the top-level surface.
320
+ // - toolStatePath : the per-tool overlay path, read FRESH per tools/list & tools/call so a UI
321
+ // hot/enabled toggle takes effect with no restart (the visibility MATRIX).
322
+ const build = { protocol: null, aggregator: null, engine: null, ctx, registry, toolStatePath: TOOL_STATE_PATH };
323
+
324
+ // Engine PROXY: protocol/gatedRun only need engine.fire. Delegating to build.engine means a
325
+ // reloadHooks swap gates the lean-forward (and local run_tool) path with the NEW engine, exactly
326
+ // like curated-direct (which reads build.engine live via deps). Without this the protocol would
327
+ // capture the engine at construction and a runtime hook change would gate one route, not the other.
328
+ const engineProxy = { fire: (event, hookCtx, extra) => build.engine.fire(event, hookCtx, extra) };
329
+
330
+ // The adapter reads the aggregator through a LIVE getter (never a captured instance).
331
+ const registryAdapter = makeRegistryAdapter(registry, {
332
+ toolStatePath: TOOL_STATE_PATH,
333
+ getAggregator: () => build.aggregator,
334
+ });
335
+
336
+ build.protocol = makeProtocol({
337
+ registry: registryAdapter,
338
+ gatedRun,
339
+ engine: engineProxy,
340
+ ctx,
341
+ howto,
342
+ });
343
+ build.engine = new HookEngine(loader, { cwd: ROOT });
344
+
345
+ // Phase 2: the aggregator over expose.json (EMPTY by default -> nothing connects).
346
+ // loadExposeStore on a missing/empty file returns an empty store; Aggregator over an empty store
347
+ // advertises no curated-direct tools and connectAll() is an instant no-op.
348
+ const store = loadExposeStore(EXPOSE_PATH);
349
+ build.aggregator = new Aggregator({
350
+ store, v3Root: ROOT,
351
+ // The WRAPPED upstream is exempt from the path-isolation guard (transparent-wrapper mode,
352
+ // by design). Fresh state read per connect, so a live wrap change counts.
353
+ wrapTargetProvider: () => {
354
+ try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state); } catch (_e) { return null; }
355
+ },
356
+ // Outbound identity per upstream: captured wrap mirror > configured clientName > built-in.
357
+ clientInfoProvider: clientInfoFor,
358
+ });
359
+
360
+ // /health downgrade flag: while a wrap is ACTIVE the /mcp surface impersonates the wrapped
361
+ // server, so the transport serves liveness-only health. Fresh read per call -
362
+ // a live wrap change counts, same as wrapTargetProvider above.
363
+ build.wrapActive = () => {
364
+ try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state) != null; } catch (_e) { return false; }
365
+ };
366
+
367
+ // MODERN-ONLY POLICY flag for the transport (the legacy GET-SSE channel refuses on it) + the
368
+ // LOUD startup warning the opt-in demands: today every real client is legacy-era, so a flipped
369
+ // switch must never be quiet. The wrap interaction gets its own line - a wrapped legacy server
370
+ // stays reachable ONLY by modern clients while this is set.
371
+ build.serveLegacy = SERVER_CONFIG.serveLegacy !== false;
372
+ if (!build.serveLegacy) {
373
+ let wrapNote = '';
374
+ try {
375
+ const pt = getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state);
376
+ if (pt) wrapNote = ` A wrap ("${pt}") is active: LEGACY clients cannot reach the wrapped server through this gateway while modern-only is set.`;
377
+ } catch (_e) { /* state unreadable - the base warning still fires */ }
378
+ process.stderr.write('[toolfunnel] MODERN-ONLY: serveLegacy:false in toolfunnel.json - legacy-era clients are REFUSED ' +
379
+ '(initialize disabled, legacy requests get a clear error, the legacy SSE channel is off). ' +
380
+ 'Every current mainstream client speaks legacy: only set this once your clients are 2026-07-28 era.' + wrapNote + '\n');
381
+ }
382
+
383
+ return build;
384
+ }
385
+
386
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
387
+ // JSON-RPC 2.0 helpers.
388
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
389
+ const JSONRPC = '2.0';
390
+ // Standard JSON-RPC error codes.
391
+ const ERR = Object.freeze({
392
+ PARSE: -32700,
393
+ INVALID_REQUEST: -32600,
394
+ METHOD_NOT_FOUND: -32601,
395
+ INVALID_PARAMS: -32602,
396
+ INTERNAL: -32603,
397
+ });
398
+
399
+ function makeResult(id, result) {
400
+ return { jsonrpc: JSONRPC, id, result };
401
+ }
402
+ function makeError(id, code, message, data) {
403
+ const error = { code, message };
404
+ if (data !== undefined) error.data = data;
405
+ // A null id is valid for errors where the request id couldn't be determined.
406
+ return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
407
+ }
408
+
409
+ // ── MCP method handlers. ──────────────────────────────────────────────────────────────────────
410
+ // `wrapId` (optional): when a passthrough WRAP is active, the wrapped upstream's OWN identity
411
+ // ({ protocolVersion, serverInfo, capabilities, instructions } from aggregator.wrappedIdentity).
412
+ // A wrap must be indistinguishable from connecting to the wrapped MCP directly, so we present ITS
413
+ // identity verbatim - name, version, capabilities, instructions - instead of ToolFunnel's own.
414
+ function handleInitialize(params, wrapId) {
415
+ // protocolVersion + serverInfo + capabilities { tools:{} } (see the architecture notes §7,
416
+ // mcp-server.test.js asserts a `tools` capability + a protocolVersion).
417
+ // Activity log (self-gating; no-op unless logging is enabled): the CLIENT-connected half of
418
+ // connect logging - the aggregator logs the upstream half. Wrapped though logger.log never throws.
419
+ try {
420
+ const info = params && params.clientInfo;
421
+ logger.log({
422
+ type: 'client',
423
+ event: 'connect',
424
+ client: info && typeof info.name === 'string' ? info.name : 'unknown',
425
+ protocolVersion: params && typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined,
426
+ });
427
+ } catch (_e) { /* never let logging affect the handshake */ }
428
+
429
+ // WRAP: present the wrapped upstream's OWN handshake, verbatim - a wrapped MCP must look like
430
+ // itself, not ToolFunnel. EXCEPT protocolVersion: initialize is the LEGACY path, and a legacy
431
+ // client handed a modern version (a wrapped MODERN upstream's identity carries 2026-07-28)
432
+ // would correctly disconnect. Clamp to a version this handshake's era can speak; identity
433
+ // fields (serverInfo/capabilities/instructions) stay verbatim.
434
+ if (wrapId) {
435
+ const upstreamVersion = typeof wrapId.protocolVersion === 'string' ? wrapId.protocolVersion : null;
436
+ const out = {
437
+ protocolVersion: (upstreamVersion && upstreamVersion !== modern.MODERN_PROTOCOL_VERSION)
438
+ ? upstreamVersion : PROTOCOL_VERSION,
439
+ serverInfo: wrapId.serverInfo || SERVER_INFO,
440
+ capabilities: wrapId.capabilities || { tools: { listChanged: true } },
441
+ };
442
+ if (typeof wrapId.instructions === 'string' && wrapId.instructions) out.instructions = wrapId.instructions;
443
+ return out;
444
+ }
445
+
446
+ return {
447
+ protocolVersion: PROTOCOL_VERSION,
448
+ serverInfo: SERVER_INFO,
449
+ capabilities: {
450
+ // We are a tool host. `tools: {}` declares the capability. Phase 2 wires curated-direct
451
+ // hot-updates, so we now advertise listChanged:true - the server may emit
452
+ // notifications/tools/list_changed when the curated-direct expose set changes (see
453
+ // emitToolsListChanged). The mcp-server.test.js assertion only requires a `tools`
454
+ // capability key + a protocolVersion, both still present.
455
+ tools: { listChanged: true },
456
+ },
457
+ };
458
+ }
459
+
460
+ function handleToolsList(protocol, aggregator, opts) {
461
+ // The TOP-LEVEL surface (tools/list - injected EVERY turn) is assembled from the per-tool `hot`
462
+ // axis of the visibility MATRIX, deduped by name with a fixed precedence (first writer wins):
463
+ //
464
+ // 1. META-TOOLS hot defaults TRUE - the 4 management tools; a meta-tool can be HIDDEN
465
+ // from the top level (state[name].hot === false).
466
+ // 2. LOCAL hot tools hot defaults FALSE - a register tool promoted hot (and enabled); its def
467
+ // carries the entry's inputSchema (or {type:object}).
468
+ // 3. CURATED-DIRECT the existing expose[] path (an enabled expose entry == top-level).
469
+ // 4. UPSTREAM hot tools hot defaults FALSE - a connected upstream tool promoted hot by its
470
+ // surfaced name (lean run semantics; not via expose[]).
471
+ //
472
+ // Precedence is local-register-wins over an upstream of the same name (matches the lean rule), and
473
+ // a META-tool can NEVER be shadowed by a local/upstream tool of the same name (safety). With the
474
+ // default EMPTY state + EMPTY expose.json this is exactly the 4 meta-tools - byte-identical to the
475
+ // pre-matrix surface. `opts` is OPTIONAL: a Phase-1 / bare-protocol caller (no registry/state) gets
476
+ // meta-tools (all hot) + curated-direct, the prior behaviour. NEVER throws.
477
+ const o = opts || {};
478
+ const registry = o.registry || null;
479
+ const statePath = typeof o.toolStatePath === 'string' && o.toolStatePath ? o.toolStatePath : null;
480
+ let state = {};
481
+ if (statePath) {
482
+ // A CORRUPT overlay (present but unparseable) is not silent: it warns. A wrap lives in this
483
+ // file, so a corrupt overlay would otherwise SILENTLY drop the wrap and leak the funnel surface
484
+ // (meta-tools included) to a wrapped client. We keep serving
485
+ // defaults (fail-open is right for the common non-wrapped case) but the operator now SEES it.
486
+ const res = loadToolStateResult(statePath);
487
+ state = res.state;
488
+ if (res.parseError) warnCorruptToolState(statePath);
489
+ }
490
+
491
+ // ── PASSTHROUGH (0.6.0): the transparent-wrapper mode ────────────────────────────────────────
492
+ // When tools.state.json carries `"passthrough": "<upstreamId>"`, the advertised surface is
493
+ // EXACTLY that upstream's tools (real schemas, implicitly hot) - no meta-tools, no locals, no
494
+ // curated-direct. handleToolsCall routes by the same rule, so the callable surface stays equal
495
+ // to the advertised one, and every call still fires the PreToolUse gate. The wrap survives an
496
+ // upstream that is down (empty list + a throttled warning) rather than falling back to the
497
+ // funnel surface - a wrapped gateway must never leak its own tools because a child was slow.
498
+ const passthroughId = getPassthrough(state);
499
+ if (passthroughId) {
500
+ return { tools: passthroughDefinitions(aggregator, state, passthroughId) };
501
+ }
502
+
503
+ const byName = new Map(); // name -> MCP tool def; first writer wins (the precedence above)
504
+
505
+ // The 4 meta-tool names are RESERVED: nothing else may be advertised under them, even when a meta
506
+ // is hidden (else a curated-direct/upstream tool aliased onto a meta name would be advertised yet
507
+ // uncallable - handleToolsCall routes a meta name to the meta gate first).
508
+ const metaNames = new Set(protocol.toolDefinitions().map((d) => d && d.name));
509
+
510
+ // 1. META-TOOLS - hot by default; hideable via state[name].hot === false (footgun: hiding all is
511
+ // the "ordinary tools as an MCP" pattern - warned below + at the write site).
512
+ for (const def of protocol.toolDefinitions()) {
513
+ if (def && isToolHot(state, def.name, true) && !byName.has(def.name)) byName.set(def.name, def);
514
+ }
515
+
516
+ // 2. LOCAL hot tools - opt-in (hot:true) AND enabled. A real MCP def from the register entry.
517
+ if (registry) {
518
+ for (const def of localHotDefinitions(registry, state)) {
519
+ if (def && !byName.has(def.name)) byName.set(def.name, def);
520
+ }
521
+ }
522
+
523
+ // 3. CURATED-DIRECT (expose[]) - unchanged. exposedToolDefinitions never throws, but guard anyway.
524
+ // Skip any def colliding with a RESERVED meta name (never advertise a phantom uncallable tool).
525
+ if (aggregator && typeof aggregator.exposedToolDefinitions === 'function') {
526
+ try {
527
+ const defs = aggregator.exposedToolDefinitions();
528
+ if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name)) byName.set(def.name, def);
529
+ } catch (err) {
530
+ logErr('exposedToolDefinitions failed:', (err && err.message) || String(err));
531
+ }
532
+ }
533
+
534
+ // 4. UPSTREAM hot tools - a connected upstream tool promoted hot by its surfaced name (and enabled),
535
+ // not already surfaced. Skip a name that is RESERVED (meta) or TAKEN by a local tool's id/display
536
+ // name - local-register-wins, AND it keeps the advertised name resolving to what actually runs
537
+ // (a local display-name collision would otherwise advertise the upstream but run the local tool).
538
+ if (aggregator && typeof aggregator.leanToolDefinitions === 'function') {
539
+ try {
540
+ for (const d of aggregator.leanToolDefinitions()) {
541
+ if (!d || byName.has(d.name) || metaNames.has(d.name) || localNameTaken(registry, d.name)) continue;
542
+ if (isToolHot(state, d.name, false) && isToolEnabled(state, d.name)) {
543
+ byName.set(d.name, { name: d.name, description: d.description || '', inputSchema: d.inputSchema || { type: 'object' } });
544
+ }
545
+ }
546
+ } catch (err) {
547
+ logErr('leanToolDefinitions (hot) failed:', (err && err.message) || String(err));
548
+ }
549
+ }
550
+
551
+ // Footgun warning (server-side, throttled): if NO meta-tool is on the surface, in-band tool
552
+ // discovery/run is gone - recovery is the --ui console or hand-editing tools/tools.state.json.
553
+ const metaPresent = [...metaNames].some((n) => byName.has(n));
554
+ if (!metaPresent && !_noMetaWarned) {
555
+ logErr('WARNING: all meta-tools are hidden (hot:false) - no in-band tool discovery/run remains. Recover via `--ui` or by editing tools/tools.state.json.');
556
+ _noMetaWarned = true;
557
+ } else if (metaPresent) {
558
+ _noMetaWarned = false;
559
+ }
560
+
561
+ return { tools: [...byName.values()] };
562
+ }
563
+
564
+ /** Module flag so the all-metas-hidden warning logs ONCE per episode (not on every tools/list). */
565
+ let _noMetaWarned = false;
566
+
567
+ /** Throttled (once per episode) warning that tools.state.json is present but corrupt - so a dropped
568
+ * wrap / lost off-switch is never SILENT. Reset would need a process restart; that's fine. */
569
+ let _corruptStateWarned = false;
570
+ function warnCorruptToolState(statePath) {
571
+ if (_corruptStateWarned) return;
572
+ _corruptStateWarned = true;
573
+ logErr(`WARNING: ${statePath} is present but not valid JSON - ignoring it and serving DEFAULTS. ` +
574
+ 'If a passthrough wrap or a per-tool disable was set, it is NOT in effect right now. Fix the file.');
575
+ }
576
+
577
+ /**
578
+ * The advertised surface under PASSTHROUGH: exactly the wrapped upstream's tools, presented
579
+ * under their ORIGINAL names (`d.tool`, not the collision-proof surfaced `<upstream>_<tool>`) -
580
+ * with ONE upstream, collisions are impossible, and a transparent wrapper must look like the
581
+ * MCP it wraps, tool names included. Each def carries its real discovered schema. An explicit
582
+ * enabled:false (keyed by the SURFACED name - the stable key the UI/tf_* tools write) still
583
+ * filters a tool out; the hot axis is ignored (the wrap makes everything implicitly hot). A down
584
+ * or unknown upstream yields an EMPTY list plus a throttled stderr warning - never a fallback to
585
+ * the funnel surface. NEVER throws.
586
+ */
587
+ function passthroughDefinitions(aggregator, state, upstreamId) {
588
+ const out = [];
589
+ if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') {
590
+ warnPassthroughEmpty(upstreamId, 'no aggregator');
591
+ return out;
592
+ }
593
+ try {
594
+ // The upstream's RAW discovered defs - a wrapped surface must advertise the tool exactly as
595
+ // the upstream does (annotations, title, outputSchema, future fields). The old projection to
596
+ // {name, description, inputSchema} made all 13 real server-everything tools differ from a
597
+ // direct connection (wrap-lab, 2026-07-17). leanToolDefinitions still drives the walk: it
598
+ // carries the SURFACED name (the stable enabled:false key) and the collision guard.
599
+ const rawByName = new Map();
600
+ if (typeof aggregator.toolsByUpstream === 'function') {
601
+ for (const t of (aggregator.toolsByUpstream()[upstreamId] || [])) {
602
+ if (t && typeof t.name === 'string') rawByName.set(t.name, t);
603
+ }
604
+ }
605
+ for (const d of aggregator.leanToolDefinitions()) {
606
+ if (!d || d.upstream !== upstreamId) continue;
607
+ if (!isToolEnabled(state, d.name)) continue;
608
+ const original = typeof d.tool === 'string' && d.tool ? d.tool : d.name;
609
+ const raw = rawByName.get(original);
610
+ out.push(raw || {
611
+ name: original,
612
+ description: d.description || '',
613
+ inputSchema: d.inputSchema || { type: 'object' },
614
+ });
615
+ }
616
+ } catch (err) {
617
+ logErr('passthrough: leanToolDefinitions failed:', (err && err.message) || String(err));
618
+ }
619
+ if (out.length === 0) warnPassthroughEmpty(upstreamId, 'upstream not connected or has no tools');
620
+ else _passthroughWarned = false;
621
+ return out;
622
+ }
623
+
624
+ /** Throttled (once per episode) warning when a wrapped gateway has nothing to advertise. */
625
+ let _passthroughWarned = false;
626
+ function warnPassthroughEmpty(upstreamId, why) {
627
+ if (_passthroughWarned) return;
628
+ _passthroughWarned = true;
629
+ logErr(`WARNING: passthrough wrap of upstream "${upstreamId}" advertises NO tools (${why}). ` +
630
+ 'Check the upstream is attached + enabled in mcp/expose.json, or clear the wrap with `toolfunnel wrap --off`.');
631
+ }
632
+
633
+ /**
634
+ * Resolve a PASSTHROUGH call name (the ORIGINAL upstream tool name, as advertised) back to the
635
+ * SURFACED name the aggregator/register resolve internally. Returns the surfaced name, or null
636
+ * when the name is not an advertised wrapped tool (unknown, other upstream, or explicitly
637
+ * disabled - callable == advertised). The call-routing twin of passthroughDefinitions. The
638
+ * PreToolUse gate fires on the SURFACED name - stable whether the wrap is on or off, so a gate
639
+ * authored before wrapping keeps gating the same tool after. NEVER throws.
640
+ */
641
+ function resolvePassthroughName(aggregator, state, upstreamId, name) {
642
+ if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') return null;
643
+ try {
644
+ const d = aggregator
645
+ .leanToolDefinitions()
646
+ .find((x) => x && x.upstream === upstreamId && ((x.tool || x.name) === name));
647
+ if (!d) return null;
648
+ return isToolEnabled(state, d.name) ? d.name : null;
649
+ } catch (_e) {
650
+ return null;
651
+ }
652
+ }
653
+
654
+ /** Is `name` already a LOCAL register tool's id OR display name? Used to keep the lean-rule
655
+ * "local-register wins" consistent on the top-level surface (and so an advertised upstream name is
656
+ * never run as a local tool via the run path's display-name resolution). NEVER throws. */
657
+ function localNameTaken(registry, name) {
658
+ if (!registry) return false;
659
+ try {
660
+ return registry.list().some((b) => b && (b.id === name || b.name === name));
661
+ } catch (_e) {
662
+ return false;
663
+ }
664
+ }
665
+
666
+ /**
667
+ * The MCP tool definitions for every LOCAL register tool promoted HOT (and enabled). Each is built
668
+ * from the register entry so a directly-callable top-level tool carries a real input schema:
669
+ * - name = the register `id` (an identifier-clean MCP tool name; the human `name` may carry
670
+ * spaces, which is a poor MCP tool name). The id is also what toolfunnel_run_tool
671
+ * resolves, and the PreToolUse gate fires on `entry.name || id` for BOTH the direct
672
+ * call and a run_tool call (the adapter's resolveExecution sets that toolName), so a
673
+ * gate authored via the UI - which keys the matcher on that same `entry.name || id`
674
+ * - gates both routes identically. No new gate-naming footgun vs run_tool today.
675
+ * - description = the entry summary (falls back to the human name, then the id).
676
+ * - inputSchema = the entry's optional `inputSchema` object, else a free-form {type:object}.
677
+ * NEVER throws.
678
+ * @param {object} registry the raw Registry
679
+ * @param {object} state the loaded tool-state overlay
680
+ * @returns {Array<{name:string, description:string, inputSchema:object}>}
681
+ */
682
+ function localHotDefinitions(registry, state) {
683
+ const out = [];
684
+ let briefs;
685
+ try { briefs = registry.list(); } catch (_e) { return out; }
686
+ for (const b of briefs) {
687
+ if (!b || typeof b.id !== 'string') continue;
688
+ if (!isToolHot(state, b.id, false)) continue; // opt-in only (default off)
689
+ if (!isToolEnabled(state, b.id)) continue; // a disabled tool is never hot
690
+ let entry;
691
+ try { entry = registry.getEntry(b.id); } catch (_e) { continue; }
692
+ out.push(localToolDefinition(entry));
693
+ }
694
+ return out;
695
+ }
696
+
697
+ /** Build one top-level MCP def from a register entry. name = the id (clean identifier). description
698
+ * = summary || name || id. inputSchema = entry.inputSchema when an object, else free-form {type:object}. */
699
+ function localToolDefinition(entry) {
700
+ const schema = entry && entry.inputSchema && typeof entry.inputSchema === 'object' && !Array.isArray(entry.inputSchema)
701
+ ? entry.inputSchema
702
+ : { type: 'object' };
703
+ const description = (entry && typeof entry.summary === 'string' && entry.summary)
704
+ ? entry.summary
705
+ : ((entry && typeof entry.name === 'string' && entry.name) ? entry.name : entry.id);
706
+ return { name: entry.id, description, inputSchema: schema };
707
+ }
708
+
709
+ async function handleToolsCall(protocol, aggregator, deps, params) {
710
+ const p = params || {};
711
+ const name = p.name;
712
+ const args = p.arguments;
713
+ if (typeof name !== 'string' || name.length === 0) {
714
+ // Shape the bad-params case as an MCP tools/call error result (isError) rather than a
715
+ // protocol-level JSON-RPC error, so the model sees a usable message.
716
+ return {
717
+ content: [{ type: 'text', text: 'tools/call requires a string "name".' }],
718
+ isError: true,
719
+ };
720
+ }
721
+
722
+ // ── Routing (see the architecture notes §2; the curated-direct invariant in §9; the MATRIX) ────
723
+ // 1. A meta-tool name -> protocol.dispatch (unchanged; a meta-tool can NEVER be shadowed).
724
+ // 2. ELSE a LOCAL tool promoted HOT, called directly -> run it exactly like toolfunnel_run_tool
725
+ // {name,args}: ONE path, reusing the PreToolUse gate + reference-mode + register resolution.
726
+ // Local-register wins over a curated-direct of the same name (the lean rule + list precedence).
727
+ // 3. ELSE a curated-direct tool (expose[]) -> run THROUGH gatedRun with transparent upstream-
728
+ // envelope passthrough. THE INVARIANT: it can NEVER reach the upstream without the gate.
729
+ // 4. ELSE an UPSTREAM tool promoted HOT (by surfaced name, not via expose[]), called directly ->
730
+ // run it through the gate with the upstream envelope passed through VERBATIM, exactly like
731
+ // route 3 - a hot upstream tool and a curated one must be indistinguishable to the caller.
732
+ // 5. ELSE -> protocol.dispatch, which returns the clean unknown-tool error.
733
+ // Only tools ACTUALLY advertised hot are directly callable (the advertised surface == the callable
734
+ // surface - a non-promoted tool called directly gets the clean unknown-tool error, not a silent run).
735
+ // Load the tool-state overlay ONCE for the routing checks - fresh per call so a UI toggle is live.
736
+ const d0 = deps || {};
737
+ let state = {};
738
+ if (d0.toolStatePath) { try { state = loadToolState(d0.toolStatePath); } catch (_e) { state = {}; } }
739
+
740
+ // 0. PASSTHROUGH: under a wrap the callable surface is EXACTLY the wrapped upstream's tools -
741
+ // the meta-tools and everything else are uncallable (the advertised surface is the callable
742
+ // surface, same invariant as the matrix). The call runs the UPSTREAM tool directly through the
743
+ // gate (runLeanForward), NOT via protocol.runTool - protocol.runTool's adapter is local-first,
744
+ // so a local register tool whose id collides with the wrapped upstream's surfaced name would
745
+ // silently execute INSTEAD of the upstream. A transparent
746
+ // wrap must run the upstream regardless of a local shadow; runLeanForward resolves straight off
747
+ // the aggregator, so the gate still fires on the surfaced name and the upstream always runs.
748
+ const passthroughId = getPassthrough(state);
749
+ // Bridge B RESUME: an MRTR retry carries requestState (+ inputResponses) - answer the
750
+ // upstream's held question and continue the SUSPENDED call. It never re-enters the gate:
751
+ // the gate fired when the original call started, and no new upstream call is issued.
752
+ // Checked BEFORE the passthrough guard: a retry after the wrap was CLEARED mid-suspension
753
+ // (A->null) must still reach the token machinery - whose identity check cancels the stranded
754
+ // upstream question and answers neutrally - instead of falling into funnel routing and
755
+ // leaking an "Unknown meta-tool" funnel tell mid-conversation. requestState
756
+ // is protocol-level MRTR vocabulary, never ordinary tool args, so this intercept is safe.
757
+ if (p.requestState !== undefined || p.inputResponses !== undefined) {
758
+ return await resumeElicit(p, d0.modernCaller === true, passthroughId, d0.cancelKey || null);
759
+ }
760
+ if (passthroughId) {
761
+ // The client calls the ORIGINAL tool name (as advertised); resolve it to the SURFACED name the
762
+ // aggregator + gate use (stable whether wrapped or not).
763
+ const surfaced = resolvePassthroughName(aggregator, state, passthroughId, name);
764
+ if (surfaced) {
765
+ // Forward the caller's request-scoped _meta minus the modern protocol trio - same
766
+ // strip-and-merge as forwardWrapped. progressToken lives here; dropping it killed progress
767
+ // for every wrapped call.
768
+ const meta = stripProtocolMeta(p._meta);
769
+ return await runLeanForward(aggregator, { engine: d0.engine, ctx: d0.ctx }, surfaced, args,
770
+ { calledName: name, meta, cancelKey: d0.cancelKey || null, modernCaller: d0.modernCaller === true });
771
+ }
772
+ return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
773
+ }
774
+
775
+ // 1. A meta-tool - but callable ONLY while it is on the advertised top-level surface (hot, default
776
+ // true). A meta HIDDEN via hot:false is dropped from tools/list AND becomes uncallable, so the
777
+ // callable surface == the advertised surface even for the meta-tools - the "ordinary tools as an
778
+ // MCP" lockdown is REAL, not cosmetic. (A meta-tool is still never SHADOWED by a same-named
779
+ // local/upstream tool: this check runs FIRST, so a hot meta always wins precedence.)
780
+ if (isMetaTool(protocol, name)) {
781
+ if (isToolHot(state, name, true)) {
782
+ return wrapProtocolResult(await protocol.dispatch(name, args || {}));
783
+ }
784
+ // Hidden meta-tool: not advertised, so not callable - the clean unknown-tool error (never run it).
785
+ return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
786
+ }
787
+
788
+ if (d0.registry && isPromotedLocal(d0.registry, state, name)) {
789
+ return wrapProtocolResult(await protocol.runTool({ name, args: args == null ? {} : args }));
790
+ }
791
+
792
+ if (aggregator && typeof aggregator.isExposed === 'function' && aggregator.isExposed(name)) {
793
+ // The caller's non-protocol _meta rides the forward here too - the progressToken is the
794
+ // upstream-side keep-alive (beats are NOT fanned out to funnel clients; wrap mode relays them).
795
+ return await runCuratedDirect(aggregator, deps, name, args, stripProtocolMeta(p._meta));
796
+ }
797
+
798
+ if (isPromotedUpstream(aggregator, d0.registry, state, name)) {
799
+ // VERBATIM envelope, exactly like curated-direct - routing this through protocol.runTool's
800
+ // lean adapter flattened multi-block content to one JSON-stringified text block and dropped
801
+ // structuredContent: identical advertised def, broken results.
802
+ return await runHotUpstreamDirect(aggregator, { engine: d0.engine, ctx: d0.ctx }, name, args,
803
+ stripProtocolMeta(p._meta));
804
+ }
805
+
806
+ // Not a meta-tool, not promoted, not exposed -> let the protocol return its clean unknown-tool error.
807
+ return wrapProtocolResult(await protocol.dispatch(name, args || {}));
808
+ }
809
+
810
+ /**
811
+ * Is `name` a LOCAL register tool that is promoted HOT and enabled (so it's on the top-level surface
812
+ * AND therefore directly callable)? Resolves the advertised name (entry.name || id) back to the
813
+ * register entry, then checks the hot+enabled flags by the tool's id (the state key). NEVER throws.
814
+ */
815
+ function isPromotedLocal(registry, state, name) {
816
+ const entry = localEntryByAdvertisedName(registry, name);
817
+ if (!entry) return false;
818
+ return isToolHot(state, entry.id, false) && isToolEnabled(state, entry.id);
819
+ }
820
+
821
+ /** Resolve a top-level call name back to its register brief. The advertised top-level name is the
822
+ * `id`; the human `name` is accepted too (forgiving callers). Returns the brief {id,name,...} or
823
+ * null. NEVER throws. */
824
+ function localEntryByAdvertisedName(registry, name) {
825
+ try {
826
+ const briefs = registry.list();
827
+ let hit = briefs.find((b) => b && b.id === name);
828
+ if (!hit) hit = briefs.find((b) => b && b.name === name);
829
+ return hit || null;
830
+ } catch (_e) {
831
+ return null;
832
+ }
833
+ }
834
+
835
+ /**
836
+ * Is `name` a connected UPSTREAM tool (by surfaced name) promoted HOT and enabled? Only reached for a
837
+ * name that is NOT a meta-tool, NOT a promoted local tool, and NOT curated-direct (those are routed
838
+ * first), so this is the "hot upstream, not via expose[]" case. NEVER throws.
839
+ */
840
+ function isPromotedUpstream(aggregator, registry, state, name) {
841
+ if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') return false;
842
+ // local-register wins: a name that is a local tool's id/display name is NOT an upstream-hot route,
843
+ // even if an upstream surfaces that name (it would otherwise run the LOCAL tool via the run path's
844
+ // display-name resolution - advertised-upstream / executed-local). Matches handleToolsList step 4.
845
+ if (localNameTaken(registry, name)) return false;
846
+ try {
847
+ const d = aggregator.leanToolDefinitions().find((x) => x && x.name === name);
848
+ if (!d) return false;
849
+ return isToolHot(state, d.name, false) && isToolEnabled(state, d.name);
850
+ } catch (_e) {
851
+ return false;
852
+ }
853
+ }
854
+
855
+ /** Is `name` one of the 4 lean meta-tools the protocol owns? */
856
+ function isMetaTool(protocol, name) {
857
+ // protocol.META_TOOLS is { LIST, INSTRUCTIONS, RUN, HOWTO } -> string values.
858
+ const meta = protocol && protocol.META_TOOLS;
859
+ if (!meta || typeof meta !== 'object') return false;
860
+ for (const key of Object.keys(meta)) {
861
+ if (meta[key] === name) return true;
862
+ }
863
+ return false;
864
+ }
865
+
866
+ /**
867
+ * Run a CURATED-DIRECT tool through the hook gate. Resolve {execute} from the aggregator, then
868
+ * hand it to gatedRun with the threaded engine + ctx so PreToolUse fires on the DOWNSTREAM name
869
+ * (the gate's matcher target) BEFORE the upstream tool is ever invoked. The result is wrapped in
870
+ * the MCP tools/call envelope: isError when !ok, surfacing reason (a PreToolUse block) or error
871
+ * (a thrown upstream call). NEVER throws - a misbehaving aggregator/gate becomes a clean error
872
+ * result so the loop survives.
873
+ */
874
+ /** The caller's request-scoped _meta minus the modern protocol keys - one strip shared by the
875
+ * wrap forward and both funnel forward paths. Returns undefined when nothing survives. */
876
+ function stripProtocolMeta(m) {
877
+ if (!m || typeof m !== 'object' || Array.isArray(m)) return undefined;
878
+ const out = {};
879
+ for (const k of Object.keys(m)) {
880
+ if (!k.startsWith('io.modelcontextprotocol/')) out[k] = m[k];
881
+ }
882
+ return Object.keys(out).length ? out : undefined;
883
+ }
884
+
885
+ async function runCuratedDirect(aggregator, deps, name, args, meta) {
886
+ const d = deps || {};
887
+ // resolveExposedExecution -> { execute, toolName, upstream } | null. It can change between the
888
+ // isExposed() check and here only if the expose set mutated mid-call; guard for null.
889
+ let resolution = null;
890
+ try {
891
+ resolution = aggregator.resolveExposedExecution(name, args, meta);
892
+ } catch (err) {
893
+ return {
894
+ content: [{ type: 'text', text: `curated tool "${name}" failed to resolve: ${(err && err.message) || String(err)}` }],
895
+ isError: true,
896
+ };
897
+ }
898
+ if (!resolution || typeof resolution.execute !== 'function') {
899
+ // Raced away (or never really runnable) - surface a clean error rather than reaching upstream.
900
+ return {
901
+ content: [{ type: 'text', text: `curated tool "${name}" is no longer runnable` }],
902
+ isError: true,
903
+ };
904
+ }
905
+
906
+ // The gate matches on the DOWNSTREAM name (resolution.toolName === the `as` name). gatedRun is
907
+ // contracted never to throw, but we still try/catch so a wiring bug can't crash the loop.
908
+ let result;
909
+ try {
910
+ result = await gatedRun({
911
+ engine: d.engine,
912
+ ctx: d.ctx,
913
+ toolName: resolution.toolName || name,
914
+ args: args == null ? {} : args,
915
+ execute: resolution.execute,
916
+ });
917
+ } catch (err) {
918
+ return {
919
+ content: [{ type: 'text', text: `gated execution of "${name}" failed: ${(err && err.message) || String(err)}` }],
920
+ isError: true,
921
+ };
922
+ }
923
+
924
+ // Gate-level failure (a PreToolUse block, or a thrown upstream call): surface the most
925
+ // informative message - a block carries `reason`, a thrown call carries `error`.
926
+ if (!(result && result.ok === true)) {
927
+ const message = firstString(
928
+ result && result.error && (result.error.message || String(result.error)),
929
+ result && result.reason,
930
+ 'curated tool call failed'
931
+ );
932
+ return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
933
+ }
934
+
935
+ // Success: result.output IS the upstream's own tools/call envelope ({ content, isError }).
936
+ // Pass it THROUGH transparently so the client receives the upstream's real content blocks
937
+ // (text / image / etc.) AND its isError flag - rather than the whole envelope JSON-stringified
938
+ // into one text block (which also used to swallow the upstream's isError). runCuratedDirect only
939
+ // ever runs FORWARDED upstream tools, so result.output is always an MCP envelope here.
940
+ const out = result.output;
941
+ if (out && typeof out === 'object' && Array.isArray(out.content)) {
942
+ const shaped = { content: out.content, isError: out.isError === true };
943
+ // structuredContent rides along - callTool preserves it from the upstream; dropping it HERE
944
+ // silently stripped modern upstreams' structured results on the curated-direct path while
945
+ // the wrap path returned them verbatim.
946
+ if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
947
+ return shaped;
948
+ }
949
+ // Defensive fallback: a non-standard upstream payload - wrap it as text so the client still gets
950
+ // a well-formed envelope rather than an empty/invalid content array.
951
+ return { content: [{ type: 'text', text: stringifyContent(out) }], isError: false };
952
+ }
953
+
954
+ /**
955
+ * Run a HOT-PROMOTED upstream tool (matrix route 4 - on the top-level surface WITHOUT an
956
+ * expose[] entry) through the hook gate with the upstream's envelope passed through VERBATIM -
957
+ * multi-block content, isError, structuredContent - exactly like curated-direct. The lean
958
+ * adapter's unwrap flattened all of that into one JSON-stringified text block.
959
+ * resolveRawExecution is the envelope-verbatim resolver (nothing about it is wrap-specific);
960
+ * gatedRun still fires PreToolUse on the surfaced name BEFORE the upstream is invoked. Error
961
+ * text is funnel-honest (this is funnel mode - no invisibility requirement). NEVER throws.
962
+ */
963
+ async function runHotUpstreamDirect(aggregator, deps, name, args, meta) {
964
+ const d = deps || {};
965
+ let resolution = null;
966
+ try {
967
+ if (aggregator && typeof aggregator.resolveRawExecution === 'function') {
968
+ resolution = aggregator.resolveRawExecution(name, args, meta);
969
+ }
970
+ } catch (err) {
971
+ return {
972
+ content: [{ type: 'text', text: `tool "${name}" failed to resolve: ${(err && err.message) || String(err)}` }],
973
+ isError: true,
974
+ };
975
+ }
976
+ if (!resolution || typeof resolution.execute !== 'function') {
977
+ // Raced away (tools changed / upstream dropped) - clean unknown-tool error, never a silent run.
978
+ return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
979
+ }
980
+ let result;
981
+ try {
982
+ result = await gatedRun({
983
+ engine: d.engine,
984
+ ctx: d.ctx,
985
+ toolName: resolution.toolName || name,
986
+ args: args == null ? {} : args,
987
+ execute: resolution.execute,
988
+ });
989
+ } catch (err) {
990
+ return {
991
+ content: [{ type: 'text', text: `gated execution of "${name}" failed: ${(err && err.message) || String(err)}` }],
992
+ isError: true,
993
+ };
994
+ }
995
+ if (!(result && result.ok === true)) {
996
+ const message = firstString(
997
+ result && result.error && (result.error.message || String(result.error)),
998
+ result && result.reason,
999
+ 'tool call failed'
1000
+ );
1001
+ return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
1002
+ }
1003
+ // Success: result.output IS the upstream's own tools/call envelope - pass it through, with
1004
+ // structuredContent riding along (same shaping as runCuratedDirect).
1005
+ const out = result.output;
1006
+ if (out && typeof out === 'object' && Array.isArray(out.content)) {
1007
+ const shaped = { content: out.content, isError: out.isError === true };
1008
+ if (out.structuredContent !== undefined) shaped.structuredContent = out.structuredContent;
1009
+ return shaped;
1010
+ }
1011
+ // Defensive fallback: a non-standard upstream payload still returns a well-formed envelope.
1012
+ return { content: [{ type: 'text', text: stringifyContent(out) }], isError: false };
1013
+ }
1014
+
1015
+ /**
1016
+ * runLeanForward - run a wrapped upstream tool (by surfaced name) through the gate DIRECTLY off
1017
+ * the aggregator, bypassing the local-first register adapter. Used by passthrough so a local
1018
+ * register tool sharing the surfaced name can never shadow the wrapped upstream's tool. The
1019
+ * execution is RAW (resolveRawExecution): the upstream's tools/call envelope - multi-block
1020
+ * content, isError, structuredContent - returns VERBATIM, exactly as a direct connection would
1021
+ * deliver it. Only a gate block or a transport failure synthesises an envelope. NEVER throws.
1022
+ * Synthetic error text is NEUTRAL and keyed to the name the CLIENT called: the surfaced
1023
+ * `<upstream>_<tool>` form and internal error strings ("Aggregator.ensureConnected: ...") are both
1024
+ * naked wrap tells. A PreToolUse gate block keeps its reason - the gate is deliberate
1025
+ * operator policy, not an internal leak.
1026
+ * @param {object} aggregator
1027
+ * @param {{engine?:object, ctx?:object}} deps
1028
+ * @param {string} surfaced the surfaced upstream tool name (`<upstream>_<tool>` or its `as`)
1029
+ * @param {any} args
1030
+ * @param {{calledName?:string, meta?:object}} [opts] the client-called name (for error text) +
1031
+ * the request-scoped non-protocol _meta to forward (progressToken et al.)
1032
+ * @returns {Promise<object>} the upstream's verbatim result, or a synthetic { content, isError }
1033
+ */
1034
+ async function runLeanForward(aggregator, deps, surfaced, args, opts) {
1035
+ const d = deps || {};
1036
+ const o = opts || {};
1037
+ const called = typeof o.calledName === 'string' && o.calledName ? o.calledName : surfaced;
1038
+ if (!aggregator || typeof aggregator.resolveRawExecution !== 'function') {
1039
+ return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1040
+ }
1041
+ // Cancel translation for the TOOL path: register the upstream rpc id
1042
+ // under the caller's (connection, client id) the moment the forward is issued, exactly like
1043
+ // forwardWrapped - a wrapped tools/call is the DOMINANT long-running-cancel case and was the
1044
+ // one hole left in the map. Registered post-gate: a cancel landing during the gate phase is a
1045
+ // best-effort drop (micro-window, same accepted class as the rpc-issue microtask window).
1046
+ // The rpc id is ALSO captured locally: a Bridge B suspension must extend that request's
1047
+ // timeout (a human answers at human speed; the dead-upstream clock must not kill the hold).
1048
+ let upstreamRpcId = null;
1049
+ const track = {
1050
+ set rpcId(v) {
1051
+ upstreamRpcId = v;
1052
+ if (o.cancelKey) inflightForwards.set(o.cancelKey, v);
1053
+ },
1054
+ };
1055
+ let resolution = null;
1056
+ try {
1057
+ resolution = aggregator.resolveRawExecution(surfaced, args, o.meta, track);
1058
+ } catch (_err) {
1059
+ return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1060
+ }
1061
+ if (!resolution || typeof resolution.execute !== 'function') {
1062
+ return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1063
+ }
1064
+ // Bridge B: the upstream may send `elicitation/create` mid-call and hold the call open. Race
1065
+ // the real forward against an elicit INSIDE the gated execute, so the gate + PostToolUse fire
1066
+ // exactly once around whichever wins. An elicit win resolves gatedRun with a sentinel; the real
1067
+ // upstream promise keeps running and is either suspended (modern caller -> MRTR) or completed
1068
+ // after an auto-decline (legacy caller - no backwards-request relay yet; see KNOWN_BUGS).
1069
+ // The sentinel handed back through gatedRun is SANITISED: PostToolUse hooks JSON-stringify
1070
+ // tool_response onto hook stdin, and the full elicit object carries the live McpClient -
1071
+ // transport guts + the upstream's env (API keys). Hooks see only the question; the real
1072
+ // elicit object (with the client to answer on) rides this closure.
1073
+ let realPromise = null;
1074
+ let elicitCaught = null;
1075
+ const racedExecute = async () => {
1076
+ realPromise = resolution.execute();
1077
+ const winner = await raceElicit(resolution.upstream, realPromise);
1078
+ if (winner.kind === 'elicit') {
1079
+ elicitCaught = winner.value;
1080
+ return { __tfElicit: { elicitation: winner.value.params } };
1081
+ }
1082
+ return winner.value;
1083
+ };
1084
+ let result;
1085
+ try {
1086
+ result = await gatedRun({
1087
+ engine: d.engine,
1088
+ ctx: d.ctx,
1089
+ toolName: resolution.toolName || surfaced, // the gate matches the surfaced name (stable)
1090
+ args: args == null ? {} : args,
1091
+ execute: racedExecute,
1092
+ });
1093
+ } catch (_err) {
1094
+ return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1095
+ } finally {
1096
+ if (o.cancelKey) inflightForwards.delete(o.cancelKey); // settled - cancels can no longer target it
1097
+ }
1098
+ if (result && result.ok === true && result.output && result.output.__tfElicit && elicitCaught) {
1099
+ const elicit = elicitCaught;
1100
+ if (o.modernCaller === true) {
1101
+ return suspendForElicit(elicit, realPromise, resolution.upstream, called, upstreamRpcId);
1102
+ }
1103
+ // Legacy caller: it cannot receive a backwards request, and the relay is future work -
1104
+ // DECLINE so the upstream's held call completes, then serve its actual outcome.
1105
+ logErr(`bridge-b: legacy client cannot receive elicitation - auto-declined for "${called}"`);
1106
+ try { elicit.client.respondToServer(elicit.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
1107
+ try {
1108
+ const out = await realPromise;
1109
+ result = { ok: true, blocked: false, output: out };
1110
+ } catch (_err) {
1111
+ return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1112
+ }
1113
+ }
1114
+ // Success -> the upstream's envelope, VERBATIM (never re-shaped: re-stringifying multi-block or
1115
+ // structured results both destroys data and is a wrap tell).
1116
+ if (result && result.ok === true && result.output && typeof result.output === 'object' &&
1117
+ Array.isArray(result.output.content)) {
1118
+ return result.output;
1119
+ }
1120
+ // A gate BLOCK carries its reason ONLY when it is deliberate operator policy. An INTERNAL
1121
+ // fail-closed (engine wiring/crash - gatedRun marks it internal:true) holds gateway strings:
1122
+ // neutralise those too.
1123
+ if (result && result.ok !== true && typeof result.reason === 'string' && result.reason &&
1124
+ result.internal !== true) {
1125
+ return wrapProtocolResult(result);
1126
+ }
1127
+ if (result && result.ok === true) return wrapProtocolResult(result); // malformed envelope - legacy shaping
1128
+ return { content: [{ type: 'text', text: `tool "${called}" failed` }], isError: true };
1129
+ }
1130
+
1131
+ /**
1132
+ * Wrap a protocol dispatch result ({ ok, output, error?, reason? }) in the MCP tools/call
1133
+ * envelope. Extracted so the meta-tool path and the unknown-tool path share one shaping.
1134
+ */
1135
+ function wrapProtocolResult(result) {
1136
+ const isError = !(result && result.ok === true);
1137
+ // On error surface the most informative message (a PreToolUse block carries `reason`, a thrown
1138
+ // tool carries `error`); on success serialise the output payload.
1139
+ let payload;
1140
+ if (isError) {
1141
+ payload = firstString(
1142
+ // gatedRun returns `error` as an Error INSTANCE on a thrown tool - extract its message
1143
+ // (firstString only takes strings, so a bare Error would be dropped -> generic text).
1144
+ // Mirrors runCuratedDirect's error shaping so the two paths report identically.
1145
+ result && result.error && (result.error.message || String(result.error)),
1146
+ result && result.reason,
1147
+ 'tool call failed'
1148
+ );
1149
+ } else if (result && result.output !== undefined) {
1150
+ payload = result.output;
1151
+ } else if (result && result.mode === 'reference') {
1152
+ // A reference HANDOFF carries no `output` - its `instructions` ARE the payload. Surface them
1153
+ // (with the handoff message) so a reference tool returns usable text whether reached via
1154
+ // toolfunnel_run_tool OR called directly after being promoted hot (the matrix). Falls back to
1155
+ // the whole result object if neither field is a string.
1156
+ payload = firstString(result.instructions, result.message) || result;
1157
+ } else {
1158
+ payload = result && result.output; // undefined -> stringifyContent guards it to a string
1159
+ }
1160
+
1161
+ return {
1162
+ content: [{ type: 'text', text: stringifyContent(payload) }],
1163
+ isError,
1164
+ };
1165
+ }
1166
+
1167
+ /** Return the first argument that is a non-empty string (used to pick the best error message). */
1168
+ function firstString(...candidates) {
1169
+ for (const c of candidates) {
1170
+ if (typeof c === 'string' && c.length > 0) return c;
1171
+ }
1172
+ return '';
1173
+ }
1174
+
1175
+ /** Serialise a protocol payload into a text block. Strings pass through; everything else -> JSON.
1176
+ * NEVER returns a non-string: JSON.stringify(undefined) / a function yields `undefined`, so guard it
1177
+ * (an MCP content block's `text` must be a string - an undefined text is a malformed envelope). */
1178
+ function stringifyContent(value) {
1179
+ if (typeof value === 'string') return value;
1180
+ try {
1181
+ const s = JSON.stringify(value);
1182
+ return typeof s === 'string' ? s : String(value);
1183
+ } catch (_e) {
1184
+ return String(value);
1185
+ }
1186
+ }
1187
+
1188
+ /**
1189
+ * Filter predicate replicating Registry.list's brief filtering (src/tools/registry.js:211-228) so
1190
+ * LEAN upstream briefs filter IDENTICALLY to local briefs: exact `category` match + case-insensitive
1191
+ * substring `filter` over `id name summary`. Kept in lockstep with registry.list (a parity test covers
1192
+ * it); if registry.list's filter semantics ever change, change this too.
1193
+ */
1194
+ function briefMatches(b, opts) {
1195
+ const { filter, category } = opts || {};
1196
+ if (category && b.category !== category) return false;
1197
+ if (typeof filter === 'string' && filter.length) {
1198
+ const hay = `${b.id} ${b.name} ${b.summary || ''}`.toLowerCase();
1199
+ if (!hay.includes(filter.toLowerCase())) return false;
1200
+ }
1201
+ return true;
1202
+ }
1203
+
1204
+ /**
1205
+ * Render a lean upstream tool's instructions as a STRING (homogeneous with registry.instructions),
1206
+ * synthesised from its discovered def { name, description, inputSchema, upstream, tool }: the exact
1207
+ * toolfunnel_run_tool invocation, provenance, description, the input schema + required fields, and a
1208
+ * note that the call passes the same PreToolUse gate as any local tool.
1209
+ */
1210
+ function renderUpstreamInstructions(def) {
1211
+ const lines = [];
1212
+ lines.push(`${def.name} - forwarded from upstream MCP "${def.upstream}" (upstream tool: ${def.tool}).`);
1213
+ if (def.description) lines.push('', def.description);
1214
+ lines.push('', 'Run it through the gateway with:', ` toolfunnel_run_tool { "name": "${def.name}", "args": { ... } }`);
1215
+ lines.push('', 'Every call passes the same PreToolUse gate as a local tool (a hook may deny it).');
1216
+ let schemaStr;
1217
+ try { schemaStr = JSON.stringify(def.inputSchema || { type: 'object' }, null, 2); } catch (_e) { schemaStr = '{ "type": "object" }'; }
1218
+ lines.push('', 'Input schema:', schemaStr);
1219
+ const req = def.inputSchema && Array.isArray(def.inputSchema.required) ? def.inputSchema.required : [];
1220
+ if (req.length) lines.push('', 'Required: ' + req.join(', '));
1221
+ return lines.join('\n');
1222
+ }
1223
+
1224
+ /**
1225
+ * Normalise the first argument of handleMessage / createStdioLoop into the canonical Phase-2
1226
+ * build shape { protocol, aggregator, engine, ctx }. Backward compat: a caller may still pass a
1227
+ * bare protocol (Phase-1 callers / older tests) - we wrap it with a null aggregator + no deps so
1228
+ * the meta-tool path is identical and the curated-direct path is simply never taken (isExposed
1229
+ * is unreachable without an aggregator). A protocol is detected by its dispatch() method.
1230
+ * @param {object} arg either { protocol, aggregator, engine, ctx } or a bare protocol
1231
+ * @returns {{ protocol:object, aggregator:(object|null), engine:(object|null), ctx:(object|null) }}
1232
+ */
1233
+ function normaliseBuild(arg) {
1234
+ const a = arg || {};
1235
+ if (a && typeof a.dispatch === 'function') {
1236
+ // It's a bare protocol - Phase-1 shape (no register/state -> no hot promotion; meta + curated only).
1237
+ return { protocol: a, aggregator: null, engine: null, ctx: null, registry: null, toolStatePath: null };
1238
+ }
1239
+ return {
1240
+ protocol: a.protocol,
1241
+ aggregator: a.aggregator || null,
1242
+ engine: a.engine || null,
1243
+ ctx: a.ctx || null,
1244
+ // The MATRIX inputs: the raw register (for a hot LOCAL tool's full MCP def) + the state overlay
1245
+ // path (read fresh per call). Absent -> the surface is meta + curated-direct, the prior behaviour.
1246
+ registry: a.registry || null,
1247
+ toolStatePath: a.toolStatePath || null,
1248
+ };
1249
+ }
1250
+
1251
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1252
+ // Dispatch ONE parsed JSON-RPC message. Returns a response object to send, or null for
1253
+ // notifications / messages that take no reply. NEVER throws.
1254
+ //
1255
+ // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
1256
+ // still accepted for backward compat - see normaliseBuild). The aggregator + engine + ctx are
1257
+ // threaded into tools/list and tools/call so the curated-direct surface is advertised and runs
1258
+ // THROUGH the PreToolUse gate.
1259
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1260
+ async function handleMessage(build, msg, connKey) {
1261
+ // connKey: OPTIONAL stable per-connection identity from the transport. stdio (one client per
1262
+ // process) passes a constant; HTTP passes nothing - each POST is a fresh connection, so no
1263
+ // cross-POST identity exists in the modern (sessionless) era. Cancel translation under a wrap
1264
+ // is only safe WITH an identity: JSON-RPC ids are per-client, and two clients both at id=3
1265
+ // must never cancel each other's calls.
1266
+ const { protocol, aggregator, engine, ctx, registry, toolStatePath } = normaliseBuild(build);
1267
+
1268
+ // Basic envelope validation - a non-object or wrong jsonrpc is an Invalid Request.
1269
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
1270
+ return makeError(null, ERR.INVALID_REQUEST, 'Invalid Request: expected a JSON-RPC object');
1271
+ }
1272
+
1273
+ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
1274
+ const id = hasId ? msg.id : null;
1275
+ const method = msg.method;
1276
+
1277
+ // MODERN-ONLY POLICY (serveLegacy:false in toolfunnel.json - explicit hardening opt-in):
1278
+ // refuse every LEGACY-shaped request with a clear error naming the policy. Exemptions:
1279
+ // `server/discover` stays answerable meta-less (it is the negotiation endpoint - both eras
1280
+ // may knock to learn what this gateway speaks), and modern-SHAPED-but-invalid requests fall
1281
+ // through so the standard validator returns its precise -32602/-32022 instead of a blanket
1282
+ // refusal. Legacy NOTIFICATIONS are dropped silently - a notification never gets a response.
1283
+ if (SERVER_CONFIG.serveLegacy === false && !modern.isModernRequest(msg) &&
1284
+ method !== 'server/discover') {
1285
+ if (!hasId) return null;
1286
+ const text = method === 'initialize'
1287
+ ? 'modern-only gateway (serveLegacy:false): the legacy initialize handshake is disabled - connect with the 2026-07-28 protocol (per-request _meta)'
1288
+ : 'modern-only gateway (serveLegacy:false): legacy-era requests are refused - send the 2026-07-28 per-request _meta';
1289
+ return makeError(id, -32020, text);
1290
+ }
1291
+
1292
+ // A message with no method but with an id is a response/garbage to us - acknowledge nothing.
1293
+ if (typeof method !== 'string') {
1294
+ if (hasId) return makeError(id, ERR.INVALID_REQUEST, 'Invalid Request: missing "method"');
1295
+ return null; // notification-shaped junk: ignore
1296
+ }
1297
+
1298
+ try {
1299
+ // ── WRAP identity (computed once) ─────────────────────────────────────────────────────────
1300
+ // Under a passthrough wrap, the handshake + non-tool methods present the WRAPPED upstream, so
1301
+ // a client cannot tell it is wrapped (the design requirement: "no one should know it's wrapped,
1302
+ // save the ToolFunnel dependency"). ptId = the wrapped upstream id (null when not wrapped);
1303
+ // wrapId = its captured identity (null when not wrapped OR not yet connected).
1304
+ let ptId = null;
1305
+ if (toolStatePath) {
1306
+ try {
1307
+ const stateRes = loadToolStateResult(toolStatePath);
1308
+ if (stateRes.parseError) warnCorruptToolState(toolStatePath); // corrupt file must WARN on every path, not just tools/list
1309
+ ptId = getPassthrough(stateRes.state);
1310
+ } catch (_e) { ptId = null; }
1311
+ }
1312
+ // A wrap retarget (A->B, A->null, null->A) invalidates the captured identity mirror - it belongs
1313
+ // to the OLD wrap context; presenting it to a different upstream mirrors the wrong client
1314
+ //Same-value passes clear nothing.
1315
+ if (ptId !== wrapMirrorPtId) { wrapMirrorClientInfo = null; wrapMirrorPtId = ptId; }
1316
+ if (aggregator) {
1317
+ // Keep the chatter bridge live with the wrap state. Through the setter: a target CHANGE
1318
+ // clears the recorded subscriptions (old wrap context); same-value
1319
+ // re-arms (every message) clear nothing.
1320
+ if (typeof aggregator.setWrapChatterUpstream === 'function') aggregator.setWrapChatterUpstream(ptId);
1321
+ else aggregator.wrapChatterUpstream = ptId;
1322
+ }
1323
+ let wrapId = (ptId && aggregator && typeof aggregator.wrappedIdentity === 'function')
1324
+ ? aggregator.wrappedIdentity(ptId) : null;
1325
+ if (ptId) {
1326
+ // Identity continuity: cache the last-known wrapped identity so an upstream death or a
1327
+ // reconnect window never answers a handshake as ToolFunnel (identity leak + visible swap).
1328
+ // Never-connected + handshake -> refuse neutrally rather than leak the funnel.
1329
+ if (wrapId) lastWrappedIdentity.set(ptId, wrapId);
1330
+ else wrapId = lastWrappedIdentity.get(ptId) || null;
1331
+ // No identity at all (never connected this process): refuse neutrally - for the handshake
1332
+ // AND for any modern request, whose result decoration would otherwise carry ToolFunnel's
1333
+ // serverInfo (modern clients are stateless, tools/list without a prior
1334
+ // discover is normal - the leak window is real). Legacy non-handshake results carry no
1335
+ // identity fields, so they pass.
1336
+ if (!wrapId && (method === 'initialize' || method === 'server/discover' || modern.isModernRequest(msg))) {
1337
+ return hasId ? makeError(id, ERR.INTERNAL, 'request failed') : null;
1338
+ }
1339
+ }
1340
+
1341
+ // ── Dual-era routing ─────────────────────────────────────────────────────────────────────
1342
+ // server/discover is answered in EITHER era: it is the spec's era probe (a dual-era client
1343
+ // uses it on stdio to detect what the server speaks), and the RC leaves the meta-less-probe
1344
+ // case undefined - answering is the honest choice (an error would read as "legacy-only").
1345
+ // A modern-shaped discover still validates its _meta first. Under a wrap it presents the
1346
+ // wrapped server's identity (but keeps OUR supportedVersions - modern-compliance is the point).
1347
+ if (method === 'server/discover') {
1348
+ if (!hasId) return null;
1349
+ if (modern.isModernRequest(msg)) {
1350
+ const vErr = modern.validateModernRequest(msg);
1351
+ if (vErr) return makeError(id, vErr.code, vErr.message, vErr.data);
1352
+ }
1353
+ return makeResult(id, handleServerDiscover(wrapId));
1354
+ }
1355
+
1356
+ // ── WRAP passthrough for NON-tool methods ─────────────────────────────────────────────────
1357
+ // Under a wrap, any method ToolFunnel does not own itself (resources/*, prompts/*, logging/*,
1358
+ // completion/*, ...) is FORWARDED verbatim to the wrapped upstream, so a wrapped server behaves
1359
+ // exactly as it would connected directly (its advertised capabilities are honest). ToolFunnel
1360
+ // keeps ownership of: the handshake (initialize/discover - identity), the tool surface
1361
+ // (tools/list + tools/call - GATED, and re-presented under original names), the change stream
1362
+ // (subscriptions/listen), and ping. Tool calls NEVER forward here - they stay gated.
1363
+ if (ptId && !WRAP_OWNED_METHODS.has(method)) {
1364
+ return await forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey);
1365
+ }
1366
+
1367
+ // A request carrying modern per-request _meta -> the 2026-07-28 path. `initialize` ALWAYS
1368
+ // selects legacy semantics (the spec's dual-era server rule), even if it carries _meta.
1369
+ if (method !== 'initialize' && modern.isModernRequest(msg)) {
1370
+ return await handleModernMessage(
1371
+ { protocol, aggregator, engine, ctx, registry, toolStatePath },
1372
+ msg, hasId, id, method, wrapId, connKey
1373
+ );
1374
+ }
1375
+
1376
+ switch (method) {
1377
+ case 'initialize': {
1378
+ // CLIENT IDENTITY MIRRORING (wrap, STDIO only - HTTP is sessionless/multi-client, the
1379
+ // configured identity applies there). Capture the real downstream client's clientInfo;
1380
+ // if the live wrapped connection introduced itself differently, reconnect it INLINE -
1381
+ // the client is waiting on the handshake anyway. Honest cost bound: one full reconnect
1382
+ // inside the handshake - up to ~3 s for the era re-probe on an upstream that ignores
1383
+ // server/discover, plus the initialize wait on a wedged one (the old
1384
+ // "+1-2s once" claim understated it). Once per client session, and only when the
1385
+ // presented identity differs.
1386
+ // Best-effort: any failure answers from the cached identity; the mirror must never
1387
+ // break the handshake.
1388
+ if (ptId && connKey === 'stdio' && aggregator) {
1389
+ const ci = msg.params && msg.params.clientInfo;
1390
+ if (ci && typeof ci === 'object' && typeof ci.name === 'string' && ci.name.length) {
1391
+ // Carry the FULL clientInfo through (title etc. - the wrap's serverInfo direction
1392
+ // learned this the hard way: a name/version whitelist eats spec fields). Only
1393
+ // name/version are normalised; the comparison below uses just those two.
1394
+ wrapMirrorClientInfo = Object.assign({}, ci, {
1395
+ name: ci.name,
1396
+ version: typeof ci.version === 'string' ? ci.version : '0.0.0',
1397
+ });
1398
+ try {
1399
+ const live = await aggregator.ensureConnected(ptId, { allowConnect: false })
1400
+ .catch(() => null); // not connected right now -> reconnect() below presents the mirror
1401
+ const presented = live && live.clientInfo;
1402
+ if (!presented || presented.name !== wrapMirrorClientInfo.name ||
1403
+ presented.version !== wrapMirrorClientInfo.version) {
1404
+ // reconnect() settles any in-flight connect before discarding - never join a
1405
+ // connect this path just poisoned.
1406
+ await aggregator.reconnect(ptId);
1407
+ }
1408
+ // The (re)connect refreshed the upstream identity - recompute wrapId from it.
1409
+ const freshId = typeof aggregator.wrappedIdentity === 'function'
1410
+ ? aggregator.wrappedIdentity(ptId) : null;
1411
+ if (freshId) { wrapId = freshId; lastWrappedIdentity.set(ptId, freshId); }
1412
+ } catch (_e) { /* mirror is best-effort - wrapId (cached identity) still answers */ }
1413
+ }
1414
+ }
1415
+ return makeResult(id, handleInitialize(msg.params, wrapId));
1416
+ }
1417
+
1418
+ case 'initialized':
1419
+ case 'notifications/initialized':
1420
+ // Client -> server notification that init handshake is complete. No reply.
1421
+ return null;
1422
+
1423
+ case 'tools/list':
1424
+ return makeResult(id, handleToolsList(protocol, aggregator, { registry, toolStatePath }));
1425
+
1426
+ case 'tools/call': {
1427
+ // cancelKey: lets a wrapped tools/call register its upstream rpc id for cancel
1428
+ // translation, same (connection, client id) contract as forwardWrapped.
1429
+ const cancelKey = (connKey !== undefined && hasId) ? `${connKey}:${id}` : null;
1430
+ const result = await handleToolsCall(protocol, aggregator,
1431
+ { engine, ctx, registry, toolStatePath, cancelKey, modernCaller: false }, msg.params);
1432
+ // Observability (in-memory counters; never throws). Count every tools/call by name + whether
1433
+ // it errored (an isError envelope = a tool failure OR a PreToolUse denial). Single chokepoint
1434
+ // -> covers stdio AND the HTTP transport, which both route through handleMessage.
1435
+ metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
1436
+ return makeResult(id, result);
1437
+ }
1438
+
1439
+ case 'ping':
1440
+ // MCP keep-alive: empty result.
1441
+ return makeResult(id, {});
1442
+
1443
+ default:
1444
+ // Unknown notification (no id) -> silently ignore. Unknown request -> method-not-found.
1445
+ if (!hasId) {
1446
+ if (method.startsWith('notifications/')) return null;
1447
+ return null;
1448
+ }
1449
+ return makeError(id, ERR.METHOD_NOT_FOUND, `Method not found: ${method}`);
1450
+ }
1451
+ } catch (err) {
1452
+ // Any unexpected internal failure becomes a JSON-RPC error (request) or is swallowed
1453
+ // (notification) - the loop never crashes on one bad message.
1454
+ logErr('handler error for', method + ':', (err && err.stack) || String(err));
1455
+ if (hasId) {
1456
+ return makeError(id, ERR.INTERNAL, `Internal error handling "${method}"`, (err && err.message) || String(err));
1457
+ }
1458
+ return null;
1459
+ }
1460
+ }
1461
+
1462
+ /**
1463
+ * handleServerDiscover - the server/discover result (modern era; servers MUST implement it).
1464
+ * Capabilities mirror what initialize advertises so the two eras never drift.
1465
+ *
1466
+ * `wrapId` (optional): under a passthrough WRAP, present the wrapped upstream's serverInfo +
1467
+ * capabilities + instructions verbatim (invisible wrap) - but keep OUR supportedVersions (both
1468
+ * eras): the wrap's value is that a legacy MCP becomes modern-compliant, so a modern client
1469
+ * SHOULD see modern support. Identity from the upstream; protocol reach from ToolFunnel.
1470
+ */
1471
+ function handleServerDiscover(wrapId) {
1472
+ if (wrapId) {
1473
+ return modern.discoverResult(
1474
+ wrapId.serverInfo || SERVER_INFO,
1475
+ wrapId.capabilities || { tools: { listChanged: true } },
1476
+ typeof wrapId.instructions === 'string' ? wrapId.instructions : undefined
1477
+ );
1478
+ }
1479
+ return modern.discoverResult(
1480
+ SERVER_INFO,
1481
+ { tools: { listChanged: true } },
1482
+ 'ToolFunnel is a dual-era MCP gateway: it speaks the ' + modern.MODERN_PROTOCOL_VERSION +
1483
+ ' revision (per-request _meta, server/discover, subscriptions/listen) and the ' +
1484
+ PROTOCOL_VERSION + ' revision (initialize handshake) on the same endpoint. ' +
1485
+ 'Every tool call passes a server-side policy gate before it runs.'
1486
+ );
1487
+ }
1488
+
1489
+ /**
1490
+ * The methods ToolFunnel OWNS even under a wrap - everything else is forwarded to the wrapped
1491
+ * upstream (see forwardWrapped). The handshake (identity), the gated tool surface, the change
1492
+ * stream, and ping stay ours; resources/prompts/logging/completion/etc. pass straight through.
1493
+ */
1494
+ const WRAP_OWNED_METHODS = new Set([
1495
+ 'initialize', 'initialized', 'notifications/initialized',
1496
+ 'tools/list', 'tools/call', 'server/discover', 'subscriptions/listen', 'ping',
1497
+ ]);
1498
+
1499
+ /** Forwarded methods whose modern result is a CacheableResult (MUST carry ttlMs + cacheScope per
1500
+ * spec). tools/list is NOT here - it's ToolFunnel-owned and decorated with its own hints. */
1501
+ const CACHEABLE_FORWARD_METHODS = new Set([
1502
+ 'resources/list', 'resources/read', 'resources/templates/list', 'prompts/list',
1503
+ ]);
1504
+
1505
+ /** Last-known wrapped identity per upstream id - survives upstream deaths and reconnect windows
1506
+ * so the wrap NEVER answers a handshake as ToolFunnel (identity leak). Keyed by passthrough id;
1507
+ * entries are tiny and bounded by the number of upstreams ever wrapped in-process. */
1508
+ const lastWrappedIdentity = new Map();
1509
+
1510
+ /** CLIENT IDENTITY MIRRORING (wrap, stdio): the real downstream client's clientInfo, captured at
1511
+ * its initialize handshake. While set, the wrapped upstream is (re)connected presenting THIS
1512
+ * identity instead of ToolFunnel's - the outbound half of two-way wrap invisibility for
1513
+ * legacy/stdio downstream clients (modern clients send no initialize; HTTP is multi-client -
1514
+ * both keep the configured identity). */
1515
+ let wrapMirrorClientInfo = null;
1516
+ /** The passthrough id the mirror was captured under - a retarget clears the mirror (see the
1517
+ * transition check in handleMessage). */
1518
+ let wrapMirrorPtId = null;
1519
+
1520
+ /** The identity to PRESENT to `upstreamId` (Aggregator clientInfoProvider - read fresh at each
1521
+ * connect). Chain: wrap target with a captured mirror -> the mirror; configured clientName ->
1522
+ * the configured identity; else null (McpClient's built-in). NEVER throws. */
1523
+ function clientInfoFor(upstreamId) {
1524
+ try {
1525
+ const pt = getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state);
1526
+ if (pt && pt === upstreamId && wrapMirrorClientInfo) return wrapMirrorClientInfo;
1527
+ } catch (_e) { /* state unreadable -> fall through to the configured identity */ }
1528
+ if (SERVER_CONFIG.clientName) {
1529
+ return { name: SERVER_CONFIG.clientName, version: SERVER_CONFIG.clientVersion || '0.0.0' };
1530
+ }
1531
+ return null;
1532
+ }
1533
+
1534
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1535
+ // BRIDGE B - legacy elicitation ↔ modern MRTR (the stateful adapter).
1536
+ //
1537
+ // A legacy upstream may send a SERVER-INITIATED `elicitation/create` mid-tool-call and hold the
1538
+ // call open until it is answered. A modern client cannot receive backwards requests (stateless
1539
+ // era): instead its tools/call RETURNS `resultType:"input_required"` with the question(s) and an
1540
+ // opaque `requestState` token, and the client RETRIES the call with `inputResponses` + the token.
1541
+ // ToolFunnel holds the suspended legacy call in the middle. Design + verified MRTR schema:
1542
+ // toolfunnel_060_design.md §BRIDGE B (spec draft/basic/patterns/mrtr).
1543
+ //
1544
+ // Correlation (the id-translation discipline from day one): a legacy
1545
+ // elicit carries NO originating-call marker, so it can only be bound to an in-flight wrapped call
1546
+ // when EXACTLY ONE such call is in flight for that upstream (always true on stdio - the chain
1547
+ // serialises; HTTP concurrency can produce >1, in which case the elicit is DECLINED rather than
1548
+ // guessed - a wrong binding would hand one client's question to another client's call).
1549
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1550
+
1551
+ /** Suspended elicitations: requestState token -> the held call + how to answer the upstream.
1552
+ * Tokens are unguessable (128-bit random); state never leaves this process, so the token needs
1553
+ * no integrity protection - a tampered/unknown token is simply not in the map. */
1554
+ const pendingElicits = new Map(); // token -> { upstreamId, elicitId, client, executePromise, calledName, createdAt, timer }
1555
+ const ELICIT_TTL_MS = 5 * 60 * 1000; // an unanswered suspension is cancelled upstream + dropped
1556
+
1557
+ /** In-flight wrapped calls willing to receive an elicit, per upstream id. Each entry is a
1558
+ * deliver() that hands the elicit to that call's race. Set size ≠ 1 -> no safe binding. */
1559
+ const elicitWaiters = new Map(); // upstreamId -> Set<{ deliver: (elicit) => void }>
1560
+
1561
+ function registerElicitWaiter(upstreamId, waiter) {
1562
+ let set = elicitWaiters.get(upstreamId);
1563
+ if (!set) { set = new Set(); elicitWaiters.set(upstreamId, set); }
1564
+ set.add(waiter);
1565
+ return () => {
1566
+ set.delete(waiter);
1567
+ if (set.size === 0) elicitWaiters.delete(upstreamId);
1568
+ };
1569
+ }
1570
+
1571
+ /**
1572
+ * The aggregator's onUpstreamServerRequest handler. Bridges `elicitation/create` from the WRAPPED
1573
+ * upstream to the one in-flight wrapped call; everything else (sampling/createMessage, roots/list
1574
+ * - future work) is answered -32601, and an unbindable elicit is DECLINED so the upstream's held
1575
+ * call resolves instead of hanging. NEVER throws.
1576
+ */
1577
+ function handleUpstreamServerRequest(build, upstreamId, msg, client) {
1578
+ try {
1579
+ if (!msg || msg.id === undefined || msg.id === null) return;
1580
+ if (msg.method === 'ping') {
1581
+ // A server-initiated ping is a keepalive, not a capability - answer it (an error reply
1582
+ // could read as a FAILED ping and kill a session that used to survive).
1583
+ client.respondToServer(msg.id, {});
1584
+ return;
1585
+ }
1586
+ if (msg.method !== 'elicitation/create') {
1587
+ client.respondToServerError(msg.id, -32601, 'Method not found: ' + msg.method);
1588
+ return;
1589
+ }
1590
+ // Only the WRAPPED upstream's elicitations bridge (the funnel has no client to ask).
1591
+ let ptId = null;
1592
+ try {
1593
+ ptId = build && build.toolStatePath ? getPassthrough(loadToolStateResult(build.toolStatePath).state) : null;
1594
+ } catch (_e) { ptId = null; }
1595
+ // The binding invariant: "exactly one wrapped call in flight" must mean it in
1596
+ // GROUND TRUTH, not just one tools/call race waiter. A SUSPENDED call's upstream request is
1597
+ // still open (it may re-elicit), and a raw non-tool forward (resources/read, prompts/get -
1598
+ // both legal elicit triggers) holds an open upstream call with NO waiter. Any of those
1599
+ // in-flight -> the elicit's origin is ambiguous -> DECLINE rather than hand one caller's
1600
+ // question to another caller's call.
1601
+ let suspendedHere = false;
1602
+ for (const e of pendingElicits.values()) {
1603
+ if (e.upstreamId === upstreamId) { suspendedHere = true; break; }
1604
+ }
1605
+ const rawInFlight = (inflightRawForwards.get(upstreamId) || 0) > 0;
1606
+ const waiters = ptId === upstreamId ? elicitWaiters.get(upstreamId) : null;
1607
+ if (!waiters || waiters.size !== 1 || suspendedHere || rawInFlight) {
1608
+ // No call to bind to (or an ambiguous set) - decline, so the upstream's held call completes.
1609
+ client.respondToServer(msg.id, { action: 'decline' });
1610
+ logErr('bridge-b: declined elicitation from "' + upstreamId + '" (' +
1611
+ (ptId !== upstreamId ? 'not the wrapped upstream'
1612
+ : suspendedHere ? 'a suspension is already pending'
1613
+ : rawInFlight ? 'a non-tool forward is in flight (ambiguous origin)'
1614
+ : (waiters ? 'ambiguous concurrent calls' : 'no in-flight wrapped call')) + ')');
1615
+ return;
1616
+ }
1617
+ const waiter = waiters.values().next().value;
1618
+ waiter.deliver({ elicitId: msg.id, params: msg.params || {}, client });
1619
+ } catch (_e) {
1620
+ try { client.respondToServerError(msg.id, -32603, 'request failed'); } catch (_e2) { /* ignore */ }
1621
+ }
1622
+ }
1623
+
1624
+ /**
1625
+ * Race a held upstream promise against an incoming elicitation for that upstream. Registers a
1626
+ * single-use waiter (a SECOND elicit during the same round is auto-declined - one question per
1627
+ * round is what MRTR's retry loop models); always unregisters. Returns { kind:'result'|'elicit',
1628
+ * value }. Rejects if the upstream promise rejects first.
1629
+ */
1630
+ async function raceElicit(upstreamId, promise) {
1631
+ let resolveElicit;
1632
+ const elicitP = new Promise((res) => { resolveElicit = res; });
1633
+ let delivered = false;
1634
+ const unregister = registerElicitWaiter(upstreamId, {
1635
+ deliver: (e) => {
1636
+ if (delivered) {
1637
+ try { e.client.respondToServer(e.elicitId, { action: 'decline' }); } catch (_e) { /* ignore */ }
1638
+ return;
1639
+ }
1640
+ delivered = true;
1641
+ resolveElicit(e);
1642
+ },
1643
+ });
1644
+ try {
1645
+ return await Promise.race([
1646
+ promise.then((v) => ({ kind: 'result', value: v })),
1647
+ elicitP.then((e) => ({ kind: 'elicit', value: e })),
1648
+ ]);
1649
+ } finally {
1650
+ unregister();
1651
+ }
1652
+ }
1653
+
1654
+ /** Resume a suspended elicitation (the MRTR retry): answer the upstream's held question with the
1655
+ * client's inputResponse, then await the held call - which may elicit AGAIN (multi-round) or
1656
+ * finish. Token misses get a clean, neutral error. NEVER throws. */
1657
+ async function resumeElicit(params, modernCaller, currentPtId, cancelKey) {
1658
+ const p = params || {};
1659
+ const token = typeof p.requestState === 'string' ? p.requestState : null;
1660
+ const entry = token && modernCaller === true ? pendingElicits.get(token) : null;
1661
+ if (!entry) {
1662
+ // Unknown/expired/foreign token - or a legacy caller, which cannot do MRTR at all.
1663
+ return { content: [{ type: 'text', text: 'unknown or expired requestState' }], isError: true };
1664
+ }
1665
+ pendingElicits.delete(token);
1666
+ try { clearTimeout(entry.timer); } catch (_e) { /* ignore */ }
1667
+ // The wrap may have been RETARGETED mid-suspension (A->B). Serving A's result under B's
1668
+ // identity is a mixed-identity response - refuse, and cancel A's held question so its call
1669
+ // resolves.
1670
+ if (currentPtId !== entry.upstreamId) {
1671
+ try { entry.client.respondToServer(entry.elicitId, { action: 'cancel' }); } catch (_e) { /* ignore */ }
1672
+ return { content: [{ type: 'text', text: 'unknown or expired requestState' }], isError: true };
1673
+ }
1674
+ // The resume may block for the held call's remaining work: re-extend its timeout (a resume at
1675
+ // TTL-minus-seconds must not lose the result to the original clock) and make
1676
+ // the RETRY id cancellable against the held upstream call.
1677
+ if (entry.rpcId !== null && typeof entry.client.extendRequestTimeout === 'function') {
1678
+ entry.client.extendRequestTimeout(entry.rpcId, ELICIT_TTL_MS + 30000);
1679
+ }
1680
+ if (cancelKey && entry.rpcId !== null) inflightForwards.set(cancelKey, entry.rpcId);
1681
+ // One question per round (r0) -> one response. The client's InputResponse passes VERBATIM as
1682
+ // the upstream's ElicitResult ({ action, content? }); a malformed response declines - never
1683
+ // invent an "accept" the user did not give.
1684
+ const responses = p.inputResponses && typeof p.inputResponses === 'object' && !Array.isArray(p.inputResponses)
1685
+ ? p.inputResponses : {};
1686
+ const keys = Object.keys(responses);
1687
+ const r = keys.length ? responses[keys[0]] : null;
1688
+ const elicitResult = (r && typeof r === 'object' && typeof r.action === 'string') ? r : { action: 'decline' };
1689
+ try { entry.client.respondToServer(entry.elicitId, elicitResult); } catch (_e) { /* ignore */ }
1690
+ let winner;
1691
+ try {
1692
+ winner = await raceElicit(entry.upstreamId, entry.executePromise);
1693
+ } catch (_err) {
1694
+ return { content: [{ type: 'text', text: `tool "${entry.calledName}" failed` }], isError: true };
1695
+ } finally {
1696
+ if (cancelKey) inflightForwards.delete(cancelKey); // settled (or re-suspended) - retry cancel window over
1697
+ }
1698
+ if (winner.kind === 'elicit') {
1699
+ return suspendForElicit(winner.value, entry.executePromise, entry.upstreamId, entry.calledName, entry.rpcId);
1700
+ }
1701
+ const out = winner.value;
1702
+ if (out && typeof out === 'object' && Array.isArray(out.content)) return out; // verbatim envelope
1703
+ return { content: [{ type: 'text', text: `tool "${entry.calledName}" failed` }], isError: true };
1704
+ }
1705
+
1706
+ /** Suspend a wrapped call that hit an elicitation: mint the token, hold the still-running
1707
+ * execute promise, extend the held request's timeout to the suspension window (a human answers
1708
+ * at human speed), arm the TTL, and shape the modern InputRequiredResult. */
1709
+ function suspendForElicit(elicit, executePromise, upstreamId, calledName, rpcId) {
1710
+ const token = require('node:crypto').randomBytes(16).toString('base64url');
1711
+ executePromise.catch(() => { /* held in the background - its rejection surfaces on resume */ });
1712
+ if (rpcId !== null && rpcId !== undefined && typeof elicit.client.extendRequestTimeout === 'function') {
1713
+ // +grace beyond the TTL: the TTL path CANCELS the question upstream, and the upstream still
1714
+ // needs time to complete/fail its held call before the waiter finally dies.
1715
+ elicit.client.extendRequestTimeout(rpcId, ELICIT_TTL_MS + 30000);
1716
+ }
1717
+ const entry = {
1718
+ upstreamId,
1719
+ elicitId: elicit.elicitId,
1720
+ client: elicit.client,
1721
+ executePromise,
1722
+ calledName,
1723
+ rpcId: rpcId === undefined ? null : rpcId,
1724
+ createdAt: Date.now(),
1725
+ timer: setTimeout(() => {
1726
+ // Unclaimed suspension: cancel the upstream's question so its held call resolves, drop the
1727
+ // token. A later retry with this token gets the clean unknown-token error.
1728
+ pendingElicits.delete(token);
1729
+ try { elicit.client.respondToServer(elicit.elicitId, { action: 'cancel' }); } catch (_e) { /* ignore */ }
1730
+ logErr('bridge-b: elicitation "' + token.slice(0, 8) + '..." expired unanswered (' + calledName + ')');
1731
+ }, ELICIT_TTL_MS),
1732
+ };
1733
+ if (entry.timer && typeof entry.timer.unref === 'function') entry.timer.unref();
1734
+ pendingElicits.set(token, entry);
1735
+ // The MRTR result: the upstream's question under a minted key + the resume token. One
1736
+ // translation applies: MRTR's ElicitRequest params REQUIRE `mode:"form"`, which pre-MRTR
1737
+ // legacy upstreams never send - default-inject it when absent (the caller's own
1738
+ // mode, if any, wins). Everything else passes verbatim.
1739
+ const params = Object.assign({ mode: 'form' }, elicit.params);
1740
+ return {
1741
+ resultType: 'input_required',
1742
+ inputRequests: { r0: { method: 'elicitation/create', params } },
1743
+ requestState: token,
1744
+ };
1745
+ }
1746
+
1747
+ /**
1748
+ * armWrapChatter - sync the aggregator's wrapChatterUpstream from the on-disk wrap state.
1749
+ * handleMessage re-arms per message, but three paths bypass it: startup (notifications before the
1750
+ * first client message), a reloadExpose swap, and a listen-first modern client (the listen branch
1751
+ * is transport-owned). Each of those calls this so the N3 cross-upstream filter is never unarmed.
1752
+ * NEVER throws.
1753
+ */
1754
+ function armWrapChatter(build) {
1755
+ try {
1756
+ if (!build || !build.aggregator) return;
1757
+ const statePath = build.toolStatePath;
1758
+ // Route through the aggregator's setter - a passthrough TARGET CHANGE clears the recorded
1759
+ // resource subscriptions (they belong to the old wrap context).
1760
+ const arm = (v) => (typeof build.aggregator.setWrapChatterUpstream === 'function'
1761
+ ? build.aggregator.setWrapChatterUpstream(v)
1762
+ : (build.aggregator.wrapChatterUpstream = v));
1763
+ if (!statePath) { arm(null); return; }
1764
+ arm(getPassthrough(loadToolStateResult(statePath).state));
1765
+ // F2 companion: hot-promoted upstream tools put their upstream on the top-level surface with
1766
+ // no expose[] entry - give the aggregator a live predicate so death/recovery of such an
1767
+ // upstream still fires list_changed. Reads the state file fresh (a UI toggle is live), keyed
1768
+ // by the standard `<upstream>_<tool>` surfaced shape; the reserved wrap key is a string, not
1769
+ // a tool entry, so it never matches.
1770
+ build.aggregator.topLevelUpstreamExtra = (upstreamId) => {
1771
+ try {
1772
+ const st = loadToolStateResult(statePath).state || {};
1773
+ const prefix = String(upstreamId) + '_';
1774
+ return Object.keys(st).some((k) =>
1775
+ k !== 'passthrough' && k.startsWith(prefix) && st[k] && st[k].hot === true);
1776
+ } catch (_e) { return false; }
1777
+ };
1778
+ } catch (_e) { /* never let arming break the caller */ }
1779
+ }
1780
+
1781
+ /** In-flight forwarded requests under a wrap: CLIENT JSON-RPC id -> the McpClient rpc id the
1782
+ * forward was re-issued under. The two id spaces are disjoint, so a client's
1783
+ * notifications/cancelled must be TRANSLATED before relay - relaying its requestId verbatim
1784
+ * would cancel the wrong upstream call. Entries live only for the duration
1785
+ * of the forward (deleted in the finally). Gated tools/call is NOT tracked here - full
1786
+ * id-translation for the tool path arrives with Bridge B's correlation machinery. */
1787
+ const inflightForwards = new Map();
1788
+
1789
+ /** In-flight RAW (non-tool) forwards per upstream id - part of Bridge B's binding invariant: a
1790
+ * resources/read or prompts/get holds an open upstream call that may legally elicit, so while
1791
+ * one is in flight an elicit's origin is ambiguous and must be declined. */
1792
+ const inflightRawForwards = new Map();
1793
+
1794
+ /**
1795
+ * forwardWrapped - relay a non-tool method to the wrapped upstream and shape the reply for the
1796
+ * caller's era. Strips the modern per-request `_meta` before forwarding (the legacy upstream never
1797
+ * saw modern params); a modern caller's result is decorated (resultType + the WRAPPED server's
1798
+ * serverInfo in `_meta`, never ToolFunnel's - the wrap must stay invisible). A notification (no id)
1799
+ * is dropped (we do not relay unknown client notifications). NEVER throws.
1800
+ */
1801
+ async function forwardWrapped(aggregator, ptId, msg, hasId, id, wrapId, connKey) {
1802
+ if (!hasId) {
1803
+ // A client NOTIFICATION under a wrap (notifications/cancelled, notifications/progress ...)
1804
+ // must still reach the upstream - a directly-connected server would receive it, and eating
1805
+ // notifications/cancelled leaves the upstream running a call the client abandoned.
1806
+ // Fire-and-forget; notifications are never answered.
1807
+ if (typeof msg.method === 'string' && msg.method.startsWith('notifications/') &&
1808
+ aggregator && typeof aggregator.notifyRaw === 'function') {
1809
+ if (msg.method === 'notifications/cancelled') {
1810
+ // params.requestId lives in the CLIENT's id space; the forward ran under the McpClient's
1811
+ // own id. TRANSLATE via the in-flight map - an untranslatable cancel is DROPPED, because
1812
+ // relaying it verbatim could cancel a DIFFERENT in-flight call. The
1813
+ // map is scoped by connKey: without a connection identity (HTTP - sessionless modern era,
1814
+ // every POST is a stranger) translation is impossible and the cancel is dropped whole.
1815
+ if (connKey !== undefined && msg.params && msg.params.requestId !== undefined) {
1816
+ const rpcId = inflightForwards.get(`${connKey}:${msg.params.requestId}`);
1817
+ if (rpcId !== undefined) {
1818
+ aggregator.notifyRaw(ptId, msg.method, Object.assign({}, msg.params, { requestId: rpcId }));
1819
+ }
1820
+ }
1821
+ return null;
1822
+ }
1823
+ aggregator.notifyRaw(ptId, msg.method, msg.params);
1824
+ }
1825
+ return null;
1826
+ }
1827
+ if (!aggregator || typeof aggregator.forwardRaw !== 'function') {
1828
+ // Neutral text - naming the wrap or the upstream id here would announce the funnel.
1829
+ return makeError(id, ERR.INTERNAL, 'request failed');
1830
+ }
1831
+ let params = msg.params;
1832
+ if (params && typeof params === 'object' && !Array.isArray(params) &&
1833
+ params._meta && typeof params._meta === 'object' && !Array.isArray(params._meta)) {
1834
+ // Strip ONLY the modern protocol keys from _meta. The rest - progressToken, trace keys, app
1835
+ // keys - is legitimate in BOTH eras and a direct connection would deliver it verbatim.
1836
+ // (McpClient.request() re-injects the trio, merged, when the upstream itself is modern.)
1837
+ const meta = Object.assign({}, params._meta);
1838
+ let changed = false;
1839
+ for (const k of Object.keys(meta)) {
1840
+ if (k.startsWith('io.modelcontextprotocol/')) { delete meta[k]; changed = true; }
1841
+ }
1842
+ if (changed) {
1843
+ params = Object.assign({}, params);
1844
+ if (Object.keys(meta).length) params._meta = meta; else delete params._meta;
1845
+ }
1846
+ }
1847
+ let raw;
1848
+ // Track the forward's upstream rpc id LIVE (the setter fires synchronously at issue time inside
1849
+ // McpClient._request) so a concurrent notifications/cancelled can be translated (N1). Keyed by
1850
+ // (connection, client id) - raw client ids collide across connections (focused review).
1851
+ const mapKey = connKey !== undefined ? `${connKey}:${id}` : null;
1852
+ const track = mapKey === null ? undefined : { set rpcId(v) { inflightForwards.set(mapKey, v); } };
1853
+ inflightRawForwards.set(ptId, (inflightRawForwards.get(ptId) || 0) + 1); // Bridge B binding invariant
1854
+ try {
1855
+ raw = await aggregator.forwardRaw(ptId, msg.method, params, track);
1856
+ } catch (err) {
1857
+ // Relay the upstream's OWN error verbatim - code, message, data. Re-wrapping loses fidelity
1858
+ // AND announces the wrap (the old text carried `wrapped upstream "<id>"`; one probe of any
1859
+ // unimplemented method exposed the funnel). Only a pure transport failure (no rpcError on
1860
+ // the rejection) gets a synthetic error - neutral: no wrap, no id, no McpClient prefix.
1861
+ if (err && err.rpcError && typeof err.rpcError === 'object') {
1862
+ return makeError(id, err.rpcError.code, err.rpcError.message, err.rpcError.data);
1863
+ }
1864
+ return makeError(id, ERR.INTERNAL, 'request failed');
1865
+ } finally {
1866
+ if (mapKey !== null) inflightForwards.delete(mapKey); // settled - cancels can no longer target it
1867
+ const n = (inflightRawForwards.get(ptId) || 1) - 1;
1868
+ if (n <= 0) inflightRawForwards.delete(ptId); else inflightRawForwards.set(ptId, n);
1869
+ }
1870
+ // A legacy client's DIRECT resources/subscribe rides this forward - record it for replay. The
1871
+ // wrap hides upstream deaths behind silent background reconnects, so it inherits the duty of
1872
+ // re-arming the subscription on the fresh process: a direct
1873
+ // connection's client would have SEEN the death and re-subscribed itself.
1874
+ if (aggregator && aggregator._subscribedUris instanceof Set &&
1875
+ params && typeof params.uri === 'string') {
1876
+ if (msg.method === 'resources/subscribe') aggregator._subscribedUris.add(params.uri);
1877
+ else if (msg.method === 'resources/unsubscribe') aggregator._subscribedUris.delete(params.uri);
1878
+ }
1879
+ // A modern caller needs a modern-shaped result (resultType + serverInfo). Present the WRAPPED
1880
+ // server's serverInfo so the decoration is not a ToolFunnel tell. The CacheableResult surfaces
1881
+ // (resources/list|read, resources/templates/list, prompts/list) MUST carry ttlMs + cacheScope
1882
+ // per spec - the legacy upstream can't supply them, so we add a conservative default
1883
+ if (modern.isModernRequest(msg)) {
1884
+ const si = (wrapId && wrapId.serverInfo) || SERVER_INFO;
1885
+ const cacheHints = CACHEABLE_FORWARD_METHODS.has(msg.method) ? modern.CACHE_HINTS.toolsList : undefined;
1886
+ return makeResult(id, modern.decorateResult(raw, si, cacheHints));
1887
+ }
1888
+ return makeResult(id, raw); // legacy caller - verbatim upstream result
1889
+ }
1890
+
1891
+ /**
1892
+ * handleModernMessage - dispatch ONE request under 2026-07-28 semantics. The tool surface and
1893
+ * the gate are the SAME handlers the legacy path uses (handleToolsList / handleToolsCall) - the
1894
+ * modern era only changes the envelope: validated per-request _meta in, `resultType` +
1895
+ * `_meta[serverInfo]` (+ ttlMs/cacheScope on the CacheableResult surfaces) out. Methods the
1896
+ * modern revision removed (ping, logging/setLevel, the initialized notification) fall through
1897
+ * to method-not-found / ignored, per spec. NEVER throws (the caller's try/catch backstops it).
1898
+ */
1899
+ async function handleModernMessage(build, msg, hasId, id, method, wrapId, connKey) {
1900
+ const { protocol, aggregator, engine, ctx, registry, toolStatePath } = build;
1901
+
1902
+ const vErr = modern.validateModernRequest(msg);
1903
+ if (vErr) return hasId ? makeError(id, vErr.code, vErr.message, vErr.data) : null;
1904
+
1905
+ // Under a wrap, the serverInfo in every decorated result must be the WRAPPED server's, not
1906
+ // ToolFunnel's (the wrap is invisible). Also latch whether a wrap is active, to suppress the
1907
+ // legacyShim _meta tell (it would reveal the wrapping - the invisibility requirement).
1908
+ const wrapped = !!wrapId;
1909
+ const resultServerInfo = (wrapId && wrapId.serverInfo) || SERVER_INFO;
1910
+
1911
+ switch (method) {
1912
+ case 'tools/list':
1913
+ // A notification (no id) MUST NOT get a reply (JSON-RPC 2.0). Every case guards on hasId.
1914
+ if (!hasId) return null;
1915
+ return makeResult(
1916
+ id,
1917
+ modern.decorateResult(
1918
+ handleToolsList(protocol, aggregator, { registry, toolStatePath }),
1919
+ resultServerInfo,
1920
+ modern.CACHE_HINTS.toolsList
1921
+ )
1922
+ );
1923
+
1924
+ case 'tools/call': {
1925
+ const result = await handleToolsCall(
1926
+ protocol, aggregator,
1927
+ // cancelKey: same cancel-translation contract as the legacy path.
1928
+ // modernCaller: a modern client can receive input_required + retry (Bridge B / MRTR).
1929
+ { engine, ctx, registry, toolStatePath, modernCaller: true,
1930
+ cancelKey: (connKey !== undefined && hasId) ? `${connKey}:${id}` : null },
1931
+ msg.params
1932
+ );
1933
+ // Same observability chokepoint as the legacy path - both eras count in one place.
1934
+ metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
1935
+ if (!hasId) return null; // notification-shaped call: executed (side effects), but never replied to
1936
+ const decorated = modern.decorateResult(result, resultServerInfo);
1937
+ // Legacy-shim transparency for MODERN clients: when the call ACTUALLY forwarded to a PINNED
1938
+ // legacy upstream, say so in result _meta - metadata, never content. NOT under a wrap: a wrap
1939
+ // must be invisible, and this tag would reveal the wrapping (the invisibility requirement). Gated on
1940
+ // !isError so a PreToolUse deny / unknown-tool error is not falsely tagged as "forwarded".
1941
+ if (!wrapped && aggregator && typeof aggregator.legacyShimInfo === 'function' && result && result.isError !== true) {
1942
+ try {
1943
+ const shim = aggregator.legacyShimInfo(msg.params && msg.params.name);
1944
+ if (shim) decorated._meta['io.toolfunnel/legacyShim'] = shim;
1945
+ } catch (_e) { /* decoration only - never affects the result */ }
1946
+ }
1947
+ return makeResult(id, decorated);
1948
+ }
1949
+
1950
+ case 'subscriptions/listen':
1951
+ // Transport-owned: the ack + the notification stream need the transport's writer, and the
1952
+ // final result is only sent at close. Both transports intercept BEFORE handleMessage;
1953
+ // reaching here means a wiring gap - answer honestly rather than hang the client.
1954
+ return hasId
1955
+ ? makeError(id, ERR.INTERNAL, 'subscriptions/listen must be handled by the transport')
1956
+ : null;
1957
+
1958
+ default:
1959
+ // ping / logging/setLevel / resources / prompts are removed-or-absent in the modern era of
1960
+ // this tools-only server: unknown request -> method-not-found; unknown notification -> ignored.
1961
+ return hasId ? makeError(id, ERR.METHOD_NOT_FOUND, `Method not found: ${method}`) : null;
1962
+ }
1963
+ }
1964
+
1965
+ /**
1966
+ * emitToolsListChanged(send) - write the MCP `notifications/tools/list_changed` JSON-RPC
1967
+ * notification (NO id - it is a notification, not a request) via the provided `send` fn. The
1968
+ * server emits this when the curated-direct expose set hot-updates so a CLI that honours
1969
+ * listChanged re-fetches tools/list (see the architecture notes §1 "no restart for register
1970
+ * changes"; §7 the host runs the server). `send` is injected so this is unit-testable with a spy
1971
+ * and reused by both the stdio loop and (later) the host.
1972
+ *
1973
+ * @param {(obj: object) => void} send the transport's writer (stdio loop's send, or a spy)
1974
+ * @returns {void}
1975
+ */
1976
+ function emitToolsListChanged(send) {
1977
+ if (typeof send !== 'function') return; // never throw - a missing transport is a no-op
1978
+ send({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
1979
+ }
1980
+
1981
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1982
+ // HOT-RELOAD - pick up on-disk config changes with NO restart, NO user intervention.
1983
+ //
1984
+ // The tf_* management tools (and the UI) write expose.json / the hooks files from a SEPARATE
1985
+ // process, so the running gateway's in-memory aggregator + hook engine never saw the change - that
1986
+ // was the root cause of "you have to restart to attach an MCP / change a hook". These reloaders
1987
+ // re-read the on-disk config and SWAP build.aggregator / build.engine IN PLACE. handleMessage reads
1988
+ // the build object fresh on every message, so the next tools/list or tools/call uses the new state.
1989
+ // startConfigWatchers wires them to fs.watch so the gateway heals/updates itself automatically.
1990
+ // Everything here is contracted NEVER to throw - a bad reload keeps the last-good state.
1991
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1992
+
1993
+ /**
1994
+ * Re-read expose.json, rebuild + reconnect the aggregator, swap it into `build`, then emit
1995
+ * notifications/tools/list_changed so a listChanged-honouring client (Claude Code does) refreshes
1996
+ * its curated-direct surface. The new aggregator is connected BEFORE the old one is closed so there
1997
+ * is never a window with no upstreams. NEVER throws.
1998
+ *
1999
+ * @param {{aggregator?:object}} build the live build object (mutated in place)
2000
+ * @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
2001
+ * @returns {Promise<void>}
2002
+ */
2003
+ async function reloadExpose(build, send) {
2004
+ // NEVER-throws contract. A null/absent build means the caller (e.g. the HTTP host mid-teardown,
2005
+ // which nulls its build) has moved on - do nothing rather than deref null. (reloadHooks/
2006
+ // reloadRegister already guard this; expose was the one unguarded reloader - a proven crash.)
2007
+ if (!build || typeof build !== 'object') return;
2008
+ let store;
2009
+ try {
2010
+ store = loadExposeStore(EXPOSE_PATH);
2011
+ } catch (err) {
2012
+ logErr('reloadExpose: cannot read expose.json (keeping current upstreams):', (err && err.message) || String(err));
2013
+ return;
2014
+ }
2015
+ const next = new Aggregator({
2016
+ store, v3Root: ROOT,
2017
+ wrapTargetProvider: () => {
2018
+ try { return getPassthrough(loadToolStateResult(TOOL_STATE_PATH).state); } catch (_e) { return null; }
2019
+ },
2020
+ // Outbound identity per upstream: captured wrap mirror > configured clientName > built-in.
2021
+ clientInfoProvider: clientInfoFor,
2022
+ });
2023
+ next.onToolsChanged = () => emitToolsListChanged(send); // re-wire the background-reconnect -> list_changed signal
2024
+ // Identity-continuity invalidation: the lastWrappedIdentity cache exists to bridge
2025
+ // RECONNECT windows of the SAME server - but if this reload REPLACED an upstream entry (same id,
2026
+ // different command/args/env) or removed it, serving the previous server's identity during the
2027
+ // new one's connect window presents the WRONG server. Drop cache entries whose upstream config
2028
+ // changed; a pure reconnect (config untouched) keeps its bridge.
2029
+ try {
2030
+ const prevStore = build.aggregator && build.aggregator._store;
2031
+ for (const cachedId of Array.from(lastWrappedIdentity.keys())) {
2032
+ const before = prevStore && typeof prevStore.getUpstream === 'function' ? prevStore.getUpstream(cachedId) : null;
2033
+ const after = store && typeof store.getUpstream === 'function' ? store.getUpstream(cachedId) : null;
2034
+ const shape = (u) => u ? JSON.stringify({ c: u.command, a: u.args, e: u.env }) : null;
2035
+ if (!after || shape(before) !== shape(after)) lastWrappedIdentity.delete(cachedId);
2036
+ }
2037
+ } catch (_e) { /* invalidation is best-effort; a stale entry self-heals on connect */ }
2038
+ // Carry the notification bridge to the new instance - the hook closes over the TRANSPORT
2039
+ // (loop.notify / pushToListeners), not the aggregator, so the function transfers verbatim.
2040
+ // Without this every expose.json hot-reload (including a live tf_mcp_add) silently killed
2041
+ // Bridge A.
2042
+ const prevAgg = build.aggregator;
2043
+ if (prevAgg && typeof prevAgg.onUpstreamNotification === 'function') {
2044
+ next.onUpstreamNotification = prevAgg.onUpstreamNotification;
2045
+ }
2046
+ // Bridge B carryover - same contract as the notification bridge above: the handler closes over
2047
+ // the stable build object, so it transfers verbatim onto the swapped-in aggregator.
2048
+ if (prevAgg && typeof prevAgg.onUpstreamServerRequest === 'function') {
2049
+ next.onUpstreamServerRequest = prevAgg.onUpstreamServerRequest;
2050
+ }
2051
+ // Arm the wrap scope on the NEW aggregator BEFORE connectAll - _connectOne's subscription
2052
+ // replay checks wrapChatterUpstream, and an unarmed (null) scope replayed the URIs onto EVERY
2053
+ // subscribe-capable upstream during the reload window. Must precede the
2054
+ // carryover below: arming through the setter clears the set on a target change.
2055
+ armWrapChatter({ aggregator: next, toolStatePath: build.toolStatePath });
2056
+ // Carry the agreed resource-subscription set BEFORE connectAll, so the new aggregator's
2057
+ // _connectOne replays them onto the fresh upstream processes (a hot reload used
2058
+ // to silently kill every agreed subscription channel).
2059
+ if (prevAgg && prevAgg._subscribedUris instanceof Set && prevAgg._subscribedUris.size) {
2060
+ next._subscribedUris = new Set(prevAgg._subscribedUris);
2061
+ }
2062
+ let res = null;
2063
+ try {
2064
+ res = await next.connectAll();
2065
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
2066
+ logErr('reloadExpose: ' + res.failed.length + ' upstream(s) failed:',
2067
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
2068
+ }
2069
+ } catch (err) {
2070
+ logErr('reloadExpose: connectAll threw (ignored):', (err && err.message) || String(err));
2071
+ }
2072
+ // The swap/close/notify is guarded too: even a surprise (a build torn down DURING the async
2073
+ // connectAll above) must be a caught no-op, not an escaped rejection that crashes the host.
2074
+ try {
2075
+ const prev = build.aggregator;
2076
+ build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
2077
+ armWrapChatter(build); // the fresh aggregator's N3 filter must not wait for a client message
2078
+ if (prev && typeof prev.closeAll === 'function') {
2079
+ try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
2080
+ }
2081
+ emitToolsListChanged(send);
2082
+ } catch (err) {
2083
+ logErr('reloadExpose: swap failed (keeping last-good):', (err && err.message) || String(err));
2084
+ try { await next.closeAll(); } catch (_e) { /* orphan-avoidance; never throw */ }
2085
+ return;
2086
+ }
2087
+ // Activity log (self-gating): mark the reload trigger; the per-upstream connect/disconnect lines
2088
+ // (from the aggregator) carry the detail. Together they record the reconnect.
2089
+ logger.log({
2090
+ type: 'mcp',
2091
+ event: 'reload',
2092
+ connected: res && Array.isArray(res.connected) ? res.connected : [],
2093
+ failed: res && Array.isArray(res.failed) ? res.failed.map((f) => f.id) : [],
2094
+ });
2095
+ logErr('reloadExpose: expose.json reloaded - upstreams reconnected');
2096
+ }
2097
+
2098
+ /**
2099
+ * Re-read tools.register.json and swap the ONE live Registry's index IN PLACE (Registry.reload -
2100
+ * last-good on any failure). In-place mutation is the point: the Registry instance is CAPTURED by
2101
+ * the protocol adapter at build time, so swapping internals means every reader - the lean
2102
+ * meta-tools, run_tool resolution, and the top-level hot surface - sees the new entries on its
2103
+ * next call with no rewiring. Emits list_changed: a hot-promoted local tool's definition may have
2104
+ * appeared, changed, or gone. NEVER throws.
2105
+ *
2106
+ * @param {{registry?:object}} build
2107
+ * @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
2108
+ * @returns {void}
2109
+ */
2110
+ function reloadRegister(build, send) {
2111
+ try {
2112
+ if (build && build.registry && typeof build.registry.reload === 'function' && build.registry.reload()) {
2113
+ emitToolsListChanged(send);
2114
+ logErr('reloadRegister: tools.register.json reloaded');
2115
+ } else {
2116
+ logErr('reloadRegister: reload failed (bad JSON / invalid entry / mid-write?) - keeping the last-good register');
2117
+ }
2118
+ } catch (err) {
2119
+ logErr('reloadRegister: unexpected failure (keeping current register):', (err && err.message) || String(err));
2120
+ }
2121
+ }
2122
+
2123
+ /**
2124
+ * Re-read the hooks manifest + state overlay and swap a fresh HookEngine into `build`. The next
2125
+ * gated call (toolfunnel_run_tool / curated-direct) uses the new engine, so an added/changed/toggled
2126
+ * hook takes effect with no restart. NEVER throws (a bad manifest keeps the current engine).
2127
+ *
2128
+ * @param {{engine?:object}} build
2129
+ * @returns {void}
2130
+ */
2131
+ function reloadHooks(build) {
2132
+ try {
2133
+ const loader = loadManifest(MANIFEST_PATH);
2134
+ build.engine = new HookEngine(loader, { cwd: ROOT });
2135
+ logErr('reloadHooks: hooks manifest/state reloaded');
2136
+ } catch (err) {
2137
+ logErr('reloadHooks: cannot reload hooks (keeping current engine):', (err && err.message) || String(err));
2138
+ }
2139
+ }
2140
+
2141
+ /**
2142
+ * Watch the config and hot-reload on change. TWO mechanisms, belt-and-braces:
2143
+ * 1. fs.watch on the DIRECTORIES - fast, event-driven, filtered by basename.
2144
+ * 2. fs.watchFile POLLING on each specific config file - the reliable fallback.
2145
+ * The directory watch alone is NOT enough: fs.watch is event-based and on Windows frequently
2146
+ * MISSES the atomic temp+rename writes the stores use (the target's inode is replaced without a
2147
+ * usable change event on the directory), so a `tf_hook_set` / UI toggle could fail to hot-reload
2148
+ * until restart. fs.watchFile polls each file's stat, so it catches every create/modify (including
2149
+ * atomic rename) on every platform. Both paths feed the SAME debounced schedulers, so a change
2150
+ * that trips both still collapses to one reload. Debounced (a temp+rename burst -> one reload),
2151
+ * with a re-entrancy guard so a mid-flight expose reload can't overlap itself. A watch failure (an
2152
+ * fs that supports neither) is logged and ignored - the server still runs, just without
2153
+ * auto-reload. NEVER throws.
2154
+ *
2155
+ * @param {object} build the live build object (mutated by the reloaders)
2156
+ * @param {(obj:object)=>void} send the stdio writer
2157
+ * @returns {void}
2158
+ */
2159
+ function startConfigWatchers(buildArg, send) {
2160
+ // buildArg may be the build OBJECT (stdio: one stable object, mutated in place) OR a GETTER
2161
+ // () => build. The HTTP host REBUILDS its build (reload()/stop() reassign it), so it passes a
2162
+ // getter - the reloaders then always target the LIVE build and a rebuild can never desync the
2163
+ // watchers. Backward-compatible: a plain object is wrapped in a getter, so stdio is unchanged.
2164
+ const getBuild = typeof buildArg === 'function' ? buildArg : () => buildArg;
2165
+ const DEBOUNCE_MS = 150;
2166
+ const mcpDir = path.dirname(EXPOSE_PATH);
2167
+ const hooksDir = path.dirname(MANIFEST_PATH);
2168
+ const openWatchers = []; // fs.watch handles, closed by the returned stop()
2169
+ const polledFiles = []; // fs.watchFile paths, unwatched by the returned stop()
2170
+
2171
+ let exposeTimer = null;
2172
+ let reloadingExpose = false;
2173
+ function scheduleExpose() {
2174
+ if (exposeTimer) clearTimeout(exposeTimer);
2175
+ exposeTimer = setTimeout(async () => {
2176
+ exposeTimer = null;
2177
+ if (reloadingExpose) { scheduleExpose(); return; } // a reload is mid-flight; re-arm
2178
+ reloadingExpose = true;
2179
+ // reloadExpose is contracted never to throw, but a debounced setTimeout callback has no
2180
+ // caller to catch an escaped rejection - so guard here too (an unhandled rejection would
2181
+ // crash the host on default Node). Belt and braces.
2182
+ try { await reloadExpose(getBuild(), send); }
2183
+ catch (e) { logErr('scheduleExpose: reload rejected (should not):', (e && e.message) || String(e)); }
2184
+ finally { reloadingExpose = false; }
2185
+ }, DEBOUNCE_MS);
2186
+ if (exposeTimer && typeof exposeTimer.unref === 'function') exposeTimer.unref(); // don't pin the loop
2187
+ }
2188
+
2189
+ let hooksTimer = null;
2190
+ function scheduleHooks() {
2191
+ if (hooksTimer) clearTimeout(hooksTimer);
2192
+ hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(getBuild()); }, DEBOUNCE_MS);
2193
+ if (hooksTimer && typeof hooksTimer.unref === 'function') hooksTimer.unref();
2194
+ }
2195
+
2196
+ let registerTimer = null;
2197
+ function scheduleRegister() {
2198
+ if (registerTimer) clearTimeout(registerTimer);
2199
+ registerTimer = setTimeout(() => { registerTimer = null; reloadRegister(getBuild(), send); }, DEBOUNCE_MS);
2200
+ if (registerTimer && typeof registerTimer.unref === 'function') registerTimer.unref();
2201
+ }
2202
+
2203
+ // tools.state.json needs NO reload (it is read fresh per tools/list & tools/call) - but a
2204
+ // hot/hidden/enabled toggle CHANGES the top-level surface, so a connected client must be told
2205
+ // to re-fetch. Notification only.
2206
+ let stateTimer = null;
2207
+ function scheduleStateNotify() {
2208
+ if (stateTimer) clearTimeout(stateTimer);
2209
+ stateTimer = setTimeout(() => {
2210
+ stateTimer = null;
2211
+ // A LIVE wrap-set may target an upstream this process refused at boot (path-isolation -
2212
+ // the wrap exemption is read per CONNECT, not retroactively). Kick a background connect
2213
+ // for the wrap target if it isn't connected; fire-and-forget, NEVER throws (the
2214
+ // transparent-wrapper ruling).
2215
+ try {
2216
+ const b = getBuild();
2217
+ const agg = b && b.aggregator;
2218
+ const ptId = b && b.toolStatePath
2219
+ ? getPassthrough(loadToolStateResult(b.toolStatePath).state) : null;
2220
+ if (agg && ptId && typeof agg.ensureConnected === 'function') {
2221
+ Promise.resolve(agg.ensureConnected(ptId)).catch(() => { /* background; reconnect owns retries */ });
2222
+ }
2223
+ } catch (_e) { /* a state-notify must never die on the kick */ }
2224
+ emitToolsListChanged(send);
2225
+ }, DEBOUNCE_MS);
2226
+ if (stateTimer && typeof stateTimer.unref === 'function') stateTimer.unref();
2227
+ }
2228
+
2229
+ try {
2230
+ const w = fs.watch(mcpDir, (_event, filename) => {
2231
+ // null filename (some platforms don't report it) -> reload to be safe; else only expose.json.
2232
+ if (!filename || path.basename(String(filename)) === 'expose.json') scheduleExpose();
2233
+ });
2234
+ // unref so an FSWatcher never keeps the process alive after the client closes stdin - the
2235
+ // gateway must still exit naturally when its transport closes (it did before this feature).
2236
+ if (w && typeof w.unref === 'function') w.unref();
2237
+ openWatchers.push(w);
2238
+ logErr('startConfigWatchers: watching', mcpDir, 'for expose.json changes');
2239
+ } catch (err) {
2240
+ logErr('startConfigWatchers: cannot watch', mcpDir + ':', (err && err.message) || String(err));
2241
+ }
2242
+
2243
+ try {
2244
+ const w = fs.watch(hooksDir, (_event, filename) => {
2245
+ const base = filename ? path.basename(String(filename)) : '';
2246
+ if (!filename || base === 'hooks.manifest.json' || base === 'hooks.state.json') scheduleHooks();
2247
+ });
2248
+ if (w && typeof w.unref === 'function') w.unref();
2249
+ openWatchers.push(w);
2250
+ logErr('startConfigWatchers: watching', hooksDir, 'for hook changes');
2251
+ } catch (err) {
2252
+ logErr('startConfigWatchers: cannot watch', hooksDir + ':', (err && err.message) || String(err));
2253
+ }
2254
+
2255
+ // The register + state overlay live in tools/. Register edits (tf_tool_add from its child
2256
+ // process, the UI, a hand edit) were the ONE config surface with no watcher - a running gateway
2257
+ // served its startup snapshot until restart, which broke the "add a tool live" story the moment
2258
+ // the server outlived the edit. State toggles only need the list_changed nudge (read-fresh).
2259
+ const toolsDir = path.dirname(REGISTER_PATH);
2260
+ try {
2261
+ const w = fs.watch(toolsDir, (_event, filename) => {
2262
+ const base = filename ? path.basename(String(filename)) : '';
2263
+ if (!filename || base === 'tools.register.json') scheduleRegister();
2264
+ if (!filename || base === 'tools.state.json') scheduleStateNotify();
2265
+ });
2266
+ if (w && typeof w.unref === 'function') w.unref();
2267
+ openWatchers.push(w);
2268
+ logErr('startConfigWatchers: watching', toolsDir, 'for register/state changes');
2269
+ } catch (err) {
2270
+ logErr('startConfigWatchers: cannot watch', toolsDir + ':', (err && err.message) || String(err));
2271
+ }
2272
+
2273
+ // ── Reliable fallback: fs.watchFile POLLS each config file's stat, so it catches every
2274
+ // create/modify - including the atomic temp+rename writes that fs.watch misses on Windows.
2275
+ // A 1s interval is ample for config (no sub-second reload need); the schedulers debounce, so a
2276
+ // change caught by BOTH the directory watch and the file poll still yields a single reload.
2277
+ // The state overlays (hooks.state.json / tools.state.json) may not exist yet - watchFile fires
2278
+ // when they first appear (a tf_hook_set / tf_tool_set toggle CREATES them), which is the case
2279
+ // the directory watch was dropping.
2280
+ const pollWatch = (file, schedule) => {
2281
+ try {
2282
+ // Retain the listener ref so stop() can unwatchFile(file, listener) - a bare
2283
+ // unwatchFile(file) would remove ALL listeners on that path, clobbering a second host/watcher
2284
+ // set in the same process (parallel tests, an embedder running two hosts).
2285
+ const listener = (curr, prev) => {
2286
+ if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) schedule();
2287
+ };
2288
+ const sw = fs.watchFile(file, { interval: 1000 }, listener);
2289
+ if (sw && typeof sw.unref === 'function') sw.unref(); // never pin the event loop
2290
+ polledFiles.push({ file, listener });
2291
+ } catch (err) {
2292
+ logErr('startConfigWatchers: cannot poll-watch', file + ':', (err && err.message) || String(err));
2293
+ }
2294
+ };
2295
+ pollWatch(EXPOSE_PATH, scheduleExpose);
2296
+ pollWatch(MANIFEST_PATH, scheduleHooks);
2297
+ pollWatch(path.join(hooksDir, 'hooks.state.json'), scheduleHooks);
2298
+ pollWatch(REGISTER_PATH, scheduleRegister);
2299
+ pollWatch(path.join(toolsDir, 'tools.state.json'), scheduleStateNotify);
2300
+ logErr('startConfigWatchers: fs.watchFile polling fallback armed (Windows-safe)');
2301
+
2302
+ // Teardown handle. The stdio path never calls it (the process dies when stdin closes and the
2303
+ // watchers are all unref'd); the HTTP host calls it in stop() so a started->stopped host leaves
2304
+ // no dangling directory watchers or file pollers.
2305
+ return function stopConfigWatchers() {
2306
+ // Clear any PENDING debounced reload so a change caught just before teardown can't fire a
2307
+ // reload against a torn-down build after the caller has moved on. (It would be a caught no-op,
2308
+ // but a truly-stopped watcher must not schedule work.)
2309
+ for (const t of [exposeTimer, hooksTimer, registerTimer, stateTimer]) {
2310
+ if (t) { try { clearTimeout(t); } catch (_e) { /* ignore */ } }
2311
+ }
2312
+ for (const w of openWatchers) {
2313
+ try { if (w && typeof w.close === 'function') w.close(); } catch (_e) { /* ignore */ }
2314
+ }
2315
+ for (const p of polledFiles) {
2316
+ try { fs.unwatchFile(p.file, p.listener); } catch (_e) { /* ignore */ }
2317
+ }
2318
+ };
2319
+ }
2320
+
2321
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
2322
+ // stdin framing reader - accepts BOTH Content-Length framing and newline-delimited JSON.
2323
+ // We write newline-delimited JSON on stdout.
2324
+ //
2325
+ // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
2326
+ // accepted too - see normaliseBuild). It is threaded straight into handleMessage so tools/list
2327
+ // advertises the curated-direct surface and tools/call gates curated-direct calls.
2328
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
2329
+ function createStdioLoop(build) {
2330
+ let buf = Buffer.alloc(0);
2331
+ // Serialise message handling so responses are written in request order even though handlers
2332
+ // are async (avoids interleaving partial writes on stdout).
2333
+ let chain = Promise.resolve();
2334
+
2335
+ // Requests waiting on (or running in) the chain, keyed by client id - the pre-cancellation
2336
+ // registry. A notifications/cancelled processed out-of-band marks a still-queued
2337
+ // target here; its chain link then skips processing and answers nothing (per spec). One client
2338
+ // per pipe, so raw client ids are unique keys.
2339
+ const queuedRequests = new Map();
2340
+
2341
+ // Modern subscriptions/listen registrations. stdio has ONE client, so era-correct notification
2342
+ // delivery is: any active modern subscription -> tagged copies only (the client declared itself
2343
+ // modern by subscribing); none -> the raw legacy notification, byte-for-byte the 0.5.0 shape.
2344
+ const modernSubs = [];
2345
+
2346
+ // Era latch: once the pipe's client has sent ONE modern-_meta request, it is a modern client -
2347
+ // and a modern client that opened no listen stream gets NOTHING unsolicited (in its era nothing
2348
+ // arrives outside a subscription). Only a never-modern (legacy) pipe gets raw notifications.
2349
+ let sawModern = false;
2350
+
2351
+ function send(obj) {
2352
+ if (obj == null) return;
2353
+ let line;
2354
+ try {
2355
+ line = JSON.stringify(obj);
2356
+ } catch (_e) {
2357
+ line = JSON.stringify(makeError(null, ERR.INTERNAL, 'failed to serialise response'));
2358
+ }
2359
+ process.stdout.write(line + '\n');
2360
+ }
2361
+
2362
+ /**
2363
+ * Era-aware notification writer for the hot-reload watchers + the aggregator's tools-changed
2364
+ * signal. tools/list_changed fans out to active modern subscriptions (tagged, filtered on the
2365
+ * agreed toolsListChanged) or falls back to the raw legacy notification; anything else passes
2366
+ * straight through to send().
2367
+ */
2368
+ function notify(n) {
2369
+ if (!n || typeof n.method !== 'string') return;
2370
+ // Era rule: NO modern subscriber -> raw notification (a legacy client on the pipe gets the legacy
2371
+ // shape - tools/list_changed, resources/updated, etc.). At least one modern subscriber -> tagged
2372
+ // copies ONLY to those whose AGREED filter includes this notification's channel; a modern client
2373
+ // that declined a channel must NOT get it (the ack's agreed
2374
+ // subset is the contract). Bridges upstream change-notifications (resources/prompts/tools) too.
2375
+ if (modernSubs.length === 0) return sawModern ? undefined : send(n);
2376
+ for (const s of modernSubs) {
2377
+ if (modern.notificationMatchesFilter(n, s.agreed)) send(modern.tagNotification(n, s.id));
2378
+ }
2379
+ }
2380
+
2381
+ function enqueue(msg) {
2382
+ // Era latch - only a VALID modern request latches (an otherwise-legacy client that once sent
2383
+ // a malformed _meta must not lose its raw notifications forever). initialize
2384
+ // never latches: routing serves it as LEGACY regardless of decoration (a dual-era client that
2385
+ // stamps its trio on the handshake would otherwise lose raw notifications for the pipe's
2386
+ // lifetime while being served legacy semantics).
2387
+ if (msg && msg.method !== 'initialize' &&
2388
+ modern.isModernRequest(msg) && !modern.validateModernRequest(msg)) sawModern = true;
2389
+
2390
+ // notifications/cancelled leaves the chain - chaining it queued a pipelined
2391
+ // cancel BEHIND the very forward it was trying to cancel: by the time it ran, the forward
2392
+ // had settled and the in-flight entry was gone (the whole translation machinery was
2393
+ // unreachable on the real wire; live-repro'd). A cancel produces no response, so immediate
2394
+ // processing cannot disorder the response stream. ONLY cancels jump the queue: cancellation
2395
+ // is the one notification with hard timing semantics - everything else stays chained so the
2396
+ // upstream sees the client's wire order untouched.
2397
+ const hasId = msg && msg.id !== undefined && msg.id !== null;
2398
+ if (msg && msg.method === 'notifications/cancelled' && !hasId) {
2399
+ // A cancel whose target request is still QUEUED (its chain link not started) marks it:
2400
+ // the link skips processing and sends NO reply (spec: a cancelled request is never
2401
+ // answered). Nothing is relayed upstream - the forward never started, so the upstream
2402
+ // never saw the request. `started` closes the mid-processing race: once the link runs,
2403
+ // the cancel falls through to handleMessage, whose in-flight map translates + relays.
2404
+ if (msg.params && msg.params.requestId !== undefined) {
2405
+ const queued = queuedRequests.get(msg.params.requestId);
2406
+ if (queued) {
2407
+ // Started or not: a cancelled request receives NO response (spec). For a STARTED
2408
+ // forward the upstream goes silent after the translated cancel - our own eventual
2409
+ // settle/timeout envelope must not become a response to the cancelled id
2410
+ //the send site drops it via this flag.
2411
+ queued.cancelled = true;
2412
+ if (!queued.started) return; // never started -> nothing was relayed; nothing to translate
2413
+ }
2414
+ }
2415
+ Promise.resolve(handleMessage(build, msg, 'stdio'))
2416
+ .then((r) => { if (r != null) send(r); }) // a cancel answers nothing; belt-and-braces
2417
+ .catch((err) => logErr('out-of-band cancel failed:', (err && err.message) || String(err)));
2418
+ return;
2419
+ }
2420
+
2421
+ // Requests register for pre-cancellation while they wait their turn on the chain. The listen
2422
+ // request is transport-owned + long-lived (its "reply" is a stream) - cancel does not apply.
2423
+ const isListen = msg && msg.method === 'subscriptions/listen' && modern.isModernRequest(msg);
2424
+ const reg = (hasId && !isListen) ? { started: false, cancelled: false } : null;
2425
+ if (reg) queuedRequests.set(msg.id, reg);
2426
+
2427
+ chain = chain.then(async () => {
2428
+ if (reg) {
2429
+ reg.started = true;
2430
+ if (reg.cancelled) { queuedRequests.delete(msg.id); return; } // pre-cancelled: no reply (spec)
2431
+ }
2432
+ try {
2433
+ // subscriptions/listen (modern) is TRANSPORT-owned: the ack and every later notification
2434
+ // ride this same stdout pipe, and the final result is written when stdin closes (see the
2435
+ // 'end' handler). Everything else routes through handleMessage unchanged.
2436
+ if (msg && msg.method === 'subscriptions/listen' && modern.isModernRequest(msg)) {
2437
+ const vErr = modern.validateModernRequest(msg);
2438
+ if (vErr) {
2439
+ if (hasId) send(makeError(msg.id, vErr.code, vErr.message, vErr.data));
2440
+ return;
2441
+ }
2442
+ if (!hasId) return; // a listen with no id can never be acknowledged or closed - ignore
2443
+ armWrapChatter(build); // a listen-FIRST client bypasses handleMessage's per-message arming
2444
+ const { agreed } = modern.normaliseListenFilter(msg.params);
2445
+ // resourceSubscriptions is only agreed when some upstream can DELIVER per-URI updates
2446
+ // (modern era emits spontaneously; a subscribe-capable legacy upstream gets the
2447
+ // subscribes forwarded). An ack must never promise a dead channel.
2448
+ if (agreed.resourceSubscriptions) {
2449
+ const agg = build && build.aggregator;
2450
+ if (agg && typeof agg.canHonourResourceSubscriptions === 'function' && agg.canHonourResourceSubscriptions()) {
2451
+ agg.subscribeResources(agreed.resourceSubscriptions);
2452
+ } else {
2453
+ delete agreed.resourceSubscriptions;
2454
+ }
2455
+ }
2456
+ // Replace-on-same-id: a client re-sending listen with the same id (a retry after a missed
2457
+ // ack) must not accumulate a duplicate registration -> doubled notifications forever
2458
+ //Overwrite the existing entry; otherwise append.
2459
+ const existing = modernSubs.findIndex((s) => s.id === msg.id);
2460
+ if (existing !== -1) modernSubs[existing] = { id: msg.id, agreed };
2461
+ else modernSubs.push({ id: msg.id, agreed });
2462
+ send(modern.listenAck(msg.id, agreed));
2463
+ return;
2464
+ }
2465
+ // 'stdio' - the pipe has exactly ONE client for the process lifetime, so a constant
2466
+ // connection key is a true identity (enables safe cancel translation under a wrap).
2467
+ const response = await handleMessage(build, msg, 'stdio');
2468
+ // Cancelled while in flight -> the client gets NO response for that id (spec); whatever
2469
+ // settled - the upstream's late result or our timeout envelope - is dropped here
2470
+ if (reg && reg.cancelled) return;
2471
+ send(response);
2472
+ } finally {
2473
+ if (reg) queuedRequests.delete(msg.id); // settled (or skipped) - pre-cancel can no longer apply
2474
+ }
2475
+ });
2476
+ }
2477
+
2478
+ // Try to pull one Content-Length-framed message off the front of `buf`. Returns true if it
2479
+ // consumed one (and enqueued it), false if there isn't a complete header-framed message yet.
2480
+ function tryHeaderFramed() {
2481
+ const headerEnd = buf.indexOf('\r\n\r\n');
2482
+ if (headerEnd === -1) return false;
2483
+ const header = buf.slice(0, headerEnd).toString('utf8');
2484
+ const m = /Content-Length:\s*(\d+)/i.exec(header);
2485
+ if (!m) return false; // header block without a Content-Length -> not this framing
2486
+ const len = parseInt(m[1], 10);
2487
+ const bodyStart = headerEnd + 4;
2488
+ if (buf.length < bodyStart + len) return false; // body not all here yet
2489
+ const body = buf.slice(bodyStart, bodyStart + len).toString('utf8');
2490
+ buf = buf.slice(bodyStart + len);
2491
+ let obj = null;
2492
+ try {
2493
+ obj = JSON.parse(body);
2494
+ } catch (_e) {
2495
+ send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON body'));
2496
+ return true;
2497
+ }
2498
+ enqueue(obj);
2499
+ return true;
2500
+ }
2501
+
2502
+ // Try to pull one newline-delimited JSON message off the front of `buf`. Returns true if it
2503
+ // consumed a line (whether or not it parsed), false if no complete line is buffered.
2504
+ function tryLineFramed() {
2505
+ const nl = buf.indexOf(0x0a); // '\n'
2506
+ if (nl === -1) return false;
2507
+ const line = buf.slice(0, nl).toString('utf8').trim();
2508
+ buf = buf.slice(nl + 1);
2509
+ if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
2510
+ // A leftover header line ("Content-Length: ...") that wasn't consumed by tryHeaderFramed means
2511
+ // the body hasn't arrived; but since we always try header framing FIRST, a stray header line
2512
+ // here is junk - ignore non-JSON lines rather than erroring on framing artifacts.
2513
+ if (line[0] !== '{' && line[0] !== '[') return true;
2514
+ let obj = null;
2515
+ try {
2516
+ obj = JSON.parse(line);
2517
+ } catch (_e) {
2518
+ send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON line'));
2519
+ return true;
2520
+ }
2521
+ enqueue(obj);
2522
+ return true;
2523
+ }
2524
+
2525
+ function drain() {
2526
+ // Header framing first (it counts exact bytes and is unambiguous); fall back to line framing.
2527
+ // Loop until neither can make progress.
2528
+ for (;;) {
2529
+ if (tryHeaderFramed()) continue;
2530
+ if (tryLineFramed()) continue;
2531
+ break;
2532
+ }
2533
+ }
2534
+
2535
+ process.stdin.on('data', (chunk) => {
2536
+ buf = Buffer.concat([buf, chunk]);
2537
+ drain();
2538
+ });
2539
+ process.stdin.on('end', () => {
2540
+ // Flush any complete trailing message, then gracefully close each modern listen subscription
2541
+ // (the spec ends a subscription with a final RESULT to the listen id) before the process exits.
2542
+ drain();
2543
+ for (const sub of modernSubs.splice(0)) {
2544
+ try { send(modern.listenClose(sub.id)); } catch (_e) { /* pipe may already be gone */ }
2545
+ }
2546
+ });
2547
+ process.stdin.on('error', (err) => {
2548
+ logErr('stdin error:', (err && err.message) || String(err));
2549
+ });
2550
+
2551
+ // Begin reading.
2552
+ process.stdin.resume();
2553
+
2554
+ // Expose the writer so the entry point can wire hot-reload watchers (they emit
2555
+ // notifications/tools/list_changed through this same send). `notify` is the era-aware wrapper
2556
+ // the watchers should prefer - it fans tools/list_changed out to modern subscriptions.
2557
+ return { send, notify };
2558
+ }
2559
+
2560
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
2561
+ // Entry point.
2562
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
2563
+ async function main() {
2564
+ // The Phase-2 build object { protocol, aggregator, engine, ctx }.
2565
+ let build;
2566
+ try {
2567
+ build = buildProtocol();
2568
+ } catch (err) {
2569
+ // If wiring failed (e.g. a missing register/manifest), still start a server that answers every
2570
+ // request with a clean internal error - so the client gets a message instead of a hang. The
2571
+ // degraded build has a null aggregator (no curated-direct surface) but a working meta-tool
2572
+ // path that reports the wiring failure.
2573
+ logErr('FATAL wiring error:', (err && err.stack) || String(err));
2574
+ const message = `MCP server failed to initialise: ${(err && err.message) || String(err)}`;
2575
+ const protocol = makeProtocol({
2576
+ registry: { list: () => { throw new Error(message); }, instructions: () => { throw new Error(message); }, resolveExecution: () => null },
2577
+ gatedRun,
2578
+ howto: () => { throw new Error(message); },
2579
+ });
2580
+ build = { protocol, aggregator: null, engine: null, ctx: null };
2581
+ }
2582
+
2583
+ // Connect every ENABLED upstream BEFORE the loop starts so tools/list advertises the curated
2584
+ // surface from the first request. connectAll() NEVER throws (per-upstream failures land in
2585
+ // failed[]) and with the default EMPTY expose.json it is an instant no-op - so this never
2586
+ // delays or crashes startup. await-safe either way.
2587
+ if (build.aggregator && typeof build.aggregator.connectAll === 'function') {
2588
+ try {
2589
+ const res = await build.aggregator.connectAll();
2590
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
2591
+ logErr('aggregator.connectAll: ' + res.failed.length + ' upstream(s) failed:',
2592
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
2593
+ }
2594
+ } catch (err) {
2595
+ // connectAll is contracted not to throw; guard anyway so startup never dies on it.
2596
+ logErr('aggregator.connectAll threw (ignored):', (err && err.message) || String(err));
2597
+ }
2598
+ }
2599
+ armWrapChatter(build); // N3 filter armed from boot - not only from the first client message
2600
+
2601
+ // Keep the process alive on the stdio loop; never let an unhandled rejection kill it.
2602
+ process.on('uncaughtException', (err) => logErr('uncaughtException:', (err && err.stack) || String(err)));
2603
+ process.on('unhandledRejection', (err) => logErr('unhandledRejection:', (err && (err.stack || err.message)) || String(err)));
2604
+
2605
+ const loop = createStdioLoop(build);
2606
+
2607
+ // Wire the aggregator's out-of-band tool-set-change signal (a background reconnect recovering - or
2608
+ // finally losing - an upstream) to notifications/tools/list_changed, so the client refreshes when a
2609
+ // dead upstream heals itself. Set on the live instance; reloadExpose re-wires the swapped-in one.
2610
+ // loop.notify is the era-aware writer: it fans the notification out to modern subscriptions
2611
+ // (tagged) or falls back to the raw legacy shape - same for the watchers below.
2612
+ if (build.aggregator) build.aggregator.onToolsChanged = () => emitToolsListChanged(loop.notify);
2613
+ // Bridge an upstream's server-initiated change-notifications (resources/prompts/tools list_changed,
2614
+ // resources/updated) to the era-aware notify path - so a modern client's subscriptions/listen
2615
+ // stream receives them, tagged + filtered. This is what makes wrapping a resource/prompt-bearing
2616
+ // legacy MCP transparent for a modern client (2026-07-16).
2617
+ if (build.aggregator) build.aggregator.onUpstreamNotification = (uid, n) => {
2618
+ // Under a wrap, ONLY the wrapped upstream's notifications reach the client - another attached
2619
+ // upstream's events are ones the impersonated server would never emit: a transparency tell
2620
+ // and a cross-server information leak. Funnel mode (no wrap) fans all.
2621
+ const wrapOnly = build.aggregator && build.aggregator.wrapChatterUpstream;
2622
+ if (wrapOnly && uid !== wrapOnly) return;
2623
+ loop.notify(n);
2624
+ };
2625
+ // Bridge B: server-initiated upstream requests (elicitation/create) route to the bridge; it
2626
+ // binds them to the one in-flight wrapped call or declines. `build` is the stable stdio object.
2627
+ if (build.aggregator) build.aggregator.onUpstreamServerRequest =
2628
+ (uid, m, c) => handleUpstreamServerRequest(build, uid, m, c);
2629
+
2630
+ // Hot-reload: pick up expose.json / hook changes written by the tf_* tools or the UI (a separate
2631
+ // process) with NO restart and NO user intervention - the missing primitive that previously made
2632
+ // a live MCP attach or a hook change need a bounce. Auto is the default; the meta-tools/UI button
2633
+ // are just optional manual levers on top of this.
2634
+ startConfigWatchers(build, loop.notify);
2635
+
2636
+ logErr('ready -', SERVER_INFO.name, SERVER_INFO.version, '(pid', process.pid + ')');
2637
+ }
2638
+
2639
+ // Run only when invoked directly (so the module can also be required by tests). main() is async
2640
+ // (it awaits aggregator.connectAll); a startup rejection is logged to stderr, never thrown so the
2641
+ // process can't die unhandled before the loop is even up.
2642
+ if (require.main === module) {
2643
+ Promise.resolve()
2644
+ .then(main)
2645
+ .catch((err) => logErr('FATAL startup error:', (err && err.stack) || String(err)));
2646
+ }
2647
+
2648
+ module.exports = {
2649
+ // Exported for unit tests / reuse (the transport is thin; these are the seams).
2650
+ buildProtocol,
2651
+ makeRegistryAdapter,
2652
+ handleMessage,
2653
+ handleInitialize,
2654
+ // The stdio entry point - an external bin/ can require this module and call main() to start the
2655
+ // server (build -> connect upstreams -> run the stdio loop). main() is async and takes no args.
2656
+ main,
2657
+ // Phase-2 + MATRIX handlers: handleToolsList(protocol, aggregator, { registry?, toolStatePath? })
2658
+ // and handleToolsCall(protocol, aggregator, { engine, ctx, registry?, toolStatePath? }, params).
2659
+ handleToolsList,
2660
+ handleToolsCall,
2661
+ // N3 filter arming - the HTTP transport calls this at start/reload and in its listen branch.
2662
+ armWrapChatter,
2663
+ // Bridge B: the HTTP transport wires this as the aggregator's onUpstreamServerRequest handler.
2664
+ handleUpstreamServerRequest,
2665
+ // Matrix helpers (exported for unit tests / reuse).
2666
+ localHotDefinitions,
2667
+ localToolDefinition,
2668
+ isPromotedLocal,
2669
+ isPromotedUpstream,
2670
+ // Phase-2: emit the hot-update notification (no-id notifications/tools/list_changed) via send.
2671
+ emitToolsListChanged,
2672
+ // Hot-reload: re-read on-disk config live (no restart). Wired to fs.watch in main().
2673
+ reloadExpose,
2674
+ reloadHooks,
2675
+ startConfigWatchers,
2676
+ // Helpers exported for tests / reuse.
2677
+ isMetaTool,
2678
+ runCuratedDirect,
2679
+ wrapProtocolResult,
2680
+ normaliseBuild,
2681
+ stringifyContent,
2682
+ firstString,
2683
+ // Dual-era (2026-07-28) handlers - the modern module itself is at ./modern.
2684
+ handleServerDiscover,
2685
+ handleModernMessage,
2686
+ PROTOCOL_VERSION,
2687
+ SERVER_INFO,
2688
+ };