toolfunnel 0.7.0 → 0.8.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 (73) hide show
  1. package/README.md +238 -235
  2. package/SECURITY.md +84 -84
  3. package/bin/toolfunnel.js +530 -451
  4. package/docs/KNOWN_BUGS.md +100 -80
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -84
  7. package/docs/packaging.md +116 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -39
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +379 -380
  14. package/package.json +67 -67
  15. package/src/auth/config.js +172 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +358 -353
  18. package/src/core/atomic-write.js +97 -0
  19. package/src/core/child-reaper.js +61 -61
  20. package/src/core/config-home.js +226 -134
  21. package/src/core/events.js +105 -105
  22. package/src/core/hook-engine.js +304 -329
  23. package/src/core/hook-loader.js +766 -824
  24. package/src/core/hook-runner.js +615 -566
  25. package/src/core/logger.js +295 -297
  26. package/src/core/matcher.js +100 -100
  27. package/src/core/metrics.js +90 -75
  28. package/src/core/output-cap.js +31 -0
  29. package/src/core/rename-migration.js +178 -163
  30. package/src/core/requires.js +138 -138
  31. package/src/core/server-config.js +98 -98
  32. package/src/extend/add-hook.md +155 -152
  33. package/src/extend/add-mcp.md +119 -119
  34. package/src/extend/configure.md +88 -88
  35. package/src/extend/create-tool.md +168 -168
  36. package/src/extend/howto.js +102 -102
  37. package/src/extend/package.md +190 -190
  38. package/src/extend/wrap.md +99 -99
  39. package/src/mcp/aggregator.js +2248 -1674
  40. package/src/mcp/expose-store.js +755 -577
  41. package/src/mcp/gated-run.js +210 -210
  42. package/src/mcp/http-transport.js +1657 -1563
  43. package/src/mcp/mcp-client.js +1174 -1070
  44. package/src/mcp/modern.js +409 -406
  45. package/src/mcp/protocol.js +473 -468
  46. package/src/mcp/server.js +3317 -3112
  47. package/src/mcp/tool-approvals.js +396 -0
  48. package/src/tools/registry.js +607 -661
  49. package/src/tools/tool-state.js +170 -170
  50. package/src/ui/public/app.js +1246 -1187
  51. package/src/ui/public/index.html +286 -286
  52. package/src/ui/public/styles.css +535 -535
  53. package/src/ui/server.js +1742 -1616
  54. package/tools/scripts/base64.js +139 -139
  55. package/tools/scripts/danger.js +61 -61
  56. package/tools/scripts/echo.js +39 -39
  57. package/tools/scripts/hash.js +101 -101
  58. package/tools/scripts/json.js +109 -109
  59. package/tools/scripts/text-stats.js +131 -131
  60. package/tools/scripts/tf-accept-change.js +67 -0
  61. package/tools/scripts/tf-env.js +43 -43
  62. package/tools/scripts/tf-hook-add.js +151 -151
  63. package/tools/scripts/tf-hook-set.js +120 -120
  64. package/tools/scripts/tf-list.js +192 -192
  65. package/tools/scripts/tf-log.js +86 -86
  66. package/tools/scripts/tf-mcp-add.js +130 -117
  67. package/tools/scripts/tf-mcp-set.js +121 -121
  68. package/tools/scripts/tf-pack.js +237 -237
  69. package/tools/scripts/tf-tool-add.js +134 -134
  70. package/tools/scripts/tf-tool-set.js +142 -142
  71. package/tools/scripts/tf-wrap.js +103 -103
  72. package/tools/scripts/uuid.js +102 -102
  73. package/tools/tools.register.json +16 -5
package/bin/toolfunnel.js CHANGED
@@ -1,451 +1,530 @@
1
- #!/usr/bin/env node
2
- 'use strict';
3
-
4
- /**
5
- * toolfunnel - CLI entry point.
6
- *
7
- * toolfunnel Start the stdio MCP server (default)
8
- * toolfunnel --http Start the HTTP/SSE MCP host on 127.0.0.1:9998
9
- * toolfunnel --http --port <n> Bind a specific port (0 = OS-assigned)
10
- * toolfunnel --http --host <h> Bind a specific loopback host
11
- * toolfunnel --ui Start the OPTIONAL config web UI on 127.0.0.1:9777
12
- * toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)
13
- * toolfunnel install-oauth Install the OPTIONAL OAuth 2.1 dependency (jose) on demand
14
- * toolfunnel --version Print the version
15
- * toolfunnel --help Show this help
16
- *
17
- * Zero runtime dependencies. The stdio path runs the server's main(); the HTTP
18
- * path constructs the HTTP/SSE host, which builds the same protocol internally.
19
- * OAuth is opt-in: it adds exactly one audited, itself-zero-dependency library
20
- * (jose), installed only when you run `install-oauth` or click Install in the UI.
21
- */
22
-
23
- const args = process.argv.slice(2);
24
-
25
- /**
26
- * Read a flag's value, accepting BOTH `--flag value` and `--flag=value` (0.7.0). Only the
27
- * space-separated form was handled, so `--config-dir=/my/home` was silently ignored and the config
28
- * home fell back to the package root - exactly the class of quiet misconfiguration the resolved-home
29
- * stderr line and the positional parsing below exist to prevent. The `=` form is what most people
30
- * type first, and getting it wrong wrote config somewhere they were not looking.
31
- */
32
- function flagValue(name, fallback) {
33
- // ONE left-to-right scan, LAST occurrence wins whichever form it uses. Scanning the = form
34
- // across all args before the space form made precedence depend on spelling, not position
35
- // (`--port 9 --port=8` and `--port=8 --port 9` both answered 8). A present-but-EMPTY `=`
36
- // value (`--config-dir=`) is an error, not a silent fallback - downstream it is
37
- // indistinguishable from "not given", which for --config-dir meant config quietly written to
38
- // the package root, the exact misconfiguration this parser exists to prevent.
39
- const eq = name + '=';
40
- let value = fallback;
41
- for (let i = 0; i < args.length; i++) {
42
- const a = args[i];
43
- if (typeof a !== 'string') continue;
44
- if (a.startsWith(eq)) {
45
- const v = a.slice(eq.length);
46
- if (v.length === 0) {
47
- process.stderr.write(`toolfunnel: ${name}= requires a value (write ${name}=<value> or ${name} <value>)\n`);
48
- process.exit(2);
49
- }
50
- value = v;
51
- } else if (a === name && i + 1 < args.length) {
52
- value = args[i + 1];
53
- }
54
- }
55
- return value;
56
- }
57
-
58
- // POSITIONAL arguments - the subcommand and its target - recognised regardless of where global
59
- // flags sit. `toolfunnel --config-dir X wrap Y` used to silently start a normal server: args[0]
60
- // was the flag, the wrap branch never matched, and the user got a running gateway instead of a
61
- // wrap with zero feedback (cold-start field test, 2026-07-18). Value-taking flags are skipped
62
- // WITH their value; any other -/-- token is skipped alone.
63
- const VALUE_FLAGS = new Set(['--config-dir', '--port', '--host', '--as']);
64
- const positionals = [];
65
- for (let i = 0; i < args.length; i++) {
66
- const a = args[i];
67
- // `--flag value` consumes the next token; `--flag=value` is self-contained and is skipped by the
68
- // generic leading-dash rule below, so it must NOT also eat the following token.
69
- if (VALUE_FLAGS.has(a)) { i++; continue; }
70
- if (typeof a === 'string' && a.length && a[0] === '-') continue;
71
- positionals.push(a);
72
- }
73
-
74
- if (args.includes('--version') || args.includes('-v')) {
75
- process.stdout.write(require('../package.json').version + '\n');
76
- process.exit(0);
77
- }
78
-
79
- if (args.includes('--help') || args.includes('-h')) {
80
- process.stdout.write(
81
- [
82
- 'toolfunnel - a zero-dependency MCP gateway',
83
- '',
84
- 'Usage:',
85
- ' toolfunnel Start the stdio MCP server (default)',
86
- ' toolfunnel --http Start the HTTP/SSE MCP host (127.0.0.1:9998)',
87
- ' toolfunnel --http --port <n> Bind a specific port (0 = OS-assigned)',
88
- ' toolfunnel --http --host <h> Bind a specific loopback host',
89
- ' toolfunnel --ui Start the config web UI (127.0.0.1:9777)',
90
- ' toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)',
91
- ' toolfunnel wrap <upstreamId> Wrap ONE attached upstream MCP: its tools become the whole',
92
- ' surface (meta-tools hidden), every call still gated. The',
93
- ' gateway answers both MCP eras, so a legacy-only MCP keeps',
94
- ' working with modern clients. `wrap --off` restores normal.',
95
- ' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
96
- ' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
97
- ' toolfunnel --version Print the version',
98
- ' toolfunnel --help Show this help',
99
- '',
100
- 'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
101
- 'the config home - by default the package root (a git clone works as before), or the value',
102
- 'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
103
- 'defaults on first use and is NEVER overwritten after - so an npm update cannot eat your',
104
- 'tools. This is also how a wrapped MCP ships its own bundled setup.',
105
- '',
106
- 'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
107
- ' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
108
- 'A --port flag always wins over the config value.',
109
- '',
110
- ].join('\n')
111
- );
112
- process.exit(0);
113
- }
114
-
115
- // Resolve + seed the config home FIRST - before any src/ module is required - so every
116
- // module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
117
- // same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
118
- // Whether the user EXPLICITLY placed the home must be captured BEFORE init: initConfigHome
119
- // exports the resolved home into process.env.TOOLFUNNEL_HOME for the child tools it spawns,
120
- // so reading the env afterwards is always-true and the hint branch was dead (0.7.0).
121
- const explicitHome = !!(flagValue('--config-dir', '') || process.env.TOOLFUNNEL_HOME);
122
- const { initConfigHome } = require('../src/core/config-home');
123
- const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
124
-
125
- // One honest stderr line so nobody discovers the config home by accident - before this, a bare
126
- // `git status` after running from a clone was the discovery mechanism. Defaulted homes get
127
- // the relocation hint.
128
- process.stderr.write('[toolfunnel] config home: ' + configHome +
129
- (explicitHome
130
- ? '\n'
131
- : ' (default: the package root - use --config-dir <dir> or TOOLFUNNEL_HOME to relocate)\n'));
132
-
133
- // Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, ...)
134
- // for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
135
- // problem is one loud stderr line and the gateway starts anyway. Never fatal, never throws.
136
- try {
137
- const { checkRequires } = require('../src/core/requires');
138
- for (const p of checkRequires(configHome)) {
139
- process.stderr.write('[toolfunnel] REQUIRES: ' + p.problem + '\n');
140
- }
141
- } catch (_e) { /* preflight must never stop the gateway */ }
142
-
143
- if (positionals[0] === 'wrap') {
144
- // PASSTHROUGH: one command turns the gateway into a transparent wrapper for ONE attached
145
- // upstream MCP - its tools are the whole advertised surface, the meta-tools are hidden and
146
- // uncallable, and every call still fires the PreToolUse gate. Because the gateway speaks both
147
- // MCP eras, this is how a legacy-only (or unmaintained) MCP keeps working with modern clients.
148
- const path = require('node:path');
149
- const { resolveConfigHome } = require('../src/core/config-home');
150
- const { loadExposeStore } = require('../src/mcp/expose-store');
151
- const { loadToolState, getPassthrough, setPassthrough } = require('../src/tools/tool-state');
152
- const home = resolveConfigHome();
153
- const statePath = path.join(home, 'tools', 'tools.state.json');
154
- const target = positionals[1]; // flag-position-independent, same as the subcommand itself
155
- const wantsOff = args.includes('--off');
156
-
157
- (async () => {
158
- if (wantsOff) {
159
- const before = getPassthrough(loadToolState(statePath));
160
- setPassthrough(statePath, null);
161
- process.stderr.write('[toolfunnel] wrap cleared' + (before ? ' (was "' + before + '")' : '') +
162
- ' - the normal funnel surface (meta-tools + matrix) is back. A running host picks this up live.\n');
163
- process.exit(0);
164
- }
165
-
166
- const store = loadExposeStore(path.join(home, 'mcp', 'expose.json'));
167
- const upstreams = store.listUpstreams();
168
-
169
- if (!target) {
170
- // Pure status query - always exit 0 (an empty setup is a state, not a failure).
171
- const current = getPassthrough(loadToolState(statePath));
172
- process.stderr.write('[toolfunnel] wrap: ' + (current ? 'wrapping "' + current + '"' : 'not active') + '\n');
173
- process.stderr.write('Usage: toolfunnel wrap <upstreamId> [--as <serverName>] | toolfunnel wrap --off\n');
174
- process.stderr.write(upstreams.length
175
- ? 'Attached upstreams: ' + upstreams.map((u) => u.id + (u.enabled === false ? ' (disabled)' : '')).join(', ') + '\n'
176
- : 'No upstreams attached yet - attach one first (toolfunnel_howto add-mcp, tf_mcp_add, or the UI).\n');
177
- process.exit(0);
178
- }
179
-
180
- const upstream = store.getUpstream(target);
181
- if (!upstream) {
182
- process.stderr.write('[toolfunnel] wrap: no upstream with id "' + target + '".\n');
183
- process.stderr.write(upstreams.length
184
- ? 'Attached upstreams: ' + upstreams.map((u) => u.id).join(', ') + '\n'
185
- : 'No upstreams attached yet - attach one first (toolfunnel_howto add-mcp, tf_mcp_add, or the UI).\n');
186
- process.exit(1);
187
- }
188
- if (upstream.enabled === false) {
189
- process.stderr.write('[toolfunnel] wrap: upstream "' + target + '" is DISABLED in mcp/expose.json - enable it first.\n');
190
- process.exit(1);
191
- }
192
-
193
- // Isolation notice - wrapping SUSPENDS the path-isolation guard for THIS upstream (and only
194
- // this upstream, and only while wrapped). A wrap is an explicit "this server is my entire
195
- // surface" declaration, so ToolFunnel acts as a transparent wrapper: outside paths (a
196
- // documents folder for a filesystem server, a database file, an absolute install path) are
197
- // permitted BY DESIGN. Funnel-mode upstreams keep the guard. The operator gets this warning
198
- // up front - informed consent, not a silent default (the old behaviour was worse: the guard
199
- // still applied, and `wrap` reported success while the gateway refused to connect).
200
- {
201
- // Enumerate EVERY class the wrap excuses, using the guard's own lists so the notice and the
202
- // guard can never classify differently. Args-only was the defect: the archetypal wrap target
203
- // (a filesystem server on a documents folder) HAS an outside arg so the notice fired there,
204
- // while a clean-args upstream carrying NODE_OPTIONS=--require and an outside cwd wrapped in
205
- // total silence - the one moment of informed consent, blind to the class that loads code.
206
- const { looksLikePath, isInside, isDriveRelative, CODE_OPT_ENV, CODE_PATH_ENV } = require('../src/mcp/aggregator');
207
- const findings = (Array.isArray(upstream.args) ? upstream.args : [])
208
- .filter((a) => looksLikePath(a) && !isInside(home, a))
209
- .map((a) => 'arg ' + a);
210
- if (typeof upstream.cwd === 'string' && upstream.cwd.length > 0
211
- && (isDriveRelative(upstream.cwd) || !isInside(home, upstream.cwd))) {
212
- findings.push('cwd ' + upstream.cwd + ' (bare args resolve against this)');
213
- }
214
- const envObj = upstream.env && typeof upstream.env === 'object' ? upstream.env : null;
215
- if (envObj) {
216
- for (const key in envObj) {
217
- const upper = String(key).toUpperCase();
218
- const isOpt = key === '__proto__' || CODE_OPT_ENV.includes(upper);
219
- const isPathVar = CODE_PATH_ENV.includes(upper);
220
- if (!isOpt && !isPathVar) continue;
221
- let raw;
222
- try { raw = String(envObj[key]); } catch (_e) { raw = '(unreadable)'; }
223
- // An option-string var loads code whatever its value; a path-list var only when a piece
224
- // of it points outside - the same rule the guard applies, so the two agree by construction.
225
- if (isPathVar && raw !== '(unreadable)') {
226
- const parts = (raw.match(/[A-Za-z]:[^;:]*|[^;:]+/g) || []).filter((t) => t.length > 0);
227
- if (!parts.some((t) => isDriveRelative(t) || !isInside(home, t))) continue;
228
- }
229
- findings.push('env ' + key + '=' + raw + ' (loads code into the child)');
230
- }
231
- }
232
- if (findings.length) {
233
- process.stderr.write('[toolfunnel] ⚠ WRAP SECURITY NOTICE - upstream "' + target + '" reaches outside the gateway root:\n' +
234
- findings.map((a) => ' ' + a).join('\n') + '\n' +
235
- ' While WRAPPED, the path-isolation guard is suspended for this upstream: ToolFunnel is a\n' +
236
- ' transparent wrapper here, and the wrapped server can reach whatever those paths reach.\n' +
237
- ' Every call still passes the PreToolUse gate. To restrict it AFTER wrapping: add a\n' +
238
- ' PreToolUse hook (block by tool/args), or disable individual tools (enabled:false, keyed\n' +
239
- ' by the surfaced name) - see the manual, "Wrapping & security". `wrap --off` restores the\n' +
240
- ' guard for this upstream.\n');
241
- }
242
- }
243
-
244
- setPassthrough(statePath, target);
245
- process.stderr.write('[toolfunnel] wrapping "' + target + '": its tools are now the ENTIRE surface, under ' +
246
- 'their ORIGINAL names - meta-tools hidden and uncallable, every call still passes the PreToolUse gate.\n');
247
-
248
- // ── Era probe ──────────────────────────────────────────────────────────────────────────────
249
- // The 2026-07-28 spec's dual-era client rule, in probe order: try MODERN first
250
- // (server/discover), and only on failure fall back to the legacy initialize - otherwise a
251
- // dual-era upstream would be misreported as legacy just because the legacy handshake works.
252
- // Separate child per stage: a badly behaved legacy server may react badly to an unknown
253
- // method, and the fallback must not inherit a soured child.
254
- if (upstream.transport === 'stdio') {
255
- const { defaultClientFactory } = require('../src/mcp/aggregator');
256
- // The probe SPAWNS this upstream, so it goes through the same factory - and therefore the
257
- // same isolation guard - as every other spawn. Building McpClient directly here meant the
258
- // one code path that runs an upstream's command before the operator has seen anything was
259
- // also the one path with no guard on args/cwd/env, while the docs claimed otherwise.
260
- // allowOutsidePaths is TRUE because this IS the wrap path: outside references are the
261
- // operator's explicit declaration, so the guard warns instead of refusing (same rule the
262
- // running gateway applies to the wrapped upstream).
263
- const probeUpstream = Object.assign({}, upstream, {
264
- id: target,
265
- // Default the probe's cwd to the CONFIG HOME, same as the aggregator's spawn: relative
266
- // arg paths are guarded against the home, so they must spawn against it too.
267
- cwd: upstream.cwd || home,
268
- // Honour a configured slow-boot window; the probe default stays tight.
269
- requestTimeoutMs: Number.isFinite(upstream.requestTimeoutMs) && upstream.requestTimeoutMs > 0
270
- ? upstream.requestTimeoutMs : 6000,
271
- // The probe decides the era ITSELF (server/discover, then a legacy fallback on a fresh
272
- // child). Neutralise the stored era policy so a pinned upstream still gets both stages -
273
- // otherwise the factory would force one era and the probe would report its own input.
274
- legacyPin: false,
275
- modernOnly: false,
276
- });
277
- const newProbeClient = () => defaultClientFactory(probeUpstream, home, () => {}, true, undefined);
278
-
279
- let reported = false;
280
- // Stage 1: modern probe (server/discover).
281
- try {
282
- const probe = newProbeClient();
283
- try {
284
- const disc = await probe.probeDiscover();
285
- const versions = disc && Array.isArray(disc.supportedVersions) ? disc.supportedVersions : [];
286
- const who = (disc && disc._meta && disc._meta['io.modelcontextprotocol/serverInfo'] &&
287
- disc._meta['io.modelcontextprotocol/serverInfo'].name) || target;
288
- const legacyCapable = versions.some((v) => typeof v === 'string' && v < '2026-07-28');
289
- if (legacyCapable) {
290
- process.stderr.write('[toolfunnel] era probe: "' + who + '" is DUAL-ERA (speaks ' + versions.join(', ') +
291
- ') - it already survives the 2026-07-28 cutover on its own; the wrap still curates + gates it.\n');
292
- } else {
293
- process.stderr.write('[toolfunnel] era probe: "' + who + '" is MODERN-ONLY (speaks ' + versions.join(', ') +
294
- '). ToolFunnel speaks the modern era to it (server/discover, per-request _meta) and re-presents ' +
295
- 'it to legacy AND modern clients alike.\n');
296
- }
297
- reported = true;
298
- } finally {
299
- await probe.close();
300
- }
301
- } catch (_err) {
302
- /* no modern answer - fall through to the legacy handshake on a FRESH child */
303
- }
304
-
305
- // Stage 2: legacy fallback (initialize) - the era every current MCP speaks.
306
- if (!reported) {
307
- try {
308
- const legacy = newProbeClient();
309
- try {
310
- const init = await legacy.connect();
311
- const v = init && typeof init.protocolVersion === 'string' ? init.protocolVersion : 'unknown';
312
- const who = init && init.serverInfo && init.serverInfo.name ? init.serverInfo.name : target;
313
- process.stderr.write('[toolfunnel] era probe: "' + who + '" speaks MCP ' + v + ' - a LEGACY-era MCP ' +
314
- '(no server/discover). Modern (2026-07-28) clients reach it through this gateway; on its own it ' +
315
- 'cannot talk to them.\n');
316
- } finally {
317
- await legacy.close();
318
- }
319
- } catch (err) {
320
- const m = (err && err.message) || String(err);
321
- process.stderr.write('[toolfunnel] era probe: could not connect to "' + target + '" (' + m + ') - ' +
322
- 'the wrap is set anyway; the surface appears when the upstream connects.\n');
323
- }
324
- }
325
- }
326
-
327
- // Dependency honesty: the wrap itself changes nothing about dependencies.
328
- process.stderr.write('[toolfunnel] dependencies: local use (stdio / loopback --http) stays ZERO-dependency. ' +
329
- 'Serving this wrapped MCP over the network needs OAuth enabled first (`toolfunnel install-oauth` - ' +
330
- 'adds the single audited dependency, jose).\n');
331
-
332
- // --as <name>: present AS the wrapped MCP in the handshake/discover (serverInfo). Identity is
333
- // read once at host start (deliberately not hot-swapped - a mid-session identity change would
334
- // lie to connected clients), so this takes effect on the NEXT start.
335
- const asName = flagValue('--as', '');
336
- if (asName) {
337
- const nodeFs = require('node:fs');
338
- const cfgPath = path.join(home, 'toolfunnel.json');
339
- let cfg = {};
340
- let existing = null;
341
- try { existing = nodeFs.readFileSync(cfgPath, 'utf8'); } catch (_e) { existing = null; } // missing = fine
342
- if (existing !== null) {
343
- // The file exists: parse it. On a parse error, ABORT rather than overwrite - blindly
344
- // rewriting would WIPE the operator's other fields (httpPort, serverVersion, requires...) to
345
- // recover only serverName. The wrap itself is already set;
346
- // only the identity rename is skipped.
347
- try {
348
- const parsed = JSON.parse(existing);
349
- cfg = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
350
- } catch (_e) {
351
- process.stderr.write('[toolfunnel] WARNING: toolfunnel.json is not valid JSON - NOT writing serverName ' +
352
- '(refusing to overwrite and lose your other settings). Fix the file, then re-run `wrap ' + target + ' --as ' + asName + '`.\n');
353
- cfg = null; // signal: skip the write
354
- }
355
- }
356
- if (cfg !== null) {
357
- cfg.serverName = asName;
358
- // Atomic (temp + rename) like every other config write - a crash mid-writeFileSync
359
- // would leave a truncated toolfunnel.json.
360
- require('../src/tools/registry').atomicWriteJson(cfgPath, cfg);
361
- process.stderr.write('[toolfunnel] serverName set to "' + asName + '" (toolfunnel.json) - the gateway ' +
362
- 'introduces itself as that in initialize/server-discover from the NEXT start.\n');
363
- }
364
- } else {
365
- process.stderr.write('[toolfunnel] tip: `wrap ' + target + ' --as <name>` also presents the gateway AS ' +
366
- 'that MCP in the handshake (serverInfo), from the next start.\n');
367
- }
368
- process.stderr.write('[toolfunnel] undo with: toolfunnel wrap --off (a running host picks the surface up live)\n');
369
- process.exit(0);
370
- })().catch((err) => {
371
- process.stderr.write('[toolfunnel] wrap failed: ' + ((err && err.message) || err) + '\n');
372
- process.exit(1);
373
- });
374
- } else if (positionals[0] === 'install-oauth') {
375
- // Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
376
- // at zero runtime dependencies - you only ever fetch jose if you decide you need OAuth.
377
- const { installJose } = require('../src/auth/install');
378
- const { JOSE_PIN } = require('../src/auth/resource-server');
379
- process.stderr.write('[toolfunnel] installing the optional OAuth dependency jose@' + JOSE_PIN + ' ...\n');
380
- installJose()
381
- .then((res) => {
382
- if (res.stdout) process.stdout.write(res.stdout);
383
- if (res.stderr) process.stderr.write(res.stderr);
384
- process.stderr.write('[toolfunnel] ' + res.message + '\n');
385
- if (res.ok) {
386
- process.stderr.write('[toolfunnel] OAuth is ready to enable - turn it on in the UI (Auth panel) or auth/auth.config.json.\n');
387
- }
388
- process.exit(res.ok ? 0 : 1);
389
- })
390
- .catch((err) => {
391
- process.stderr.write('[toolfunnel] install-oauth failed: ' + ((err && err.message) || err) + '\n');
392
- process.exit(1);
393
- });
394
- } else if (positionals.length) {
395
- // An unrecognised COMMAND word must refuse loudly - starting the default server instead left
396
- // a mistyped/misplaced subcommand running a gateway the user never asked for, with zero
397
- // feedback (cold-start field test, 2026-07-18).
398
- process.stderr.write('[toolfunnel] unknown command "' + positionals[0] + '" - nothing started. See toolfunnel --help.\n');
399
- process.exit(1);
400
- } else if (args.includes('--ui')) {
401
- const { createUiServer } = require('../src/ui/server');
402
- const { loadServerConfig } = require('../src/core/server-config');
403
- const { resolveConfigHome } = require('../src/core/config-home');
404
- const cfg = loadServerConfig(resolveConfigHome());
405
- const host = flagValue('--host', '127.0.0.1');
406
- // Precedence: --port flag > toolfunnel.json uiPort > 9777 (loadServerConfig folds in the default).
407
- const parsedPort = parseInt(flagValue('--port', String(cfg.uiPort)), 10);
408
- const port = Number.isInteger(parsedPort) ? parsedPort : cfg.uiPort;
409
- const server = createUiServer({ host, port });
410
- server
411
- .start()
412
- .then((info) => {
413
- process.stderr.write('[toolfunnel] config UI listening on ' + info.url + '\n');
414
- })
415
- .catch((err) => {
416
- process.stderr.write('[toolfunnel] failed to start config UI: ' + ((err && err.message) || err) + '\n');
417
- process.exit(1);
418
- });
419
- const shutdown = () => {
420
- server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
421
- };
422
- process.on('SIGINT', shutdown);
423
- process.on('SIGTERM', shutdown);
424
- } else if (args.includes('--http')) {
425
- const { createHttpMcpServer } = require('../src/mcp/http-transport');
426
- const { loadServerConfig } = require('../src/core/server-config');
427
- const { resolveConfigHome } = require('../src/core/config-home');
428
- const cfg = loadServerConfig(resolveConfigHome());
429
- const host = flagValue('--host', '127.0.0.1');
430
- // Precedence: --port flag > toolfunnel.json httpPort > 9998 (loadServerConfig folds in the default).
431
- const parsedPort = parseInt(flagValue('--port', String(cfg.httpPort)), 10);
432
- const port = Number.isInteger(parsedPort) ? parsedPort : cfg.httpPort;
433
- const server = createHttpMcpServer({ host, port });
434
- server
435
- .start()
436
- .then((info) => {
437
- process.stderr.write('[toolfunnel] HTTP MCP host listening on ' + info.url + '\n');
438
- })
439
- .catch((err) => {
440
- process.stderr.write('[toolfunnel] failed to start HTTP host: ' + ((err && err.message) || err) + '\n');
441
- process.exit(1);
442
- });
443
- const shutdown = () => {
444
- server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
445
- };
446
- process.on('SIGINT', shutdown);
447
- process.on('SIGTERM', shutdown);
448
- } else {
449
- // Default: stdio MCP server. main() builds the protocol, connects upstreams, runs the loop.
450
- require('../src/mcp/server').main();
451
- }
1
+ #!/usr/bin/env node
2
+ 'use strict';
3
+
4
+ /**
5
+ * toolfunnel - CLI entry point.
6
+ *
7
+ * toolfunnel Start the stdio MCP server (default)
8
+ * toolfunnel --http Start the HTTP/SSE MCP host on 127.0.0.1:9998
9
+ * toolfunnel --http --port <n> Bind a specific port (0 = OS-assigned)
10
+ * toolfunnel --http --host <h> Bind a specific loopback host
11
+ * toolfunnel --ui Start the OPTIONAL config web UI on 127.0.0.1:9777
12
+ * toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)
13
+ * toolfunnel install-oauth Install the OPTIONAL OAuth 2.1 dependency (jose) on demand
14
+ * toolfunnel --version Print the version
15
+ * toolfunnel --help Show this help
16
+ *
17
+ * Zero runtime dependencies. The stdio path runs the server's main(); the HTTP
18
+ * path constructs the HTTP/SSE host, which builds the same protocol internally.
19
+ * OAuth is opt-in: it adds exactly one audited, itself-zero-dependency library
20
+ * (jose), installed only when you run `install-oauth` or click Install in the UI.
21
+ */
22
+
23
+ const args = process.argv.slice(2);
24
+
25
+ /**
26
+ * Read a flag's value, accepting BOTH `--flag value` and `--flag=value`. The `=` form is what most
27
+ * people type first; ignoring it would silently fall back to the package root as the config home,
28
+ * writing config somewhere they were not looking.
29
+ */
30
+ function flagValue(name, fallback) {
31
+ // ONE left-to-right scan, LAST occurrence wins whichever form it uses. Scanning the = form
32
+ // across all args before the space form made precedence depend on spelling, not position
33
+ // (`--port 9 --port=8` and `--port=8 --port 9` both answered 8). A present-but-EMPTY `=`
34
+ // value (`--config-dir=`) is an error, not a silent fallback - downstream it is
35
+ // indistinguishable from "not given", which for --config-dir meant config quietly written to
36
+ // the package root, the exact misconfiguration this parser exists to prevent.
37
+ const eq = name + '=';
38
+ let value = fallback;
39
+ for (let i = 0; i < args.length; i++) {
40
+ const a = args[i];
41
+ if (typeof a !== 'string') continue;
42
+ if (a.startsWith(eq)) {
43
+ const v = a.slice(eq.length);
44
+ if (v.length === 0) {
45
+ process.stderr.write(`toolfunnel: ${name}= requires a value (write ${name}=<value> or ${name} <value>)\n`);
46
+ process.exit(2);
47
+ }
48
+ value = v;
49
+ } else if (a === name) {
50
+ // The space form needs a value too: a following FLAG, or nothing at all, is not one. Reading
51
+ // the next flag as the value made `--config-dir --port 0` a config home named "--port", and
52
+ // a trailing bare flag a silent fallback to the package root.
53
+ const v = i + 1 < args.length ? args[i + 1] : undefined;
54
+ if (typeof v !== 'string' || v.length === 0 || v[0] === '-') {
55
+ process.stderr.write(`toolfunnel: ${name} requires a value (write ${name}=<value> or ${name} <value>)\n`);
56
+ process.exit(2);
57
+ }
58
+ value = v;
59
+ }
60
+ }
61
+ return value;
62
+ }
63
+
64
+ // POSITIONAL arguments - the subcommand and its target - recognised regardless of where global
65
+ // flags sit, so `toolfunnel --config-dir X wrap Y` wraps Y instead of reading args[0] as the
66
+ // command. Value-taking flags are skipped WITH their value; any other -/-- token is skipped alone.
67
+ const VALUE_FLAGS = new Set(['--config-dir', '--port', '--host', '--as', '--hash']);
68
+ const positionals = [];
69
+ for (let i = 0; i < args.length; i++) {
70
+ const a = args[i];
71
+ // `--flag value` consumes the next token; `--flag=value` is self-contained and is skipped by the
72
+ // generic leading-dash rule below, so it must NOT also eat the following token.
73
+ if (VALUE_FLAGS.has(a)) { if (typeof args[i + 1] === 'string' && args[i + 1][0] !== '-') i++; continue; } // a following flag is not its value
74
+ if (typeof a === 'string' && a.length && a[0] === '-') continue;
75
+ positionals.push(a);
76
+ }
77
+
78
+ if (args.includes('--version') || args.includes('-v')) {
79
+ process.stdout.write(require('../package.json').version + '\n');
80
+ process.exit(0);
81
+ }
82
+
83
+ if (args.includes('--help') || args.includes('-h')) {
84
+ process.stdout.write(
85
+ [
86
+ 'toolfunnel - a zero-dependency MCP gateway',
87
+ '',
88
+ 'Usage:',
89
+ ' toolfunnel Start the stdio MCP server (default)',
90
+ ' toolfunnel --http Start the HTTP/SSE MCP host (127.0.0.1:9998)',
91
+ ' toolfunnel --http --port <n> Bind a specific port (0 = OS-assigned)',
92
+ ' toolfunnel --http --host <h> Bind a specific loopback host',
93
+ ' toolfunnel --ui Start the config web UI (127.0.0.1:9777)',
94
+ ' toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)',
95
+ ' toolfunnel wrap <upstreamId> Wrap ONE attached upstream MCP: its tools become the whole',
96
+ ' surface (meta-tools hidden), every call still gated. The',
97
+ ' gateway answers both MCP eras, so a legacy-only MCP keeps',
98
+ ' working with modern clients. `wrap --off` restores normal.',
99
+ ' toolfunnel changes List tool description changes held back for acceptance',
100
+ ' toolfunnel accept <upstream> [tool] [--hash <h>]',
101
+ ' Accept a held change (every one of that upstream without',
102
+ ' a tool). --hash: only if it is still the change you saw.',
103
+ ' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
104
+ ' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
105
+ ' toolfunnel --version Print the version',
106
+ ' toolfunnel --help Show this help',
107
+ '',
108
+ 'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
109
+ 'the config home - by default the package root (a git clone works as before), or the value',
110
+ 'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
111
+ 'defaults on first use and is NEVER overwritten after - so an npm update cannot eat your',
112
+ 'tools. (A tool new in a release is added to your register once; nothing else changes.)',
113
+ 'This is also how a wrapped MCP ships its own bundled setup.',
114
+ '',
115
+ 'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
116
+ ' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
117
+ 'A --port flag always wins over the config value.',
118
+ '',
119
+ ].join('\n')
120
+ );
121
+ process.exit(0);
122
+ }
123
+
124
+ // Resolve + seed the config home FIRST - before any src/ module is required - so every
125
+ // module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
126
+ // same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
127
+ // Whether the user EXPLICITLY placed the home must be captured BEFORE init: initConfigHome
128
+ // exports the resolved home into process.env.TOOLFUNNEL_HOME for the child tools it spawns,
129
+ // so reading the env afterwards would always be true.
130
+ const explicitHome = !!(flagValue('--config-dir', '') || process.env.TOOLFUNNEL_HOME);
131
+ const { initConfigHome } = require('../src/core/config-home');
132
+ const { home: configHome, added: addedTools } = initConfigHome({ dir: flagValue('--config-dir', '') });
133
+
134
+ // One stderr line so nobody discovers the config home by accident. Defaulted homes get the
135
+ // relocation hint.
136
+ process.stderr.write('[toolfunnel] config home: ' + configHome +
137
+ (explicitHome
138
+ ? '\n'
139
+ : ' (default: the package root - use --config-dir <dir> or TOOLFUNNEL_HOME to relocate)\n'));
140
+ if (addedTools.length) process.stderr.write('[toolfunnel] added tool(s) new in this release to the register: ' + addedTools.join(', ') + '\n');
141
+
142
+ // Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, ...)
143
+ // for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
144
+ // problem is one loud stderr line and the gateway starts anyway. Never fatal, never throws.
145
+ try {
146
+ const { checkRequires } = require('../src/core/requires');
147
+ for (const p of checkRequires(configHome)) {
148
+ process.stderr.write('[toolfunnel] REQUIRES: ' + p.problem + '\n');
149
+ }
150
+ } catch (_e) { /* preflight must never stop the gateway */ }
151
+
152
+ if (positionals[0] === 'wrap') {
153
+ // PASSTHROUGH: one command turns the gateway into a transparent wrapper for ONE attached
154
+ // upstream MCP - its tools are the whole advertised surface, the meta-tools are hidden and
155
+ // uncallable, and every call still fires the PreToolUse gate. Because the gateway speaks both
156
+ // MCP eras, this is how a legacy-only (or unmaintained) MCP keeps working with modern clients.
157
+ const path = require('node:path');
158
+ const { resolveConfigHome } = require('../src/core/config-home');
159
+ const { loadExposeStore } = require('../src/mcp/expose-store');
160
+ const { loadToolState, getPassthrough, setPassthrough } = require('../src/tools/tool-state');
161
+ const home = resolveConfigHome();
162
+ const statePath = path.join(home, 'tools', 'tools.state.json');
163
+ const target = positionals[1]; // flag-position-independent, same as the subcommand itself
164
+ const wantsOff = args.includes('--off');
165
+
166
+ (async () => {
167
+ if (wantsOff) {
168
+ const before = getPassthrough(loadToolState(statePath));
169
+ setPassthrough(statePath, null);
170
+ process.stderr.write('[toolfunnel] wrap cleared' + (before ? ' (was "' + before + '")' : '') +
171
+ ' - the normal funnel surface (meta-tools + matrix) is back. A running host picks this up live.\n');
172
+ process.exit(0);
173
+ }
174
+
175
+ const store = loadExposeStore(path.join(home, 'mcp', 'expose.json'));
176
+ const upstreams = store.listUpstreams();
177
+
178
+ if (!target) {
179
+ // Pure status query - always exit 0 (an empty setup is a state, not a failure).
180
+ const current = getPassthrough(loadToolState(statePath));
181
+ process.stderr.write('[toolfunnel] wrap: ' + (current ? 'wrapping "' + current + '"' : 'not active') + '\n');
182
+ process.stderr.write('Usage: toolfunnel wrap <upstreamId> [--as <serverName>] | toolfunnel wrap --off\n');
183
+ process.stderr.write(upstreams.length
184
+ ? 'Attached upstreams: ' + upstreams.map((u) => u.id + (u.enabled === false ? ' (disabled)' : '')).join(', ') + '\n'
185
+ : 'No upstreams attached yet - attach one first (toolfunnel_howto add-mcp, tf_mcp_add, or the UI).\n');
186
+ process.exit(0);
187
+ }
188
+
189
+ const upstream = store.getUpstream(target);
190
+ if (!upstream) {
191
+ process.stderr.write('[toolfunnel] wrap: no upstream with id "' + target + '".\n');
192
+ process.stderr.write(upstreams.length
193
+ ? 'Attached upstreams: ' + upstreams.map((u) => u.id).join(', ') + '\n'
194
+ : 'No upstreams attached yet - attach one first (toolfunnel_howto add-mcp, tf_mcp_add, or the UI).\n');
195
+ process.exit(1);
196
+ }
197
+ if (upstream.enabled === false) {
198
+ process.stderr.write('[toolfunnel] wrap: upstream "' + target + '" is DISABLED in mcp/expose.json - enable it first.\n');
199
+ process.exit(1);
200
+ }
201
+
202
+ // Isolation notice - wrapping SUSPENDS the path-isolation guard for THIS upstream (and only
203
+ // this upstream, and only while wrapped). A wrap is an explicit "this server is my entire
204
+ // surface" declaration, so ToolFunnel acts as a transparent wrapper: outside paths (a
205
+ // documents folder for a filesystem server, a database file, an absolute install path) are
206
+ // permitted BY DESIGN. Funnel-mode upstreams keep the guard. The operator gets this warning
207
+ // up front - informed consent, not a silent default (the old behaviour was worse: the guard
208
+ // still applied, and `wrap` reported success while the gateway refused to connect).
209
+ {
210
+ // Enumerate EVERY class the wrap excuses, using the guard's own lists so the notice and the
211
+ // guard can never classify differently. Args-only was the defect: the archetypal wrap target
212
+ // (a filesystem server on a documents folder) HAS an outside arg so the notice fired there,
213
+ // while a clean-args upstream carrying NODE_OPTIONS=--require and an outside cwd wrapped in
214
+ // total silence - the one moment of informed consent, blind to the class that loads code.
215
+ const { looksLikePath, isInside, isDriveRelative, CODE_OPT_ENV, CODE_PATH_ENV } = require('../src/mcp/aggregator');
216
+ const findings = (Array.isArray(upstream.args) ? upstream.args : [])
217
+ .filter((a) => looksLikePath(a) && !isInside(home, a))
218
+ .map((a) => 'arg ' + a);
219
+ if (typeof upstream.cwd === 'string' && upstream.cwd.length > 0
220
+ && (isDriveRelative(upstream.cwd) || !isInside(home, upstream.cwd))) {
221
+ findings.push('cwd ' + upstream.cwd + ' (bare args resolve against this)');
222
+ }
223
+ const envObj = upstream.env && typeof upstream.env === 'object' ? upstream.env : null;
224
+ if (envObj) {
225
+ for (const key in envObj) {
226
+ const upper = String(key).toUpperCase();
227
+ const isOpt = key === '__proto__' || CODE_OPT_ENV.includes(upper);
228
+ const isPathVar = CODE_PATH_ENV.includes(upper);
229
+ if (!isOpt && !isPathVar) continue;
230
+ let raw;
231
+ try { raw = String(envObj[key]); } catch (_e) { raw = '(unreadable)'; }
232
+ // An option-string var loads code whatever its value; a path-list var only when a piece
233
+ // of it points outside - the same rule the guard applies, so the two agree by construction.
234
+ if (isPathVar && raw !== '(unreadable)') {
235
+ const parts = (raw.match(/[A-Za-z]:[^;:]*|[^;:]+/g) || []).filter((t) => t.length > 0);
236
+ if (!parts.some((t) => isDriveRelative(t) || !isInside(home, t))) continue;
237
+ }
238
+ findings.push('env ' + key + '=' + raw + ' (loads code into the child)');
239
+ }
240
+ }
241
+ if (findings.length) {
242
+ process.stderr.write('[toolfunnel] ⚠ WRAP SECURITY NOTICE - upstream "' + target + '" reaches outside the gateway root:\n' +
243
+ findings.map((a) => ' ' + a).join('\n') + '\n' +
244
+ ' While WRAPPED, the path-isolation guard is suspended for this upstream: ToolFunnel is a\n' +
245
+ ' transparent wrapper here, and the wrapped server can reach whatever those paths reach.\n' +
246
+ ' Every call still passes the PreToolUse gate. To restrict it AFTER wrapping: add a\n' +
247
+ ' PreToolUse hook (block by tool/args), or disable individual tools (enabled:false, keyed\n' +
248
+ ' by the surfaced name) - see the manual, "Wrapping & security". `wrap --off` restores the\n' +
249
+ ' guard for this upstream.\n');
250
+ }
251
+ }
252
+
253
+ setPassthrough(statePath, target);
254
+ process.stderr.write('[toolfunnel] wrapping "' + target + '": its tools are now the ENTIRE surface, under ' +
255
+ 'their ORIGINAL names - meta-tools hidden and uncallable, every call still passes the PreToolUse gate.\n');
256
+
257
+ // ── Era probe ──────────────────────────────────────────────────────────────────────────────
258
+ // The 2026-07-28 spec's dual-era client rule, in probe order: try MODERN first
259
+ // (server/discover), and only on failure fall back to the legacy initialize - otherwise a
260
+ // dual-era upstream would be misreported as legacy just because the legacy handshake works.
261
+ // Separate child per stage: a badly behaved legacy server may react badly to an unknown
262
+ // method, and the fallback must not inherit a soured child.
263
+ if (upstream.transport === 'stdio') {
264
+ const { defaultClientFactory } = require('../src/mcp/aggregator');
265
+ // The probe SPAWNS this upstream, so it goes through the same factory - and therefore the
266
+ // same isolation guard - as every other spawn. Building McpClient directly here meant the
267
+ // one code path that runs an upstream's command before the operator has seen anything was
268
+ // also the one path with no guard on args/cwd/env, while the docs claimed otherwise.
269
+ // allowOutsidePaths is TRUE because this IS the wrap path: outside references are the
270
+ // operator's explicit declaration, so the guard warns instead of refusing (same rule the
271
+ // running gateway applies to the wrapped upstream).
272
+ const probeUpstream = Object.assign({}, upstream, {
273
+ id: target,
274
+ // Default the probe's cwd to the CONFIG HOME, same as the aggregator's spawn: relative
275
+ // arg paths are guarded against the home, so they must spawn against it too.
276
+ cwd: upstream.cwd || home,
277
+ // Honour a configured slow-boot window; the probe default stays tight.
278
+ requestTimeoutMs: Number.isFinite(upstream.requestTimeoutMs) && upstream.requestTimeoutMs > 0
279
+ ? upstream.requestTimeoutMs : 6000,
280
+ // The probe decides the era ITSELF (server/discover, then a legacy fallback on a fresh
281
+ // child). Neutralise the stored era policy so a pinned upstream still gets both stages -
282
+ // otherwise the factory would force one era and the probe would report its own input.
283
+ legacyPin: false,
284
+ modernOnly: false,
285
+ });
286
+ const newProbeClient = () => defaultClientFactory(probeUpstream, home, () => {}, true, undefined);
287
+
288
+ let reported = false;
289
+ // Stage 1: modern probe (server/discover).
290
+ try {
291
+ const probe = newProbeClient();
292
+ try {
293
+ const disc = await probe.probeDiscover();
294
+ const versions = disc && Array.isArray(disc.supportedVersions) ? disc.supportedVersions : [];
295
+ const who = (disc && disc._meta && disc._meta['io.modelcontextprotocol/serverInfo'] &&
296
+ disc._meta['io.modelcontextprotocol/serverInfo'].name) || target;
297
+ const { MODERN_PROTOCOL_VERSION } = require('../src/mcp/modern');
298
+ const legacyCapable = versions.some((v) => typeof v === 'string' && v < MODERN_PROTOCOL_VERSION);
299
+ if (versions.length === 0) {
300
+ // An answered discover with NO version list is neither dual-era nor modern-only: say so
301
+ // instead of reporting "MODERN-ONLY (speaks )".
302
+ process.stderr.write('[toolfunnel] era probe: "' + who + '" answered server/discover without a supportedVersions list - ' +
303
+ 'treating it as MODERN (it answered the modern probe); check the server if that is unexpected.\n');
304
+ } else if (legacyCapable) {
305
+ process.stderr.write('[toolfunnel] era probe: "' + who + '" is DUAL-ERA (speaks ' + versions.join(', ') +
306
+ ') - it already survives the 2026-07-28 cutover on its own; the wrap still curates + gates it.\n');
307
+ } else {
308
+ process.stderr.write('[toolfunnel] era probe: "' + who + '" is MODERN-ONLY (speaks ' + versions.join(', ') +
309
+ '). ToolFunnel speaks the modern era to it (server/discover, per-request _meta) and re-presents ' +
310
+ 'it to legacy AND modern clients alike.\n');
311
+ }
312
+ reported = true;
313
+ } finally {
314
+ await probe.close();
315
+ }
316
+ } catch (_err) {
317
+ /* no modern answer - fall through to the legacy handshake on a FRESH child */
318
+ }
319
+
320
+ // Stage 2: legacy fallback (initialize) - the era every current MCP speaks.
321
+ if (!reported) {
322
+ try {
323
+ const legacy = newProbeClient();
324
+ try {
325
+ const init = await legacy.connect();
326
+ const v = init && typeof init.protocolVersion === 'string' ? init.protocolVersion : 'unknown';
327
+ const who = init && init.serverInfo && init.serverInfo.name ? init.serverInfo.name : target;
328
+ process.stderr.write('[toolfunnel] era probe: "' + who + '" speaks MCP ' + v + ' - a LEGACY-era MCP ' +
329
+ '(no server/discover). Modern (2026-07-28) clients reach it through this gateway; on its own it ' +
330
+ 'cannot talk to them.\n');
331
+ } finally {
332
+ await legacy.close();
333
+ }
334
+ } catch (err) {
335
+ const m = (err && err.message) || String(err);
336
+ process.stderr.write('[toolfunnel] era probe: could not connect to "' + target + '" (' + m + ') - ' +
337
+ 'the wrap is set anyway; the surface appears when the upstream connects.\n');
338
+ }
339
+ }
340
+ }
341
+
342
+ // Dependency honesty: the wrap itself changes nothing about dependencies.
343
+ process.stderr.write('[toolfunnel] dependencies: local use (stdio / loopback --http) stays ZERO-dependency. ' +
344
+ 'Serving this wrapped MCP over the network needs OAuth enabled first (`toolfunnel install-oauth` - ' +
345
+ 'adds the single audited dependency, jose).\n');
346
+
347
+ // --as <name>: present AS the wrapped MCP in the handshake/discover (serverInfo). Identity is
348
+ // read once at host start (deliberately not hot-swapped - a mid-session identity change would
349
+ // lie to connected clients), so this takes effect on the NEXT start.
350
+ const asName = flagValue('--as', '');
351
+ if (asName) {
352
+ const nodeFs = require('node:fs');
353
+ const cfgPath = path.join(home, 'toolfunnel.json');
354
+ let cfg = {};
355
+ let existing = null;
356
+ try { existing = nodeFs.readFileSync(cfgPath, 'utf8'); } catch (_e) { existing = null; } // missing = fine
357
+ if (existing !== null) {
358
+ // The file exists: parse it. On a parse error, ABORT rather than overwrite - blindly
359
+ // rewriting would WIPE the operator's other fields (httpPort, serverVersion, requires...) to
360
+ // recover only serverName. The wrap itself is already set;
361
+ // only the identity rename is skipped.
362
+ try {
363
+ const parsed = JSON.parse(existing);
364
+ cfg = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
365
+ } catch (_e) {
366
+ process.stderr.write('[toolfunnel] WARNING: toolfunnel.json is not valid JSON - NOT writing serverName ' +
367
+ '(refusing to overwrite and lose your other settings). Fix the file, then re-run `wrap ' + target + ' --as ' + asName + '`.\n');
368
+ cfg = null; // signal: skip the write
369
+ }
370
+ }
371
+ if (cfg !== null) {
372
+ cfg.serverName = asName;
373
+ // Atomic (temp + rename) like every other config write - a crash mid-writeFileSync
374
+ // would leave a truncated toolfunnel.json.
375
+ require('../src/tools/registry').atomicWriteJson(cfgPath, cfg);
376
+ process.stderr.write('[toolfunnel] serverName set to "' + asName + '" (toolfunnel.json) - the gateway ' +
377
+ 'introduces itself as that in initialize/server-discover from the NEXT start.\n');
378
+ }
379
+ } else {
380
+ process.stderr.write('[toolfunnel] tip: `wrap ' + target + ' --as <name>` also presents the gateway AS ' +
381
+ 'that MCP in the handshake (serverInfo), from the next start.\n');
382
+ }
383
+ process.stderr.write('[toolfunnel] undo with: toolfunnel wrap --off (a running host picks the surface up live)\n');
384
+ process.exit(0);
385
+ })().catch((err) => {
386
+ process.stderr.write('[toolfunnel] wrap failed: ' + ((err && err.message) || err) + '\n');
387
+ process.exit(1);
388
+ });
389
+ } else if (positionals[0] === 'changes') {
390
+ // Tool descriptor approval: what waits for acceptance, straight from the approvals file
391
+ // (the gateway writes pending entries there), plus recent upgrade approvals. Read-only.
392
+ const path = require('node:path');
393
+ const approvals = require('../src/mcp/tool-approvals');
394
+ const file = path.join(configHome, 'mcp', 'tool-approvals.json');
395
+ let doc;
396
+ try {
397
+ doc = approvals.loadApprovals(file);
398
+ } catch (err) {
399
+ process.stderr.write('[toolfunnel] ' + ((err && err.message) || err) + '\n');
400
+ process.exit(1);
401
+ }
402
+ const arg = (s) => (/^[A-Za-z0-9_.:@/-]+$/.test(s) ? s : JSON.stringify(s));
403
+ const lines = [];
404
+ for (const p of approvals.listPending(doc)) {
405
+ lines.push(` ${p.upstream} / ${p.tool} (${p.reason}, waiting since ${p.since || 'unknown'})`);
406
+ lines.push(` approved: ${p.approvedDescription == null ? '(none - a new tool)' : (p.approvedDescription || '(no description)')}`);
407
+ lines.push(` new: ${p.newDescription || '(no description)'}`);
408
+ lines.push(` changed: ${p.changed.join(', ')}`);
409
+ for (const [f, v] of Object.entries(p.fields)) {
410
+ lines.push(` ${f} approved: ${JSON.stringify(v.approved)}`);
411
+ lines.push(` ${f} new: ${JSON.stringify(v.new)}`);
412
+ }
413
+ lines.push(` accept: toolfunnel accept ${arg(p.upstream)} ${arg(p.tool)} --hash ${p.hash}`);
414
+ }
415
+ const upgrades = approvals.listUpgrades(doc, 10);
416
+ if (lines.length) {
417
+ process.stdout.write('Tool changes waiting for acceptance (the server version did not change, or strictApproval is on):\n' + lines.map(approvals.printable).join('\n') + '\n');
418
+ } else {
419
+ process.stdout.write('No tool changes are waiting.\n');
420
+ }
421
+ if (upgrades.length) {
422
+ process.stdout.write('Approved automatically on a server version upgrade (latest first):\n' +
423
+ upgrades.map((u) => approvals.printable(` ${u.upstream} / ${u.tool} (${u.at || 'unknown'})`)).join('\n') + '\n');
424
+ }
425
+ process.exit(0);
426
+ } else if (positionals[0] === 'accept') {
427
+ // Accept a held tool change through the same engine function every route uses (acceptInFile).
428
+ // A running gateway applies it from the next call.
429
+ const path = require('node:path');
430
+ const approvals = require('../src/mcp/tool-approvals');
431
+ const upstream = positionals[1];
432
+ const tool = positionals[2];
433
+ if (!upstream) {
434
+ process.stderr.write('Usage: toolfunnel accept <upstream> [tool] [--hash <sha256:...>]\n');
435
+ process.exit(1);
436
+ }
437
+ const hash = flagValue('--hash', undefined);
438
+ let res;
439
+ try {
440
+ res = approvals.acceptInFile(path.join(configHome, 'mcp', 'tool-approvals.json'), upstream, tool, hash);
441
+ } catch (err) {
442
+ process.stderr.write('[toolfunnel] ' + ((err && err.message) || err) + '\n');
443
+ process.exit(1);
444
+ }
445
+ const why = approvals.acceptFailure(res, upstream, tool);
446
+ if (why) {
447
+ process.stderr.write(approvals.printable('[toolfunnel] ' + why + (res.stale ? '. Run `toolfunnel changes` to see the current one.' : '')) + '\n');
448
+ process.exit(1);
449
+ }
450
+ process.stdout.write(approvals.printable('Accepted: ' + res.accepted.join(', ') + '.' +
451
+ (res.pending.length ? ' Still waiting: ' + res.pending.join(', ') + '.' : '') +
452
+ ' A running gateway applies this from the next call.') + '\n');
453
+ process.exit(0);
454
+ } else if (positionals[0] === 'install-oauth') {
455
+ // Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
456
+ // at zero runtime dependencies - you only ever fetch jose if you decide you need OAuth.
457
+ const { installJose } = require('../src/auth/install');
458
+ const { JOSE_PIN } = require('../src/auth/resource-server');
459
+ process.stderr.write('[toolfunnel] installing the optional OAuth dependency jose@' + JOSE_PIN + ' ...\n');
460
+ installJose()
461
+ .then((res) => {
462
+ if (res.stdout) process.stdout.write(res.stdout);
463
+ if (res.stderr) process.stderr.write(res.stderr);
464
+ process.stderr.write('[toolfunnel] ' + res.message + '\n');
465
+ if (res.ok) {
466
+ process.stderr.write('[toolfunnel] OAuth is ready to enable - turn it on in the UI (Auth panel) or auth/auth.config.json.\n');
467
+ }
468
+ process.exit(res.ok ? 0 : 1);
469
+ })
470
+ .catch((err) => {
471
+ process.stderr.write('[toolfunnel] install-oauth failed: ' + ((err && err.message) || err) + '\n');
472
+ process.exit(1);
473
+ });
474
+ } else if (positionals.length) {
475
+ // An unrecognised COMMAND word must refuse loudly - starting the default server instead would
476
+ // leave a mistyped/misplaced subcommand running a gateway the user never asked for.
477
+ process.stderr.write('[toolfunnel] unknown command "' + positionals[0] + '" - nothing started. See toolfunnel --help.\n');
478
+ process.exit(1);
479
+ } else if (args.includes('--ui')) {
480
+ const { createUiServer } = require('../src/ui/server');
481
+ const { loadServerConfig } = require('../src/core/server-config');
482
+ const { resolveConfigHome } = require('../src/core/config-home');
483
+ const cfg = loadServerConfig(resolveConfigHome());
484
+ const host = flagValue('--host', '127.0.0.1');
485
+ // Precedence: --port flag > toolfunnel.json uiPort > 9777 (loadServerConfig folds in the default).
486
+ const parsedPort = parseInt(flagValue('--port', String(cfg.uiPort)), 10);
487
+ const port = Number.isInteger(parsedPort) ? parsedPort : cfg.uiPort;
488
+ const server = createUiServer({ host, port });
489
+ server
490
+ .start()
491
+ .then((info) => {
492
+ process.stderr.write('[toolfunnel] config UI listening on ' + info.url + '\n');
493
+ })
494
+ .catch((err) => {
495
+ process.stderr.write('[toolfunnel] failed to start config UI: ' + ((err && err.message) || err) + '\n');
496
+ process.exit(1);
497
+ });
498
+ const shutdown = () => {
499
+ server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
500
+ };
501
+ process.on('SIGINT', shutdown);
502
+ process.on('SIGTERM', shutdown);
503
+ } else if (args.includes('--http')) {
504
+ const { createHttpMcpServer } = require('../src/mcp/http-transport');
505
+ const { loadServerConfig } = require('../src/core/server-config');
506
+ const { resolveConfigHome } = require('../src/core/config-home');
507
+ const cfg = loadServerConfig(resolveConfigHome());
508
+ const host = flagValue('--host', '127.0.0.1');
509
+ // Precedence: --port flag > toolfunnel.json httpPort > 9998 (loadServerConfig folds in the default).
510
+ const parsedPort = parseInt(flagValue('--port', String(cfg.httpPort)), 10);
511
+ const port = Number.isInteger(parsedPort) ? parsedPort : cfg.httpPort;
512
+ const server = createHttpMcpServer({ host, port });
513
+ server
514
+ .start()
515
+ .then((info) => {
516
+ process.stderr.write('[toolfunnel] HTTP MCP host listening on ' + info.url + '\n');
517
+ })
518
+ .catch((err) => {
519
+ process.stderr.write('[toolfunnel] failed to start HTTP host: ' + ((err && err.message) || err) + '\n');
520
+ process.exit(1);
521
+ });
522
+ const shutdown = () => {
523
+ server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
524
+ };
525
+ process.on('SIGINT', shutdown);
526
+ process.on('SIGTERM', shutdown);
527
+ } else {
528
+ // Default: stdio MCP server. main() builds the protocol, connects upstreams, runs the loop.
529
+ require('../src/mcp/server').main();
530
+ }