toolfunnel 0.5.0 → 0.7.0

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