toolfunnel 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,1240 @@
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
+ const ROOT = path.resolve(__dirname, '..', '..'); // <…>/<root>
57
+ const REGISTER_PATH = path.join(ROOT, 'tools', 'tools.register.json');
58
+ // The ACTIVE/DISABLED overlay (tool-state.js). toolfunnel_list_tools filters DISABLED tools out so
59
+ // they are not surfaced to the client. Read FRESH per list() call so UI toggles take effect with no
60
+ // restart. HIDDEN is a manager-list-only axis — it is NEVER consulted here (it must not affect the
61
+ // client's view).
62
+ const TOOL_STATE_PATH = path.join(ROOT, 'tools', 'tools.state.json');
63
+ const MANIFEST_PATH = path.join(ROOT, 'hooks', 'hooks.manifest.json');
64
+ const SCRIPTS_ROOT = path.join(ROOT, 'tools', 'scripts');
65
+ // Phase 2: the persisted curated-expose + upstream-MCP config. Default is EMPTY, so the
66
+ // aggregator connects to nothing and the curated-direct surface is empty — the server is
67
+ // behaviourally identical to Phase 1 until an upstream + expose entry is added.
68
+ const EXPOSE_PATH = path.join(ROOT, 'mcp', 'expose.json');
69
+
70
+ const PROTOCOL_VERSION = '2024-11-05';
71
+ const SERVER_INFO = { name: 'toolfunnel', version: '0.3.0' };
72
+
73
+ // ── Diagnostics → stderr only (stdout is the JSON-RPC channel; never pollute it). ─────────────
74
+ function logErr(...parts) {
75
+ try {
76
+ process.stderr.write('[toolfunnel] ' + parts.join(' ') + '\n');
77
+ } catch (_e) {
78
+ /* never let logging throw */
79
+ }
80
+ }
81
+
82
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
83
+ // Registry adapter — bridge the real Registry to what protocol.runTool consumes.
84
+ //
85
+ // protocol.runTool expects: registry.resolveExecution(name, args) -> { execute, toolName, args }
86
+ // The real Registry returns: resolveExecution(id, args) -> { type, run }
87
+ // - it looks up by `id` only (throws on unknown id),
88
+ // - returns `run` (not `execute`),
89
+ // - for a SHELL invoke it returns a DEFERRED descriptor instead of running (the gated runner
90
+ // is meant to own shell execution — registry.js keeps un-gated shell out of itself by design).
91
+ //
92
+ // The adapter therefore:
93
+ // - resolves the meta-tool `name` to a register id (accepting id OR display name),
94
+ // - wraps `run` into an `execute` thunk that returns a clean tool output,
95
+ // - for the shell-deferred case, performs the actual shell spawn INSIDE the execute thunk — so
96
+ // it only ever runs after gatedRun's PreToolUse gate has allowed it.
97
+ // list()/instructions() pass straight through (with the same name→id resolution for instructions).
98
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
99
+ function makeRegistryAdapter(registry, opts) {
100
+ // opts.toolStatePath: when set, list() filters DISABLED tools out (the enabled-filter for
101
+ // toolfunnel_list_tools). When absent (the Tool Manager's runOnce adapter, tests) list() is unfiltered.
102
+ const toolStatePath = (opts && opts.toolStatePath) || null;
103
+ // opts.getAggregator: an OPTIONAL live getter () => Aggregator|null. NEVER a captured instance —
104
+ // reloadExpose swaps build.aggregator, so the adapter must read it fresh each call. When absent the
105
+ // adapter is local-only, byte-for-byte its pre-slice-2 behaviour (preserves the runOnce adapter + tests).
106
+ const getAggregator = (opts && typeof opts.getAggregator === 'function') ? opts.getAggregator : null;
107
+
108
+ /** The live aggregator, or null. Defensive: a throwing/absent getter yields null (local-only). */
109
+ function liveAggregator() {
110
+ if (!getAggregator) return null;
111
+ try { const a = getAggregator(); return a && typeof a === 'object' ? a : null; } catch (_e) { return null; }
112
+ }
113
+
114
+ /** Resolve a meta-tool name (which may be a display name) to a register id. */
115
+ function resolveId(nameOrId) {
116
+ if (typeof nameOrId !== 'string' || nameOrId.length === 0) return null;
117
+ if (registry.has(nameOrId)) return nameOrId; // it's already an id
118
+ // Fall back to matching the display name.
119
+ const hit = registry.list().find((b) => b.name === nameOrId);
120
+ return hit ? hit.id : null;
121
+ }
122
+
123
+ /** Run a shell command synchronously, normalising to { ok, code, stdout, stderr }. */
124
+ function runShell(command, args) {
125
+ const res = spawnSync(command, {
126
+ shell: true,
127
+ cwd: ROOT,
128
+ env: Object.assign({}, process.env, { TOOLFUNNEL_TOOL_ARGS: JSON.stringify(args == null ? null : args) }),
129
+ encoding: 'utf8',
130
+ windowsHide: true,
131
+ });
132
+ if (res.error) throw new Error(`shell spawn failed: ${res.error.message}`);
133
+ const code = typeof res.status === 'number' ? res.status : -1;
134
+ return {
135
+ ok: code === 0,
136
+ code,
137
+ stdout: res.stdout != null ? String(res.stdout) : '',
138
+ stderr: res.stderr != null ? String(res.stderr) : '',
139
+ };
140
+ }
141
+
142
+ return {
143
+ // Briefs only (protocol forwards { filter, category }). When a tool-state overlay is configured,
144
+ // DISABLED (✗) tools are dropped so toolfunnel_list_tools never surfaces them to the client —
145
+ // read FRESH each call so a UI toggle takes effect immediately, no host restart. HIDDEN (👁) is
146
+ // UI-only and is deliberately NOT consulted here (it must never change what the client sees).
147
+ list(listOpts) {
148
+ const o = listOpts || {};
149
+ let local = registry.list(o);
150
+ const state = toolStatePath ? loadToolState(toolStatePath) : null;
151
+ if (state) local = local.filter((b) => isToolEnabled(state, b.id));
152
+
153
+ const agg = liveAggregator();
154
+ if (!agg || typeof agg.leanToolDefinitions !== 'function') return local;
155
+ // Append the LEAN upstream tools (the full discovered set of connected upstreams), then:
156
+ // - drop any whose name is a local tool (local-register WINS — matches resolveExecution),
157
+ // - apply the SAME {filter,category} registry.list applied (parity), and
158
+ // - apply the SAME tool-state enable filter local tools get, so the lean list is CURATABLE
159
+ // per-tool: disable an upstream tool by its surfaced id, exactly like a local tool.
160
+ let upstream = [];
161
+ try {
162
+ upstream = agg.leanToolDefinitions()
163
+ .map((d) => ({ id: d.name, name: d.name, summary: d.description || '', category: `mcp:${d.upstream}` }))
164
+ .filter((b) => resolveId(b.id) === null)
165
+ .filter((b) => briefMatches(b, o))
166
+ .filter((b) => !state || isToolEnabled(state, b.id));
167
+ } catch (_e) {
168
+ upstream = []; // a throwing aggregator must never sink toolfunnel_list_tools — local-only fallback
169
+ }
170
+ return local.concat(upstream);
171
+ },
172
+
173
+ // Full instructions for one tool (by id or display name). Registry.instructions throws on an
174
+ // unknown id; protocol.toolInstructions catches that and returns a clean error result.
175
+ instructions(nameOrId) {
176
+ const id = resolveId(nameOrId);
177
+ if (id) return registry.instructions(id);
178
+ // Not a local tool — synthesise instructions for a lean upstream tool from its discovered def.
179
+ const agg = liveAggregator();
180
+ if (!agg || typeof agg.leanToolDefinitions !== 'function') return null;
181
+ try {
182
+ const def = agg.leanToolDefinitions().find((d) => d.name === nameOrId);
183
+ return def ? renderUpstreamInstructions(def) : null; // null → protocol's "no tool named …"
184
+ } catch (_e) {
185
+ return null;
186
+ }
187
+ },
188
+
189
+ // Build the { execute, toolName, args, mode } resolution protocol.runTool hands to gatedRun.
190
+ resolveExecution(nameOrId, args) {
191
+ const id = resolveId(nameOrId);
192
+ if (id) {
193
+ // LOCAL tool — byte-for-byte the pre-slice-2 path. A local name that resolves but isn't
194
+ // runnable returns null and does NOT fall through to upstream (local-register WINS: a local
195
+ // name never silently resolves to a remote upstream).
196
+ const entry = registry.getEntry(id); // { id, name, summary, category, instructions, invoke, mode? }
197
+ const desc = registry.resolveExecution(id, args); // { type, mode, run? } | reference: { type, mode, instructions }
198
+ if (!desc) return null;
199
+
200
+ // Reference mode: nothing executes here. Hand back the instructions (no execute thunk)
201
+ // so protocol.runTool short-circuits BEFORE gatedRun — no spawn, no gate.
202
+ if (desc.mode === 'reference') {
203
+ return {
204
+ mode: 'reference',
205
+ toolName: entry.name || id,
206
+ instructions: typeof desc.instructions === 'string' ? desc.instructions : (entry.instructions || ''),
207
+ };
208
+ }
209
+
210
+ if (typeof desc.run !== 'function') return null;
211
+
212
+ const execute = async () => {
213
+ const out = await desc.run();
214
+ // Shell invokes come back deferred — the registry hands shell execution to the gated path.
215
+ if (out && out.deferred === true && out.type === 'shell') {
216
+ return runShell(out.command, out.args);
217
+ }
218
+ return out;
219
+ };
220
+
221
+ return { execute, toolName: entry.name || id, args: args == null ? {} : args, mode: desc.mode || 'gateway' };
222
+ }
223
+
224
+ // No local tool by this name — try the LEAN upstream forward. resolveLeanExecution returns a
225
+ // resolution whose execute thunk lazy-(re)connects + unwraps; the gate matches fwd.toolName (the
226
+ // surfaced name). mode is 'gateway' so it goes THROUGH gatedRun — never the reference handoff.
227
+ const agg = liveAggregator();
228
+ if (!agg || typeof agg.resolveLeanExecution !== 'function') return null;
229
+ let fwd = null;
230
+ try { fwd = agg.resolveLeanExecution(nameOrId, args); } catch (_e) { fwd = null; }
231
+ if (!fwd || typeof fwd.execute !== 'function') return null;
232
+ return { execute: fwd.execute, toolName: fwd.toolName, args: args == null ? {} : args, mode: 'gateway' };
233
+ },
234
+ };
235
+ }
236
+
237
+ // ── Build the protocol + aggregator (the meta-tool logic + the curated-direct seam). ──────────
238
+ // All wiring failures are caught so the server still starts and reports a clean JSON-RPC error
239
+ // instead of dying — a dead server hangs the client (and the test).
240
+ //
241
+ // Returns an OBJECT, not just the protocol, because the Phase-2 curated-direct call path needs
242
+ // more than the protocol:
243
+ // { protocol, aggregator, engine, ctx }
244
+ // - protocol : the 4 meta-tools (unchanged Phase-1 logic) — callers needing just the
245
+ // protocol read it off `.protocol` (backward compat).
246
+ // - aggregator : the upstream-MCP connection + curated-expose set (built from expose.json).
247
+ // Default expose.json is EMPTY so it connects to nothing.
248
+ // - engine, ctx: threaded into gatedRun for the curated-direct path (a curated tool runs
249
+ // THROUGH the same PreToolUse gate as toolfunnel_run_tool — the safety invariant).
250
+ function buildProtocol() {
251
+ const registry = loadRegistry(REGISTER_PATH, { scriptsRoot: SCRIPTS_ROOT });
252
+ const loader = loadManifest(MANIFEST_PATH);
253
+
254
+ // The hook context every gated run carries (the common hook fields).
255
+ const ctx = {
256
+ session_id: `toolfunnel-mcp-${process.pid}`,
257
+ transcript_path: '',
258
+ cwd: ROOT,
259
+ };
260
+
261
+ // The LIVE build holder, created FIRST so both the adapter and the protocol read the CURRENT
262
+ // aggregator/engine after a hot-reload swaps them in place (reloadExpose mutates build.aggregator,
263
+ // reloadHooks mutates build.engine). main() threads this SAME object into the stdio loop and the
264
+ // watchers, so nothing below captures a stale instance.
265
+ // - registry : the raw Registry, so handleToolsList can build a full MCP def (with the
266
+ // entry's inputSchema) for a LOCAL tool promoted hot to the top-level surface.
267
+ // - toolStatePath : the per-tool overlay path, read FRESH per tools/list & tools/call so a UI
268
+ // hot/enabled toggle takes effect with no restart (the visibility MATRIX).
269
+ const build = { protocol: null, aggregator: null, engine: null, ctx, registry, toolStatePath: TOOL_STATE_PATH };
270
+
271
+ // Engine PROXY: protocol/gatedRun only need engine.fire. Delegating to build.engine means a
272
+ // reloadHooks swap gates the lean-forward (and local run_tool) path with the NEW engine, exactly
273
+ // like curated-direct (which reads build.engine live via deps). Without this the protocol would
274
+ // capture the engine at construction and a runtime hook change would gate one route, not the other.
275
+ const engineProxy = { fire: (event, hookCtx, extra) => build.engine.fire(event, hookCtx, extra) };
276
+
277
+ // The adapter reads the aggregator through a LIVE getter (never a captured instance).
278
+ const registryAdapter = makeRegistryAdapter(registry, {
279
+ toolStatePath: TOOL_STATE_PATH,
280
+ getAggregator: () => build.aggregator,
281
+ });
282
+
283
+ build.protocol = makeProtocol({
284
+ registry: registryAdapter,
285
+ gatedRun,
286
+ engine: engineProxy,
287
+ ctx,
288
+ howto,
289
+ });
290
+ build.engine = new HookEngine(loader, { cwd: ROOT });
291
+
292
+ // Phase 2: the aggregator over expose.json (EMPTY by default → nothing connects).
293
+ // loadExposeStore on a missing/empty file returns an empty store; Aggregator over an empty store
294
+ // advertises no curated-direct tools and connectAll() is an instant no-op.
295
+ const store = loadExposeStore(EXPOSE_PATH);
296
+ build.aggregator = new Aggregator({ store, v3Root: ROOT });
297
+
298
+ return build;
299
+ }
300
+
301
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
302
+ // JSON-RPC 2.0 helpers.
303
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
304
+ const JSONRPC = '2.0';
305
+ // Standard JSON-RPC error codes.
306
+ const ERR = Object.freeze({
307
+ PARSE: -32700,
308
+ INVALID_REQUEST: -32600,
309
+ METHOD_NOT_FOUND: -32601,
310
+ INVALID_PARAMS: -32602,
311
+ INTERNAL: -32603,
312
+ });
313
+
314
+ function makeResult(id, result) {
315
+ return { jsonrpc: JSONRPC, id, result };
316
+ }
317
+ function makeError(id, code, message, data) {
318
+ const error = { code, message };
319
+ if (data !== undefined) error.data = data;
320
+ // A null id is valid for errors where the request id couldn't be determined.
321
+ return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
322
+ }
323
+
324
+ // ── MCP method handlers. ──────────────────────────────────────────────────────────────────────
325
+ function handleInitialize(params) {
326
+ // protocolVersion + serverInfo + capabilities { tools:{} } (see the architecture notes §7,
327
+ // mcp-server.test.js asserts a `tools` capability + a protocolVersion).
328
+ // Activity log (self-gating; no-op unless logging is enabled): the CLIENT-connected half of
329
+ // connect logging — the aggregator logs the upstream half. Wrapped though logger.log never throws.
330
+ try {
331
+ const info = params && params.clientInfo;
332
+ logger.log({
333
+ type: 'client',
334
+ event: 'connect',
335
+ client: info && typeof info.name === 'string' ? info.name : 'unknown',
336
+ protocolVersion: params && typeof params.protocolVersion === 'string' ? params.protocolVersion : undefined,
337
+ });
338
+ } catch (_e) { /* never let logging affect the handshake */ }
339
+ return {
340
+ protocolVersion: PROTOCOL_VERSION,
341
+ serverInfo: SERVER_INFO,
342
+ capabilities: {
343
+ // We are a tool host. `tools: {}` declares the capability. Phase 2 wires curated-direct
344
+ // hot-updates, so we now advertise listChanged:true — the server may emit
345
+ // notifications/tools/list_changed when the curated-direct expose set changes (see
346
+ // emitToolsListChanged). The mcp-server.test.js assertion only requires a `tools`
347
+ // capability key + a protocolVersion, both still present.
348
+ tools: { listChanged: true },
349
+ },
350
+ };
351
+ }
352
+
353
+ function handleToolsList(protocol, aggregator, opts) {
354
+ // The TOP-LEVEL surface (tools/list — injected EVERY turn) is assembled from the per-tool `hot`
355
+ // axis of the visibility MATRIX, deduped by name with a fixed precedence (first writer wins):
356
+ //
357
+ // 1. META-TOOLS hot defaults TRUE — the 4 management tools; a meta-tool can be HIDDEN
358
+ // from the top level (state[name].hot === false).
359
+ // 2. LOCAL hot tools hot defaults FALSE — a register tool promoted hot (and enabled); its def
360
+ // carries the entry's inputSchema (or {type:object}).
361
+ // 3. CURATED-DIRECT the existing expose[] path (an enabled expose entry == top-level).
362
+ // 4. UPSTREAM hot tools hot defaults FALSE — a connected upstream tool promoted hot by its
363
+ // surfaced name (lean run semantics; not via expose[]).
364
+ //
365
+ // Precedence is local-register-wins over an upstream of the same name (matches the lean rule), and
366
+ // a META-tool can NEVER be shadowed by a local/upstream tool of the same name (safety). With the
367
+ // default EMPTY state + EMPTY expose.json this is exactly the 4 meta-tools — byte-identical to the
368
+ // pre-matrix surface. `opts` is OPTIONAL: a Phase-1 / bare-protocol caller (no registry/state) gets
369
+ // meta-tools (all hot) + curated-direct, the prior behaviour. NEVER throws.
370
+ const o = opts || {};
371
+ const registry = o.registry || null;
372
+ const statePath = typeof o.toolStatePath === 'string' && o.toolStatePath ? o.toolStatePath : null;
373
+ let state = {};
374
+ if (statePath) { try { state = loadToolState(statePath); } catch (_e) { state = {}; } }
375
+
376
+ const byName = new Map(); // name -> MCP tool def; first writer wins (the precedence above)
377
+
378
+ // The 4 meta-tool names are RESERVED: nothing else may be advertised under them, even when a meta
379
+ // is hidden (else a curated-direct/upstream tool aliased onto a meta name would be advertised yet
380
+ // uncallable — handleToolsCall routes a meta name to the meta gate first).
381
+ const metaNames = new Set(protocol.toolDefinitions().map((d) => d && d.name));
382
+
383
+ // 1. META-TOOLS — hot by default; hideable via state[name].hot === false (footgun: hiding all is
384
+ // the "ordinary tools as an MCP" pattern — warned below + at the write site).
385
+ for (const def of protocol.toolDefinitions()) {
386
+ if (def && isToolHot(state, def.name, true) && !byName.has(def.name)) byName.set(def.name, def);
387
+ }
388
+
389
+ // 2. LOCAL hot tools — opt-in (hot:true) AND enabled. A real MCP def from the register entry.
390
+ if (registry) {
391
+ for (const def of localHotDefinitions(registry, state)) {
392
+ if (def && !byName.has(def.name)) byName.set(def.name, def);
393
+ }
394
+ }
395
+
396
+ // 3. CURATED-DIRECT (expose[]) — unchanged. exposedToolDefinitions never throws, but guard anyway.
397
+ // Skip any def colliding with a RESERVED meta name (never advertise a phantom uncallable tool).
398
+ if (aggregator && typeof aggregator.exposedToolDefinitions === 'function') {
399
+ try {
400
+ const defs = aggregator.exposedToolDefinitions();
401
+ if (Array.isArray(defs)) for (const def of defs) if (def && !byName.has(def.name) && !metaNames.has(def.name)) byName.set(def.name, def);
402
+ } catch (err) {
403
+ logErr('exposedToolDefinitions failed:', (err && err.message) || String(err));
404
+ }
405
+ }
406
+
407
+ // 4. UPSTREAM hot tools — a connected upstream tool promoted hot by its surfaced name (and enabled),
408
+ // not already surfaced. Skip a name that is RESERVED (meta) or TAKEN by a local tool's id/display
409
+ // name — local-register-wins, AND it keeps the advertised name resolving to what actually runs
410
+ // (a local display-name collision would otherwise advertise the upstream but run the local tool).
411
+ if (aggregator && typeof aggregator.leanToolDefinitions === 'function') {
412
+ try {
413
+ for (const d of aggregator.leanToolDefinitions()) {
414
+ if (!d || byName.has(d.name) || metaNames.has(d.name) || localNameTaken(registry, d.name)) continue;
415
+ if (isToolHot(state, d.name, false) && isToolEnabled(state, d.name)) {
416
+ byName.set(d.name, { name: d.name, description: d.description || '', inputSchema: d.inputSchema || { type: 'object' } });
417
+ }
418
+ }
419
+ } catch (err) {
420
+ logErr('leanToolDefinitions (hot) failed:', (err && err.message) || String(err));
421
+ }
422
+ }
423
+
424
+ // Footgun warning (server-side, throttled): if NO meta-tool is on the surface, in-band tool
425
+ // discovery/run is gone — recovery is the --ui console or hand-editing tools/tools.state.json.
426
+ const metaPresent = [...metaNames].some((n) => byName.has(n));
427
+ if (!metaPresent && !_noMetaWarned) {
428
+ 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.');
429
+ _noMetaWarned = true;
430
+ } else if (metaPresent) {
431
+ _noMetaWarned = false;
432
+ }
433
+
434
+ return { tools: [...byName.values()] };
435
+ }
436
+
437
+ /** Module flag so the all-metas-hidden warning logs ONCE per episode (not on every tools/list). */
438
+ let _noMetaWarned = false;
439
+
440
+ /** Is `name` already a LOCAL register tool's id OR display name? Used to keep the lean-rule
441
+ * "local-register wins" consistent on the top-level surface (and so an advertised upstream name is
442
+ * never run as a local tool via the run path's display-name resolution). NEVER throws. */
443
+ function localNameTaken(registry, name) {
444
+ if (!registry) return false;
445
+ try {
446
+ return registry.list().some((b) => b && (b.id === name || b.name === name));
447
+ } catch (_e) {
448
+ return false;
449
+ }
450
+ }
451
+
452
+ /**
453
+ * The MCP tool definitions for every LOCAL register tool promoted HOT (and enabled). Each is built
454
+ * from the register entry so a directly-callable top-level tool carries a real input schema:
455
+ * - name = the register `id` (an identifier-clean MCP tool name; the human `name` may carry
456
+ * spaces, which is a poor MCP tool name). The id is also what toolfunnel_run_tool
457
+ * resolves, and the PreToolUse gate fires on `entry.name || id` for BOTH the direct
458
+ * call and a run_tool call (the adapter's resolveExecution sets that toolName), so a
459
+ * gate authored via the UI — which keys the matcher on that same `entry.name || id`
460
+ * — gates both routes identically. No new gate-naming footgun vs run_tool today.
461
+ * - description = the entry summary (falls back to the human name, then the id).
462
+ * - inputSchema = the entry's optional `inputSchema` object, else a free-form {type:object}.
463
+ * NEVER throws.
464
+ * @param {object} registry the raw Registry
465
+ * @param {object} state the loaded tool-state overlay
466
+ * @returns {Array<{name:string, description:string, inputSchema:object}>}
467
+ */
468
+ function localHotDefinitions(registry, state) {
469
+ const out = [];
470
+ let briefs;
471
+ try { briefs = registry.list(); } catch (_e) { return out; }
472
+ for (const b of briefs) {
473
+ if (!b || typeof b.id !== 'string') continue;
474
+ if (!isToolHot(state, b.id, false)) continue; // opt-in only (default off)
475
+ if (!isToolEnabled(state, b.id)) continue; // a disabled tool is never hot
476
+ let entry;
477
+ try { entry = registry.getEntry(b.id); } catch (_e) { continue; }
478
+ out.push(localToolDefinition(entry));
479
+ }
480
+ return out;
481
+ }
482
+
483
+ /** Build one top-level MCP def from a register entry. name = the id (clean identifier). description
484
+ * = summary || name || id. inputSchema = entry.inputSchema when an object, else free-form {type:object}. */
485
+ function localToolDefinition(entry) {
486
+ const schema = entry && entry.inputSchema && typeof entry.inputSchema === 'object' && !Array.isArray(entry.inputSchema)
487
+ ? entry.inputSchema
488
+ : { type: 'object' };
489
+ const description = (entry && typeof entry.summary === 'string' && entry.summary)
490
+ ? entry.summary
491
+ : ((entry && typeof entry.name === 'string' && entry.name) ? entry.name : entry.id);
492
+ return { name: entry.id, description, inputSchema: schema };
493
+ }
494
+
495
+ async function handleToolsCall(protocol, aggregator, deps, params) {
496
+ const p = params || {};
497
+ const name = p.name;
498
+ const args = p.arguments;
499
+ if (typeof name !== 'string' || name.length === 0) {
500
+ // Shape the bad-params case as an MCP tools/call error result (isError) rather than a
501
+ // protocol-level JSON-RPC error, so the model sees a usable message.
502
+ return {
503
+ content: [{ type: 'text', text: 'tools/call requires a string "name".' }],
504
+ isError: true,
505
+ };
506
+ }
507
+
508
+ // ── Routing (see the architecture notes §2; the curated-direct invariant in §9; the MATRIX) ────
509
+ // 1. A meta-tool name → protocol.dispatch (unchanged; a meta-tool can NEVER be shadowed).
510
+ // 2. ELSE a LOCAL tool promoted HOT, called directly → run it exactly like toolfunnel_run_tool
511
+ // {name,args}: ONE path, reusing the PreToolUse gate + reference-mode + register resolution.
512
+ // Local-register wins over a curated-direct of the same name (the lean rule + list precedence).
513
+ // 3. ELSE a curated-direct tool (expose[]) → run THROUGH gatedRun with transparent upstream-
514
+ // envelope passthrough. THE INVARIANT: it can NEVER reach the upstream without the gate.
515
+ // 4. ELSE an UPSTREAM tool promoted HOT (by surfaced name, not via expose[]), called directly →
516
+ // run it like toolfunnel_run_tool: the adapter falls through to the aggregator's lean forward,
517
+ // so the gate fires on the surfaced name and the upstream envelope is unwrapped.
518
+ // 5. ELSE → protocol.dispatch, which returns the clean unknown-tool error.
519
+ // Only tools ACTUALLY advertised hot are directly callable (the advertised surface == the callable
520
+ // surface — a non-promoted tool called directly gets the clean unknown-tool error, not a silent run).
521
+ // Load the tool-state overlay ONCE for the routing checks — fresh per call so a UI toggle is live.
522
+ const d0 = deps || {};
523
+ let state = {};
524
+ if (d0.toolStatePath) { try { state = loadToolState(d0.toolStatePath); } catch (_e) { state = {}; } }
525
+
526
+ // 1. A meta-tool — but callable ONLY while it is on the advertised top-level surface (hot, default
527
+ // true). A meta HIDDEN via hot:false is dropped from tools/list AND becomes uncallable, so the
528
+ // callable surface == the advertised surface even for the meta-tools — the "ordinary tools as an
529
+ // MCP" lockdown is REAL, not cosmetic. (A meta-tool is still never SHADOWED by a same-named
530
+ // local/upstream tool: this check runs FIRST, so a hot meta always wins precedence.)
531
+ if (isMetaTool(protocol, name)) {
532
+ if (isToolHot(state, name, true)) {
533
+ return wrapProtocolResult(await protocol.dispatch(name, args || {}));
534
+ }
535
+ // Hidden meta-tool: not advertised, so not callable — the clean unknown-tool error (never run it).
536
+ return { content: [{ type: 'text', text: `Unknown tool "${name}".` }], isError: true };
537
+ }
538
+
539
+ if (d0.registry && isPromotedLocal(d0.registry, state, name)) {
540
+ return wrapProtocolResult(await protocol.runTool({ name, args: args == null ? {} : args }));
541
+ }
542
+
543
+ if (aggregator && typeof aggregator.isExposed === 'function' && aggregator.isExposed(name)) {
544
+ return await runCuratedDirect(aggregator, deps, name, args);
545
+ }
546
+
547
+ if (isPromotedUpstream(aggregator, d0.registry, state, name)) {
548
+ return wrapProtocolResult(await protocol.runTool({ name, args: args == null ? {} : args }));
549
+ }
550
+
551
+ // Not a meta-tool, not promoted, not exposed → let the protocol return its clean unknown-tool error.
552
+ return wrapProtocolResult(await protocol.dispatch(name, args || {}));
553
+ }
554
+
555
+ /**
556
+ * Is `name` a LOCAL register tool that is promoted HOT and enabled (so it's on the top-level surface
557
+ * AND therefore directly callable)? Resolves the advertised name (entry.name || id) back to the
558
+ * register entry, then checks the hot+enabled flags by the tool's id (the state key). NEVER throws.
559
+ */
560
+ function isPromotedLocal(registry, state, name) {
561
+ const entry = localEntryByAdvertisedName(registry, name);
562
+ if (!entry) return false;
563
+ return isToolHot(state, entry.id, false) && isToolEnabled(state, entry.id);
564
+ }
565
+
566
+ /** Resolve a top-level call name back to its register brief. The advertised top-level name is the
567
+ * `id`; the human `name` is accepted too (forgiving callers). Returns the brief {id,name,...} or
568
+ * null. NEVER throws. */
569
+ function localEntryByAdvertisedName(registry, name) {
570
+ try {
571
+ const briefs = registry.list();
572
+ let hit = briefs.find((b) => b && b.id === name);
573
+ if (!hit) hit = briefs.find((b) => b && b.name === name);
574
+ return hit || null;
575
+ } catch (_e) {
576
+ return null;
577
+ }
578
+ }
579
+
580
+ /**
581
+ * Is `name` a connected UPSTREAM tool (by surfaced name) promoted HOT and enabled? Only reached for a
582
+ * name that is NOT a meta-tool, NOT a promoted local tool, and NOT curated-direct (those are routed
583
+ * first), so this is the "hot upstream, not via expose[]" case. NEVER throws.
584
+ */
585
+ function isPromotedUpstream(aggregator, registry, state, name) {
586
+ if (!aggregator || typeof aggregator.leanToolDefinitions !== 'function') return false;
587
+ // local-register wins: a name that is a local tool's id/display name is NOT an upstream-hot route,
588
+ // even if an upstream surfaces that name (it would otherwise run the LOCAL tool via the run path's
589
+ // display-name resolution — advertised-upstream / executed-local). Matches handleToolsList step 4.
590
+ if (localNameTaken(registry, name)) return false;
591
+ try {
592
+ const d = aggregator.leanToolDefinitions().find((x) => x && x.name === name);
593
+ if (!d) return false;
594
+ return isToolHot(state, d.name, false) && isToolEnabled(state, d.name);
595
+ } catch (_e) {
596
+ return false;
597
+ }
598
+ }
599
+
600
+ /** Is `name` one of the 4 lean meta-tools the protocol owns? */
601
+ function isMetaTool(protocol, name) {
602
+ // protocol.META_TOOLS is { LIST, INSTRUCTIONS, RUN, HOWTO } → string values.
603
+ const meta = protocol && protocol.META_TOOLS;
604
+ if (!meta || typeof meta !== 'object') return false;
605
+ for (const key of Object.keys(meta)) {
606
+ if (meta[key] === name) return true;
607
+ }
608
+ return false;
609
+ }
610
+
611
+ /**
612
+ * Run a CURATED-DIRECT tool through the hook gate. Resolve {execute} from the aggregator, then
613
+ * hand it to gatedRun with the threaded engine + ctx so PreToolUse fires on the DOWNSTREAM name
614
+ * (the gate's matcher target) BEFORE the upstream tool is ever invoked. The result is wrapped in
615
+ * the MCP tools/call envelope: isError when !ok, surfacing reason (a PreToolUse block) or error
616
+ * (a thrown upstream call). NEVER throws — a misbehaving aggregator/gate becomes a clean error
617
+ * result so the loop survives.
618
+ */
619
+ async function runCuratedDirect(aggregator, deps, name, args) {
620
+ const d = deps || {};
621
+ // resolveExposedExecution → { execute, toolName, upstream } | null. It can change between the
622
+ // isExposed() check and here only if the expose set mutated mid-call; guard for null.
623
+ let resolution = null;
624
+ try {
625
+ resolution = aggregator.resolveExposedExecution(name, args);
626
+ } catch (err) {
627
+ return {
628
+ content: [{ type: 'text', text: `curated tool "${name}" failed to resolve: ${(err && err.message) || String(err)}` }],
629
+ isError: true,
630
+ };
631
+ }
632
+ if (!resolution || typeof resolution.execute !== 'function') {
633
+ // Raced away (or never really runnable) — surface a clean error rather than reaching upstream.
634
+ return {
635
+ content: [{ type: 'text', text: `curated tool "${name}" is no longer runnable` }],
636
+ isError: true,
637
+ };
638
+ }
639
+
640
+ // The gate matches on the DOWNSTREAM name (resolution.toolName === the `as` name). gatedRun is
641
+ // contracted never to throw, but we still try/catch so a wiring bug can't crash the loop.
642
+ let result;
643
+ try {
644
+ result = await gatedRun({
645
+ engine: d.engine,
646
+ ctx: d.ctx,
647
+ toolName: resolution.toolName || name,
648
+ args: args == null ? {} : args,
649
+ execute: resolution.execute,
650
+ });
651
+ } catch (err) {
652
+ return {
653
+ content: [{ type: 'text', text: `gated execution of "${name}" failed: ${(err && err.message) || String(err)}` }],
654
+ isError: true,
655
+ };
656
+ }
657
+
658
+ // Gate-level failure (a PreToolUse block, or a thrown upstream call): surface the most
659
+ // informative message — a block carries `reason`, a thrown call carries `error`.
660
+ if (!(result && result.ok === true)) {
661
+ const message = firstString(
662
+ result && result.error && (result.error.message || String(result.error)),
663
+ result && result.reason,
664
+ 'curated tool call failed'
665
+ );
666
+ return { content: [{ type: 'text', text: stringifyContent(message) }], isError: true };
667
+ }
668
+
669
+ // Success: result.output IS the upstream's own tools/call envelope ({ content, isError }).
670
+ // Pass it THROUGH transparently so the client receives the upstream's real content blocks
671
+ // (text / image / etc.) AND its isError flag — rather than the whole envelope JSON-stringified
672
+ // into one text block (which also used to swallow the upstream's isError). runCuratedDirect only
673
+ // ever runs FORWARDED upstream tools, so result.output is always an MCP envelope here.
674
+ const out = result.output;
675
+ if (out && typeof out === 'object' && Array.isArray(out.content)) {
676
+ return { content: out.content, isError: out.isError === true };
677
+ }
678
+ // Defensive fallback: a non-standard upstream payload — wrap it as text so the client still gets
679
+ // a well-formed envelope rather than an empty/invalid content array.
680
+ return { content: [{ type: 'text', text: stringifyContent(out) }], isError: false };
681
+ }
682
+
683
+ /**
684
+ * Wrap a protocol dispatch result ({ ok, output, error?, reason? }) in the MCP tools/call
685
+ * envelope. Extracted so the meta-tool path and the unknown-tool path share one shaping.
686
+ */
687
+ function wrapProtocolResult(result) {
688
+ const isError = !(result && result.ok === true);
689
+ // On error surface the most informative message (a PreToolUse block carries `reason`, a thrown
690
+ // tool carries `error`); on success serialise the output payload.
691
+ let payload;
692
+ if (isError) {
693
+ payload = firstString(
694
+ // gatedRun returns `error` as an Error INSTANCE on a thrown tool — extract its message
695
+ // (firstString only takes strings, so a bare Error would be dropped → generic text).
696
+ // Mirrors runCuratedDirect's error shaping so the two paths report identically.
697
+ result && result.error && (result.error.message || String(result.error)),
698
+ result && result.reason,
699
+ 'tool call failed'
700
+ );
701
+ } else if (result && result.output !== undefined) {
702
+ payload = result.output;
703
+ } else if (result && result.mode === 'reference') {
704
+ // A reference HANDOFF carries no `output` — its `instructions` ARE the payload. Surface them
705
+ // (with the handoff message) so a reference tool returns usable text whether reached via
706
+ // toolfunnel_run_tool OR called directly after being promoted hot (the matrix). Falls back to
707
+ // the whole result object if neither field is a string.
708
+ payload = firstString(result.instructions, result.message) || result;
709
+ } else {
710
+ payload = result && result.output; // undefined → stringifyContent guards it to a string
711
+ }
712
+
713
+ return {
714
+ content: [{ type: 'text', text: stringifyContent(payload) }],
715
+ isError,
716
+ };
717
+ }
718
+
719
+ /** Return the first argument that is a non-empty string (used to pick the best error message). */
720
+ function firstString(...candidates) {
721
+ for (const c of candidates) {
722
+ if (typeof c === 'string' && c.length > 0) return c;
723
+ }
724
+ return '';
725
+ }
726
+
727
+ /** Serialise a protocol payload into a text block. Strings pass through; everything else → JSON.
728
+ * NEVER returns a non-string: JSON.stringify(undefined) / a function yields `undefined`, so guard it
729
+ * (an MCP content block's `text` must be a string — an undefined text is a malformed envelope). */
730
+ function stringifyContent(value) {
731
+ if (typeof value === 'string') return value;
732
+ try {
733
+ const s = JSON.stringify(value);
734
+ return typeof s === 'string' ? s : String(value);
735
+ } catch (_e) {
736
+ return String(value);
737
+ }
738
+ }
739
+
740
+ /**
741
+ * Filter predicate replicating Registry.list's brief filtering (src/tools/registry.js:211-228) so
742
+ * LEAN upstream briefs filter IDENTICALLY to local briefs: exact `category` match + case-insensitive
743
+ * substring `filter` over `id name summary`. Kept in lockstep with registry.list (a parity test covers
744
+ * it); if registry.list's filter semantics ever change, change this too.
745
+ */
746
+ function briefMatches(b, opts) {
747
+ const { filter, category } = opts || {};
748
+ if (category && b.category !== category) return false;
749
+ if (typeof filter === 'string' && filter.length) {
750
+ const hay = `${b.id} ${b.name} ${b.summary || ''}`.toLowerCase();
751
+ if (!hay.includes(filter.toLowerCase())) return false;
752
+ }
753
+ return true;
754
+ }
755
+
756
+ /**
757
+ * Render a lean upstream tool's instructions as a STRING (homogeneous with registry.instructions),
758
+ * synthesised from its discovered def { name, description, inputSchema, upstream, tool }: the exact
759
+ * toolfunnel_run_tool invocation, provenance, description, the input schema + required fields, and a
760
+ * note that the call passes the same PreToolUse gate as any local tool.
761
+ */
762
+ function renderUpstreamInstructions(def) {
763
+ const lines = [];
764
+ lines.push(`${def.name} — forwarded from upstream MCP "${def.upstream}" (upstream tool: ${def.tool}).`);
765
+ if (def.description) lines.push('', def.description);
766
+ lines.push('', 'Run it through the gateway with:', ` toolfunnel_run_tool { "name": "${def.name}", "args": { … } }`);
767
+ lines.push('', 'Every call passes the same PreToolUse gate as a local tool (a hook may deny it).');
768
+ let schemaStr;
769
+ try { schemaStr = JSON.stringify(def.inputSchema || { type: 'object' }, null, 2); } catch (_e) { schemaStr = '{ "type": "object" }'; }
770
+ lines.push('', 'Input schema:', schemaStr);
771
+ const req = def.inputSchema && Array.isArray(def.inputSchema.required) ? def.inputSchema.required : [];
772
+ if (req.length) lines.push('', 'Required: ' + req.join(', '));
773
+ return lines.join('\n');
774
+ }
775
+
776
+ /**
777
+ * Normalise the first argument of handleMessage / createStdioLoop into the canonical Phase-2
778
+ * build shape { protocol, aggregator, engine, ctx }. Backward compat: a caller may still pass a
779
+ * bare protocol (Phase-1 callers / older tests) — we wrap it with a null aggregator + no deps so
780
+ * the meta-tool path is identical and the curated-direct path is simply never taken (isExposed
781
+ * is unreachable without an aggregator). A protocol is detected by its dispatch() method.
782
+ * @param {object} arg either { protocol, aggregator, engine, ctx } or a bare protocol
783
+ * @returns {{ protocol:object, aggregator:(object|null), engine:(object|null), ctx:(object|null) }}
784
+ */
785
+ function normaliseBuild(arg) {
786
+ const a = arg || {};
787
+ if (a && typeof a.dispatch === 'function') {
788
+ // It's a bare protocol — Phase-1 shape (no register/state → no hot promotion; meta + curated only).
789
+ return { protocol: a, aggregator: null, engine: null, ctx: null, registry: null, toolStatePath: null };
790
+ }
791
+ return {
792
+ protocol: a.protocol,
793
+ aggregator: a.aggregator || null,
794
+ engine: a.engine || null,
795
+ ctx: a.ctx || null,
796
+ // The MATRIX inputs: the raw register (for a hot LOCAL tool's full MCP def) + the state overlay
797
+ // path (read fresh per call). Absent → the surface is meta + curated-direct, the prior behaviour.
798
+ registry: a.registry || null,
799
+ toolStatePath: a.toolStatePath || null,
800
+ };
801
+ }
802
+
803
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
804
+ // Dispatch ONE parsed JSON-RPC message. Returns a response object to send, or null for
805
+ // notifications / messages that take no reply. NEVER throws.
806
+ //
807
+ // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
808
+ // still accepted for backward compat — see normaliseBuild). The aggregator + engine + ctx are
809
+ // threaded into tools/list and tools/call so the curated-direct surface is advertised and runs
810
+ // THROUGH the PreToolUse gate.
811
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
812
+ async function handleMessage(build, msg) {
813
+ const { protocol, aggregator, engine, ctx, registry, toolStatePath } = normaliseBuild(build);
814
+
815
+ // Basic envelope validation — a non-object or wrong jsonrpc is an Invalid Request.
816
+ if (!msg || typeof msg !== 'object' || Array.isArray(msg)) {
817
+ return makeError(null, ERR.INVALID_REQUEST, 'Invalid Request: expected a JSON-RPC object');
818
+ }
819
+
820
+ const hasId = Object.prototype.hasOwnProperty.call(msg, 'id') && msg.id !== null;
821
+ const id = hasId ? msg.id : null;
822
+ const method = msg.method;
823
+
824
+ // A message with no method but with an id is a response/garbage to us — acknowledge nothing.
825
+ if (typeof method !== 'string') {
826
+ if (hasId) return makeError(id, ERR.INVALID_REQUEST, 'Invalid Request: missing "method"');
827
+ return null; // notification-shaped junk: ignore
828
+ }
829
+
830
+ try {
831
+ switch (method) {
832
+ case 'initialize':
833
+ return makeResult(id, handleInitialize(msg.params));
834
+
835
+ case 'initialized':
836
+ case 'notifications/initialized':
837
+ // Client → server notification that init handshake is complete. No reply.
838
+ return null;
839
+
840
+ case 'tools/list':
841
+ return makeResult(id, handleToolsList(protocol, aggregator, { registry, toolStatePath }));
842
+
843
+ case 'tools/call': {
844
+ const result = await handleToolsCall(protocol, aggregator, { engine, ctx, registry, toolStatePath }, msg.params);
845
+ // Observability (in-memory counters; never throws). Count every tools/call by name + whether
846
+ // it errored (an isError envelope = a tool failure OR a PreToolUse denial). Single chokepoint
847
+ // → covers stdio AND the HTTP transport, which both route through handleMessage.
848
+ metrics.record({ tool: (msg.params && msg.params.name) || 'unknown', ok: !(result && result.isError === true) });
849
+ return makeResult(id, result);
850
+ }
851
+
852
+ case 'ping':
853
+ // MCP keep-alive: empty result.
854
+ return makeResult(id, {});
855
+
856
+ default:
857
+ // Unknown notification (no id) → silently ignore. Unknown request → method-not-found.
858
+ if (!hasId) {
859
+ if (method.startsWith('notifications/')) return null;
860
+ return null;
861
+ }
862
+ return makeError(id, ERR.METHOD_NOT_FOUND, `Method not found: ${method}`);
863
+ }
864
+ } catch (err) {
865
+ // Any unexpected internal failure becomes a JSON-RPC error (request) or is swallowed
866
+ // (notification) — the loop never crashes on one bad message.
867
+ logErr('handler error for', method + ':', (err && err.stack) || String(err));
868
+ if (hasId) {
869
+ return makeError(id, ERR.INTERNAL, `Internal error handling "${method}"`, (err && err.message) || String(err));
870
+ }
871
+ return null;
872
+ }
873
+ }
874
+
875
+ /**
876
+ * emitToolsListChanged(send) — write the MCP `notifications/tools/list_changed` JSON-RPC
877
+ * notification (NO id — it is a notification, not a request) via the provided `send` fn. The
878
+ * server emits this when the curated-direct expose set hot-updates so a CLI that honours
879
+ * listChanged re-fetches tools/list (see the architecture notes §1 "no restart for register
880
+ * changes"; §7 the host runs the server). `send` is injected so this is unit-testable with a spy
881
+ * and reused by both the stdio loop and (later) the host.
882
+ *
883
+ * @param {(obj: object) => void} send the transport's writer (stdio loop's send, or a spy)
884
+ * @returns {void}
885
+ */
886
+ function emitToolsListChanged(send) {
887
+ if (typeof send !== 'function') return; // never throw — a missing transport is a no-op
888
+ send({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
889
+ }
890
+
891
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
892
+ // HOT-RELOAD — pick up on-disk config changes with NO restart, NO user intervention.
893
+ //
894
+ // The tf_* management tools (and the UI) write expose.json / the hooks files from a SEPARATE
895
+ // process, so the running gateway's in-memory aggregator + hook engine never saw the change — that
896
+ // was the root cause of "you have to restart to attach an MCP / change a hook". These reloaders
897
+ // re-read the on-disk config and SWAP build.aggregator / build.engine IN PLACE. handleMessage reads
898
+ // the build object fresh on every message, so the next tools/list or tools/call uses the new state.
899
+ // startConfigWatchers wires them to fs.watch so the gateway heals/updates itself automatically.
900
+ // Everything here is contracted NEVER to throw — a bad reload keeps the last-good state.
901
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
902
+
903
+ /**
904
+ * Re-read expose.json, rebuild + reconnect the aggregator, swap it into `build`, then emit
905
+ * notifications/tools/list_changed so a listChanged-honouring client (Claude Code does) refreshes
906
+ * its curated-direct surface. The new aggregator is connected BEFORE the old one is closed so there
907
+ * is never a window with no upstreams. NEVER throws.
908
+ *
909
+ * @param {{aggregator?:object}} build the live build object (mutated in place)
910
+ * @param {(obj:object)=>void} send the stdio writer, for the list_changed notification
911
+ * @returns {Promise<void>}
912
+ */
913
+ async function reloadExpose(build, send) {
914
+ let store;
915
+ try {
916
+ store = loadExposeStore(EXPOSE_PATH);
917
+ } catch (err) {
918
+ logErr('reloadExpose: cannot read expose.json (keeping current upstreams):', (err && err.message) || String(err));
919
+ return;
920
+ }
921
+ const next = new Aggregator({ store, v3Root: ROOT });
922
+ next.onToolsChanged = () => emitToolsListChanged(send); // re-wire the background-reconnect → list_changed signal
923
+ let res = null;
924
+ try {
925
+ res = await next.connectAll();
926
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
927
+ logErr('reloadExpose: ' + res.failed.length + ' upstream(s) failed:',
928
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
929
+ }
930
+ } catch (err) {
931
+ logErr('reloadExpose: connectAll threw (ignored):', (err && err.message) || String(err));
932
+ }
933
+ const prev = build.aggregator;
934
+ build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
935
+ if (prev && typeof prev.closeAll === 'function') {
936
+ try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
937
+ }
938
+ emitToolsListChanged(send);
939
+ // Activity log (self-gating): mark the reload trigger; the per-upstream connect/disconnect lines
940
+ // (from the aggregator) carry the detail. Together they record the reconnect.
941
+ logger.log({
942
+ type: 'mcp',
943
+ event: 'reload',
944
+ connected: res && Array.isArray(res.connected) ? res.connected : [],
945
+ failed: res && Array.isArray(res.failed) ? res.failed.map((f) => f.id) : [],
946
+ });
947
+ logErr('reloadExpose: expose.json reloaded — upstreams reconnected');
948
+ }
949
+
950
+ /**
951
+ * Re-read the hooks manifest + state overlay and swap a fresh HookEngine into `build`. The next
952
+ * gated call (toolfunnel_run_tool / curated-direct) uses the new engine, so an added/changed/toggled
953
+ * hook takes effect with no restart. NEVER throws (a bad manifest keeps the current engine).
954
+ *
955
+ * @param {{engine?:object}} build
956
+ * @returns {void}
957
+ */
958
+ function reloadHooks(build) {
959
+ try {
960
+ const loader = loadManifest(MANIFEST_PATH);
961
+ build.engine = new HookEngine(loader, { cwd: ROOT });
962
+ logErr('reloadHooks: hooks manifest/state reloaded');
963
+ } catch (err) {
964
+ logErr('reloadHooks: cannot reload hooks (keeping current engine):', (err && err.message) || String(err));
965
+ }
966
+ }
967
+
968
+ /**
969
+ * Watch the config directories and hot-reload on change. Watches the DIRECTORIES (robust to the
970
+ * atomic temp+rename writes expose-store / hook-loader use, which replace the file inode) and
971
+ * filters by basename. Debounced (a temp+rename burst → one reload), with a re-entrancy guard so a
972
+ * mid-flight expose reload can't overlap itself. A watch failure (an fs that doesn't support
973
+ * fs.watch) is logged and ignored — the server still runs, just without auto-reload. NEVER throws.
974
+ *
975
+ * @param {object} build the live build object (mutated by the reloaders)
976
+ * @param {(obj:object)=>void} send the stdio writer
977
+ * @returns {void}
978
+ */
979
+ function startConfigWatchers(build, send) {
980
+ const DEBOUNCE_MS = 150;
981
+ const mcpDir = path.dirname(EXPOSE_PATH);
982
+ const hooksDir = path.dirname(MANIFEST_PATH);
983
+
984
+ let exposeTimer = null;
985
+ let reloadingExpose = false;
986
+ function scheduleExpose() {
987
+ if (exposeTimer) clearTimeout(exposeTimer);
988
+ exposeTimer = setTimeout(async () => {
989
+ exposeTimer = null;
990
+ if (reloadingExpose) { scheduleExpose(); return; } // a reload is mid-flight; re-arm
991
+ reloadingExpose = true;
992
+ try { await reloadExpose(build, send); } finally { reloadingExpose = false; }
993
+ }, DEBOUNCE_MS);
994
+ if (exposeTimer && typeof exposeTimer.unref === 'function') exposeTimer.unref(); // don't pin the loop
995
+ }
996
+
997
+ let hooksTimer = null;
998
+ function scheduleHooks() {
999
+ if (hooksTimer) clearTimeout(hooksTimer);
1000
+ hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(build); }, DEBOUNCE_MS);
1001
+ if (hooksTimer && typeof hooksTimer.unref === 'function') hooksTimer.unref();
1002
+ }
1003
+
1004
+ try {
1005
+ const w = fs.watch(mcpDir, (_event, filename) => {
1006
+ // null filename (some platforms don't report it) → reload to be safe; else only expose.json.
1007
+ if (!filename || path.basename(String(filename)) === 'expose.json') scheduleExpose();
1008
+ });
1009
+ // unref so an FSWatcher never keeps the process alive after the client closes stdin — the
1010
+ // gateway must still exit naturally when its transport closes (it did before this feature).
1011
+ if (w && typeof w.unref === 'function') w.unref();
1012
+ logErr('startConfigWatchers: watching', mcpDir, 'for expose.json changes');
1013
+ } catch (err) {
1014
+ logErr('startConfigWatchers: cannot watch', mcpDir + ':', (err && err.message) || String(err));
1015
+ }
1016
+
1017
+ try {
1018
+ const w = fs.watch(hooksDir, (_event, filename) => {
1019
+ const base = filename ? path.basename(String(filename)) : '';
1020
+ if (!filename || base === 'hooks.manifest.json' || base === 'hooks.state.json') scheduleHooks();
1021
+ });
1022
+ if (w && typeof w.unref === 'function') w.unref();
1023
+ logErr('startConfigWatchers: watching', hooksDir, 'for hook changes');
1024
+ } catch (err) {
1025
+ logErr('startConfigWatchers: cannot watch', hooksDir + ':', (err && err.message) || String(err));
1026
+ }
1027
+ }
1028
+
1029
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1030
+ // stdin framing reader — accepts BOTH Content-Length framing and newline-delimited JSON.
1031
+ // We write newline-delimited JSON on stdout.
1032
+ //
1033
+ // `build` is the Phase-2 build object { protocol, aggregator, engine, ctx } (a bare protocol is
1034
+ // accepted too — see normaliseBuild). It is threaded straight into handleMessage so tools/list
1035
+ // advertises the curated-direct surface and tools/call gates curated-direct calls.
1036
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1037
+ function createStdioLoop(build) {
1038
+ let buf = Buffer.alloc(0);
1039
+ // Serialise message handling so responses are written in request order even though handlers
1040
+ // are async (avoids interleaving partial writes on stdout).
1041
+ let chain = Promise.resolve();
1042
+
1043
+ function send(obj) {
1044
+ if (obj == null) return;
1045
+ let line;
1046
+ try {
1047
+ line = JSON.stringify(obj);
1048
+ } catch (_e) {
1049
+ line = JSON.stringify(makeError(null, ERR.INTERNAL, 'failed to serialise response'));
1050
+ }
1051
+ process.stdout.write(line + '\n');
1052
+ }
1053
+
1054
+ function enqueue(msg) {
1055
+ chain = chain.then(async () => {
1056
+ const response = await handleMessage(build, msg);
1057
+ send(response);
1058
+ });
1059
+ }
1060
+
1061
+ // Try to pull one Content-Length-framed message off the front of `buf`. Returns true if it
1062
+ // consumed one (and enqueued it), false if there isn't a complete header-framed message yet.
1063
+ function tryHeaderFramed() {
1064
+ const headerEnd = buf.indexOf('\r\n\r\n');
1065
+ if (headerEnd === -1) return false;
1066
+ const header = buf.slice(0, headerEnd).toString('utf8');
1067
+ const m = /Content-Length:\s*(\d+)/i.exec(header);
1068
+ if (!m) return false; // header block without a Content-Length → not this framing
1069
+ const len = parseInt(m[1], 10);
1070
+ const bodyStart = headerEnd + 4;
1071
+ if (buf.length < bodyStart + len) return false; // body not all here yet
1072
+ const body = buf.slice(bodyStart, bodyStart + len).toString('utf8');
1073
+ buf = buf.slice(bodyStart + len);
1074
+ let obj = null;
1075
+ try {
1076
+ obj = JSON.parse(body);
1077
+ } catch (_e) {
1078
+ send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON body'));
1079
+ return true;
1080
+ }
1081
+ enqueue(obj);
1082
+ return true;
1083
+ }
1084
+
1085
+ // Try to pull one newline-delimited JSON message off the front of `buf`. Returns true if it
1086
+ // consumed a line (whether or not it parsed), false if no complete line is buffered.
1087
+ function tryLineFramed() {
1088
+ const nl = buf.indexOf(0x0a); // '\n'
1089
+ if (nl === -1) return false;
1090
+ const line = buf.slice(0, nl).toString('utf8').trim();
1091
+ buf = buf.slice(nl + 1);
1092
+ if (line.length === 0) return true; // blank line (e.g. trailing from header framing): skip
1093
+ // A leftover header line ("Content-Length: …") that wasn't consumed by tryHeaderFramed means
1094
+ // the body hasn't arrived; but since we always try header framing FIRST, a stray header line
1095
+ // here is junk — ignore non-JSON lines rather than erroring on framing artifacts.
1096
+ if (line[0] !== '{' && line[0] !== '[') return true;
1097
+ let obj = null;
1098
+ try {
1099
+ obj = JSON.parse(line);
1100
+ } catch (_e) {
1101
+ send(makeError(null, ERR.PARSE, 'Parse error: invalid JSON line'));
1102
+ return true;
1103
+ }
1104
+ enqueue(obj);
1105
+ return true;
1106
+ }
1107
+
1108
+ function drain() {
1109
+ // Header framing first (it counts exact bytes and is unambiguous); fall back to line framing.
1110
+ // Loop until neither can make progress.
1111
+ for (;;) {
1112
+ if (tryHeaderFramed()) continue;
1113
+ if (tryLineFramed()) continue;
1114
+ break;
1115
+ }
1116
+ }
1117
+
1118
+ process.stdin.on('data', (chunk) => {
1119
+ buf = Buffer.concat([buf, chunk]);
1120
+ drain();
1121
+ });
1122
+ process.stdin.on('end', () => {
1123
+ // Flush any complete trailing message, then let the process exit naturally.
1124
+ drain();
1125
+ });
1126
+ process.stdin.on('error', (err) => {
1127
+ logErr('stdin error:', (err && err.message) || String(err));
1128
+ });
1129
+
1130
+ // Begin reading.
1131
+ process.stdin.resume();
1132
+
1133
+ // Expose the writer so the entry point can wire hot-reload watchers (they emit
1134
+ // notifications/tools/list_changed through this same send).
1135
+ return { send };
1136
+ }
1137
+
1138
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1139
+ // Entry point.
1140
+ // ─────────────────────────────────────────────────────────────────────────────────────────────
1141
+ async function main() {
1142
+ // The Phase-2 build object { protocol, aggregator, engine, ctx }.
1143
+ let build;
1144
+ try {
1145
+ build = buildProtocol();
1146
+ } catch (err) {
1147
+ // If wiring failed (e.g. a missing register/manifest), still start a server that answers every
1148
+ // request with a clean internal error — so the client gets a message instead of a hang. The
1149
+ // degraded build has a null aggregator (no curated-direct surface) but a working meta-tool
1150
+ // path that reports the wiring failure.
1151
+ logErr('FATAL wiring error:', (err && err.stack) || String(err));
1152
+ const message = `MCP server failed to initialise: ${(err && err.message) || String(err)}`;
1153
+ const protocol = makeProtocol({
1154
+ registry: { list: () => { throw new Error(message); }, instructions: () => { throw new Error(message); }, resolveExecution: () => null },
1155
+ gatedRun,
1156
+ howto: () => { throw new Error(message); },
1157
+ });
1158
+ build = { protocol, aggregator: null, engine: null, ctx: null };
1159
+ }
1160
+
1161
+ // Connect every ENABLED upstream BEFORE the loop starts so tools/list advertises the curated
1162
+ // surface from the first request. connectAll() NEVER throws (per-upstream failures land in
1163
+ // failed[]) and with the default EMPTY expose.json it is an instant no-op — so this never
1164
+ // delays or crashes startup. await-safe either way.
1165
+ if (build.aggregator && typeof build.aggregator.connectAll === 'function') {
1166
+ try {
1167
+ const res = await build.aggregator.connectAll();
1168
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
1169
+ logErr('aggregator.connectAll: ' + res.failed.length + ' upstream(s) failed:',
1170
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
1171
+ }
1172
+ } catch (err) {
1173
+ // connectAll is contracted not to throw; guard anyway so startup never dies on it.
1174
+ logErr('aggregator.connectAll threw (ignored):', (err && err.message) || String(err));
1175
+ }
1176
+ }
1177
+
1178
+ // Keep the process alive on the stdio loop; never let an unhandled rejection kill it.
1179
+ process.on('uncaughtException', (err) => logErr('uncaughtException:', (err && err.stack) || String(err)));
1180
+ process.on('unhandledRejection', (err) => logErr('unhandledRejection:', (err && (err.stack || err.message)) || String(err)));
1181
+
1182
+ const loop = createStdioLoop(build);
1183
+
1184
+ // Wire the aggregator's out-of-band tool-set-change signal (a background reconnect recovering — or
1185
+ // finally losing — an upstream) to notifications/tools/list_changed, so the client refreshes when a
1186
+ // dead upstream heals itself. Set on the live instance; reloadExpose re-wires the swapped-in one.
1187
+ if (build.aggregator) build.aggregator.onToolsChanged = () => emitToolsListChanged(loop.send);
1188
+
1189
+ // Hot-reload: pick up expose.json / hook changes written by the tf_* tools or the UI (a separate
1190
+ // process) with NO restart and NO user intervention — the missing primitive that previously made
1191
+ // a live MCP attach or a hook change need a bounce. Auto is the default; the meta-tools/UI button
1192
+ // are just optional manual levers on top of this.
1193
+ startConfigWatchers(build, loop.send);
1194
+
1195
+ logErr('ready —', SERVER_INFO.name, SERVER_INFO.version, '(pid', process.pid + ')');
1196
+ }
1197
+
1198
+ // Run only when invoked directly (so the module can also be required by tests). main() is async
1199
+ // (it awaits aggregator.connectAll); a startup rejection is logged to stderr, never thrown so the
1200
+ // process can't die unhandled before the loop is even up.
1201
+ if (require.main === module) {
1202
+ Promise.resolve()
1203
+ .then(main)
1204
+ .catch((err) => logErr('FATAL startup error:', (err && err.stack) || String(err)));
1205
+ }
1206
+
1207
+ module.exports = {
1208
+ // Exported for unit tests / reuse (the transport is thin; these are the seams).
1209
+ buildProtocol,
1210
+ makeRegistryAdapter,
1211
+ handleMessage,
1212
+ handleInitialize,
1213
+ // The stdio entry point — an external bin/ can require this module and call main() to start the
1214
+ // server (build → connect upstreams → run the stdio loop). main() is async and takes no args.
1215
+ main,
1216
+ // Phase-2 + MATRIX handlers: handleToolsList(protocol, aggregator, { registry?, toolStatePath? })
1217
+ // and handleToolsCall(protocol, aggregator, { engine, ctx, registry?, toolStatePath? }, params).
1218
+ handleToolsList,
1219
+ handleToolsCall,
1220
+ // Matrix helpers (exported for unit tests / reuse).
1221
+ localHotDefinitions,
1222
+ localToolDefinition,
1223
+ isPromotedLocal,
1224
+ isPromotedUpstream,
1225
+ // Phase-2: emit the hot-update notification (no-id notifications/tools/list_changed) via send.
1226
+ emitToolsListChanged,
1227
+ // Hot-reload: re-read on-disk config live (no restart). Wired to fs.watch in main().
1228
+ reloadExpose,
1229
+ reloadHooks,
1230
+ startConfigWatchers,
1231
+ // Helpers exported for tests / reuse.
1232
+ isMetaTool,
1233
+ runCuratedDirect,
1234
+ wrapProtocolResult,
1235
+ normaliseBuild,
1236
+ stringifyContent,
1237
+ firstString,
1238
+ PROTOCOL_VERSION,
1239
+ SERVER_INFO,
1240
+ };