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.
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/toolfunnel.js +112 -0
- package/docs/admin-ui.md +404 -0
- package/docs/configuration.md +402 -0
- package/docs/hooks-and-gating.md +428 -0
- package/docs/logging.md +223 -0
- package/docs/management.md +429 -0
- package/docs/oauth.md +103 -0
- package/docs/setup.md +352 -0
- package/docs/tools-and-upstreams.md +469 -0
- package/hooks/hooks.manifest.json +27 -0
- package/hooks/scripts/.gitkeep +3 -0
- package/hooks/scripts/example-audit.js +21 -0
- package/hooks/scripts/example-deny-dangerous.js +31 -0
- package/mcp/expose.example.json +30 -0
- package/mcp/servers/mock-upstream/server.js +170 -0
- package/package.json +63 -0
- package/src/auth/config.js +172 -0
- package/src/auth/install.js +181 -0
- package/src/auth/resource-server.js +319 -0
- package/src/core/events.js +105 -0
- package/src/core/hook-engine.js +275 -0
- package/src/core/hook-loader.js +791 -0
- package/src/core/hook-runner.js +346 -0
- package/src/core/logger.js +169 -0
- package/src/core/matcher.js +51 -0
- package/src/core/metrics.js +75 -0
- package/src/core/tool-registry.js +180 -0
- package/src/extend/add-hook.md +142 -0
- package/src/extend/add-mcp.md +121 -0
- package/src/extend/create-tool.md +132 -0
- package/src/extend/howto.js +98 -0
- package/src/extend/package.md +154 -0
- package/src/mcp/aggregator.js +802 -0
- package/src/mcp/expose-store.js +430 -0
- package/src/mcp/gated-run.js +194 -0
- package/src/mcp/http-transport.js +962 -0
- package/src/mcp/mcp-client.js +524 -0
- package/src/mcp/protocol.js +466 -0
- package/src/mcp/server.js +1240 -0
- package/src/packages/packager.js +713 -0
- package/src/tools/drift.js +138 -0
- package/src/tools/registry.js +468 -0
- package/src/tools/tool-state.js +104 -0
- package/src/ui/public/app.js +1055 -0
- package/src/ui/public/index.html +249 -0
- package/src/ui/public/logo.jpg +0 -0
- package/src/ui/public/styles.css +535 -0
- package/src/ui/server.js +1144 -0
- package/tools/scripts/base64.js +139 -0
- package/tools/scripts/danger.js +61 -0
- package/tools/scripts/echo.js +39 -0
- package/tools/scripts/hash.js +101 -0
- package/tools/scripts/json.js +109 -0
- package/tools/scripts/text-stats.js +131 -0
- package/tools/scripts/tf-hook-add.js +152 -0
- package/tools/scripts/tf-hook-set.js +113 -0
- package/tools/scripts/tf-list.js +195 -0
- package/tools/scripts/tf-log.js +83 -0
- package/tools/scripts/tf-mcp-add.js +119 -0
- package/tools/scripts/tf-mcp-set.js +104 -0
- package/tools/scripts/tf-tool-add.js +129 -0
- package/tools/scripts/tf-tool-set.js +148 -0
- package/tools/scripts/uuid.js +102 -0
- 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
|
+
};
|