toolfunnel 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/README.md +228 -231
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -372
  4. package/docs/KNOWN_BUGS.md +80 -99
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -70
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -147
  13. package/mcp/servers/mock-upstream/server.js +380 -350
  14. package/package.json +3 -1
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -98
  30. package/src/extend/add-hook.md +152 -152
  31. package/src/extend/add-mcp.md +119 -122
  32. package/src/extend/configure.md +88 -87
  33. package/src/extend/create-tool.md +168 -168
  34. package/src/extend/howto.js +102 -102
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -99
  37. package/src/mcp/aggregator.js +1719 -1246
  38. package/src/mcp/expose-store.js +577 -460
  39. package/src/mcp/gated-run.js +210 -201
  40. package/src/mcp/http-transport.js +1563 -1311
  41. package/src/mcp/mcp-client.js +1070 -907
  42. package/src/mcp/modern.js +406 -372
  43. package/src/mcp/protocol.js +468 -468
  44. package/src/mcp/server.js +3112 -2688
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -170
  47. package/src/ui/public/app.js +1187 -1174
  48. package/src/ui/public/index.html +286 -286
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1348
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -121
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -134
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -103
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -227
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
package/bin/toolfunnel.js CHANGED
@@ -1,372 +1,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 --help Show this help
15
- *
16
- * Zero runtime dependencies. The stdio path runs the server's main(); the HTTP
17
- * path constructs the HTTP/SSE host, which builds the same protocol internally.
18
- * OAuth is opt-in: it adds exactly one audited, itself-zero-dependency library
19
- * (jose), installed only when you run `install-oauth` or click Install in the UI.
20
- */
21
-
22
- const args = process.argv.slice(2);
23
-
24
- function flagValue(name, fallback) {
25
- const i = args.indexOf(name);
26
- return i !== -1 && i + 1 < args.length ? args[i + 1] : fallback;
27
- }
28
-
29
- // POSITIONAL arguments - the subcommand and its target - recognised regardless of where global
30
- // flags sit. `toolfunnel --config-dir X wrap Y` used to silently start a normal server: args[0]
31
- // was the flag, the wrap branch never matched, and the user got a running gateway instead of a
32
- // wrap with zero feedback (cold-start field test, 2026-07-18). Value-taking flags are skipped
33
- // WITH their value; any other -/-- token is skipped alone.
34
- const VALUE_FLAGS = new Set(['--config-dir', '--port', '--host', '--as']);
35
- const positionals = [];
36
- for (let i = 0; i < args.length; i++) {
37
- const a = args[i];
38
- if (VALUE_FLAGS.has(a)) { i++; continue; }
39
- if (typeof a === 'string' && a.length && a[0] === '-') continue;
40
- positionals.push(a);
41
- }
42
-
43
- if (args.includes('--help') || args.includes('-h')) {
44
- process.stdout.write(
45
- [
46
- 'toolfunnel - a zero-dependency MCP gateway',
47
- '',
48
- 'Usage:',
49
- ' toolfunnel Start the stdio MCP server (default)',
50
- ' toolfunnel --http Start the HTTP/SSE MCP host (127.0.0.1:9998)',
51
- ' toolfunnel --http --port <n> Bind a specific port (0 = OS-assigned)',
52
- ' toolfunnel --http --host <h> Bind a specific loopback host',
53
- ' toolfunnel --ui Start the config web UI (127.0.0.1:9777)',
54
- ' toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)',
55
- ' toolfunnel wrap <upstreamId> Wrap ONE attached upstream MCP: its tools become the whole',
56
- ' surface (meta-tools hidden), every call still gated. The',
57
- ' gateway answers both MCP eras, so a legacy-only MCP keeps',
58
- ' working with modern clients. `wrap --off` restores normal.',
59
- ' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
60
- ' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
61
- ' toolfunnel --help Show this help',
62
- '',
63
- 'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
64
- 'the config home - by default the package root (a git clone works as before), or the value',
65
- 'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
66
- 'defaults on first use and is NEVER overwritten after - so an npm update cannot eat your',
67
- 'tools. This is also how a wrapped MCP ships its own bundled setup.',
68
- '',
69
- 'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
70
- ' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
71
- 'A --port flag always wins over the config value.',
72
- '',
73
- ].join('\n')
74
- );
75
- process.exit(0);
76
- }
77
-
78
- // Resolve + seed the config home FIRST - before any src/ module is required - so every
79
- // module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
80
- // same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
81
- const { initConfigHome } = require('../src/core/config-home');
82
- const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
83
-
84
- // One honest stderr line so nobody discovers the config home by accident - before this, a bare
85
- // `git status` after running from a clone was the discovery mechanism. Defaulted homes get
86
- // the relocation hint.
87
- process.stderr.write('[toolfunnel] config home: ' + configHome +
88
- ((flagValue('--config-dir', '') || process.env.TOOLFUNNEL_HOME)
89
- ? '\n'
90
- : ' (default: the package root - use --config-dir <dir> or TOOLFUNNEL_HOME to relocate)\n'));
91
-
92
- // Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, ...)
93
- // for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
94
- // problem is one loud stderr line and the gateway starts anyway. Never fatal, never throws.
95
- try {
96
- const { checkRequires } = require('../src/core/requires');
97
- for (const p of checkRequires(configHome)) {
98
- process.stderr.write('[toolfunnel] REQUIRES: ' + p.problem + '\n');
99
- }
100
- } catch (_e) { /* preflight must never stop the gateway */ }
101
-
102
- if (positionals[0] === 'wrap') {
103
- // PASSTHROUGH: one command turns the gateway into a transparent wrapper for ONE attached
104
- // upstream MCP - its tools are the whole advertised surface, the meta-tools are hidden and
105
- // uncallable, and every call still fires the PreToolUse gate. Because the gateway speaks both
106
- // MCP eras, this is how a legacy-only (or unmaintained) MCP keeps working with modern clients.
107
- const path = require('node:path');
108
- const { resolveConfigHome } = require('../src/core/config-home');
109
- const { loadExposeStore } = require('../src/mcp/expose-store');
110
- const { loadToolState, getPassthrough, setPassthrough } = require('../src/tools/tool-state');
111
- const home = resolveConfigHome();
112
- const statePath = path.join(home, 'tools', 'tools.state.json');
113
- const target = positionals[1]; // flag-position-independent, same as the subcommand itself
114
- const wantsOff = args.includes('--off');
115
-
116
- (async () => {
117
- if (wantsOff) {
118
- const before = getPassthrough(loadToolState(statePath));
119
- setPassthrough(statePath, null);
120
- process.stderr.write('[toolfunnel] wrap cleared' + (before ? ' (was "' + before + '")' : '') +
121
- ' - the normal funnel surface (meta-tools + matrix) is back. A running host picks this up live.\n');
122
- process.exit(0);
123
- }
124
-
125
- const store = loadExposeStore(path.join(home, 'mcp', 'expose.json'));
126
- const upstreams = store.listUpstreams();
127
-
128
- if (!target) {
129
- // Pure status query - always exit 0 (an empty setup is a state, not a failure).
130
- const current = getPassthrough(loadToolState(statePath));
131
- process.stderr.write('[toolfunnel] wrap: ' + (current ? 'wrapping "' + current + '"' : 'not active') + '\n');
132
- process.stderr.write('Usage: toolfunnel wrap <upstreamId> [--as <serverName>] | toolfunnel wrap --off\n');
133
- process.stderr.write(upstreams.length
134
- ? 'Attached upstreams: ' + upstreams.map((u) => u.id + (u.enabled === false ? ' (disabled)' : '')).join(', ') + '\n'
135
- : 'No upstreams attached yet - attach one first (toolfunnel_howto add-mcp, tf_mcp_add, or the UI).\n');
136
- process.exit(0);
137
- }
138
-
139
- const upstream = store.getUpstream(target);
140
- if (!upstream) {
141
- process.stderr.write('[toolfunnel] wrap: no upstream with id "' + target + '".\n');
142
- process.stderr.write(upstreams.length
143
- ? 'Attached upstreams: ' + upstreams.map((u) => u.id).join(', ') + '\n'
144
- : 'No upstreams attached yet - attach one first (toolfunnel_howto add-mcp, tf_mcp_add, or the UI).\n');
145
- process.exit(1);
146
- }
147
- if (upstream.enabled === false) {
148
- process.stderr.write('[toolfunnel] wrap: upstream "' + target + '" is DISABLED in mcp/expose.json - enable it first.\n');
149
- process.exit(1);
150
- }
151
-
152
- // Isolation notice - wrapping SUSPENDS the path-isolation guard for THIS upstream (and only
153
- // this upstream, and only while wrapped). A wrap is an explicit "this server is my entire
154
- // surface" declaration, so ToolFunnel acts as a transparent wrapper: outside paths (a
155
- // documents folder for a filesystem server, a database file, an absolute install path) are
156
- // permitted BY DESIGN. Funnel-mode upstreams keep the guard. The operator gets this warning
157
- // up front - informed consent, not a silent default (the old behaviour was worse: the guard
158
- // still applied, and `wrap` reported success while the gateway refused to connect).
159
- {
160
- const { looksLikePath, isInside } = require('../src/mcp/aggregator');
161
- const outsideArgs = (Array.isArray(upstream.args) ? upstream.args : [])
162
- .filter((a) => looksLikePath(a) && !isInside(home, a));
163
- if (outsideArgs.length) {
164
- process.stderr.write('[toolfunnel] ⚠ WRAP SECURITY NOTICE - upstream "' + target + '" uses paths outside the gateway root:\n' +
165
- outsideArgs.map((a) => ' ' + a).join('\n') + '\n' +
166
- ' While WRAPPED, the path-isolation guard is suspended for this upstream: ToolFunnel is a\n' +
167
- ' transparent wrapper here, and the wrapped server can reach whatever those paths reach.\n' +
168
- ' Every call still passes the PreToolUse gate. To restrict it AFTER wrapping: add a\n' +
169
- ' PreToolUse hook (block by tool/args), or disable individual tools (enabled:false, keyed\n' +
170
- ' by the surfaced name) - see the manual, "Wrapping & security". `wrap --off` restores the\n' +
171
- ' guard for this upstream.\n');
172
- }
173
- }
174
-
175
- setPassthrough(statePath, target);
176
- process.stderr.write('[toolfunnel] wrapping "' + target + '": its tools are now the ENTIRE surface, under ' +
177
- 'their ORIGINAL names - meta-tools hidden and uncallable, every call still passes the PreToolUse gate.\n');
178
-
179
- // ── Era probe ──────────────────────────────────────────────────────────────────────────────
180
- // The 2026-07-28 spec's dual-era client rule, in probe order: try MODERN first
181
- // (server/discover), and only on failure fall back to the legacy initialize - otherwise a
182
- // dual-era upstream would be misreported as legacy just because the legacy handshake works.
183
- // Separate child per stage: a badly behaved legacy server may react badly to an unknown
184
- // method, and the fallback must not inherit a poisoned child.
185
- if (upstream.transport === 'stdio') {
186
- const { McpClient } = require('../src/mcp/mcp-client');
187
- const clientOpts = {
188
- id: target,
189
- command: upstream.command,
190
- args: Array.isArray(upstream.args) ? upstream.args : [],
191
- env: upstream.env || {},
192
- // Default the probe's cwd to the CONFIG HOME, same as the aggregator's spawn: relative
193
- // arg paths are guarded against the home, so they must spawn against it too.
194
- cwd: upstream.cwd || home,
195
- // Honour a configured slow-boot window; the probe default stays tight.
196
- requestTimeoutMs: Number.isFinite(upstream.requestTimeoutMs) && upstream.requestTimeoutMs > 0
197
- ? upstream.requestTimeoutMs : 6000,
198
- };
199
-
200
- let reported = false;
201
- // Stage 1: modern probe (server/discover).
202
- try {
203
- const probe = new McpClient(clientOpts);
204
- try {
205
- const disc = await probe.probeDiscover();
206
- const versions = disc && Array.isArray(disc.supportedVersions) ? disc.supportedVersions : [];
207
- const who = (disc && disc._meta && disc._meta['io.modelcontextprotocol/serverInfo'] &&
208
- disc._meta['io.modelcontextprotocol/serverInfo'].name) || target;
209
- const legacyCapable = versions.some((v) => typeof v === 'string' && v < '2026-07-28');
210
- if (legacyCapable) {
211
- process.stderr.write('[toolfunnel] era probe: "' + who + '" is DUAL-ERA (speaks ' + versions.join(', ') +
212
- ') - it already survives the 2026-07-28 cutover on its own; the wrap still curates + gates it.\n');
213
- } else {
214
- process.stderr.write('[toolfunnel] era probe: "' + who + '" is MODERN-ONLY (speaks ' + versions.join(', ') +
215
- '). ToolFunnel speaks the modern era to it (server/discover, per-request _meta) and re-presents ' +
216
- 'it to legacy AND modern clients alike.\n');
217
- }
218
- reported = true;
219
- } finally {
220
- await probe.close();
221
- }
222
- } catch (_err) {
223
- /* no modern answer - fall through to the legacy handshake on a FRESH child */
224
- }
225
-
226
- // Stage 2: legacy fallback (initialize) - the era every current MCP speaks.
227
- if (!reported) {
228
- try {
229
- const legacy = new McpClient(clientOpts);
230
- try {
231
- const init = await legacy.connect();
232
- const v = init && typeof init.protocolVersion === 'string' ? init.protocolVersion : 'unknown';
233
- const who = init && init.serverInfo && init.serverInfo.name ? init.serverInfo.name : target;
234
- process.stderr.write('[toolfunnel] era probe: "' + who + '" speaks MCP ' + v + ' - a LEGACY-era MCP ' +
235
- '(no server/discover). Modern (2026-07-28) clients reach it through this gateway; on its own it ' +
236
- 'cannot talk to them.\n');
237
- } finally {
238
- await legacy.close();
239
- }
240
- } catch (err) {
241
- const m = (err && err.message) || String(err);
242
- process.stderr.write('[toolfunnel] era probe: could not connect to "' + target + '" (' + m + ') - ' +
243
- 'the wrap is set anyway; the surface appears when the upstream connects.\n');
244
- }
245
- }
246
- }
247
-
248
- // Dependency honesty: the wrap itself changes nothing about dependencies.
249
- process.stderr.write('[toolfunnel] dependencies: local use (stdio / loopback --http) stays ZERO-dependency. ' +
250
- 'Serving this wrapped MCP over the network needs OAuth enabled first (`toolfunnel install-oauth` - ' +
251
- 'adds the single audited dependency, jose).\n');
252
-
253
- // --as <name>: present AS the wrapped MCP in the handshake/discover (serverInfo). Identity is
254
- // read once at host start (deliberately not hot-swapped - a mid-session identity change would
255
- // lie to connected clients), so this takes effect on the NEXT start.
256
- const asName = flagValue('--as', '');
257
- if (asName) {
258
- const nodeFs = require('node:fs');
259
- const cfgPath = path.join(home, 'toolfunnel.json');
260
- let cfg = {};
261
- let existing = null;
262
- try { existing = nodeFs.readFileSync(cfgPath, 'utf8'); } catch (_e) { existing = null; } // missing = fine
263
- if (existing !== null) {
264
- // The file exists: parse it. On a parse error, ABORT rather than overwrite - blindly
265
- // rewriting would WIPE the operator's other fields (httpPort, serverVersion, requires...) to
266
- // recover only serverName. The wrap itself is already set;
267
- // only the identity rename is skipped.
268
- try {
269
- const parsed = JSON.parse(existing);
270
- cfg = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
271
- } catch (_e) {
272
- process.stderr.write('[toolfunnel] WARNING: toolfunnel.json is not valid JSON - NOT writing serverName ' +
273
- '(refusing to overwrite and lose your other settings). Fix the file, then re-run `wrap ' + target + ' --as ' + asName + '`.\n');
274
- cfg = null; // signal: skip the write
275
- }
276
- }
277
- if (cfg !== null) {
278
- cfg.serverName = asName;
279
- // Atomic (temp + rename) like every other config write - a crash mid-writeFileSync
280
- // would leave a truncated toolfunnel.json.
281
- require('../src/tools/registry').atomicWriteJson(cfgPath, cfg);
282
- process.stderr.write('[toolfunnel] serverName set to "' + asName + '" (toolfunnel.json) - the gateway ' +
283
- 'introduces itself as that in initialize/server-discover from the NEXT start.\n');
284
- }
285
- } else {
286
- process.stderr.write('[toolfunnel] tip: `wrap ' + target + ' --as <name>` also presents the gateway AS ' +
287
- 'that MCP in the handshake (serverInfo), from the next start.\n');
288
- }
289
- process.stderr.write('[toolfunnel] undo with: toolfunnel wrap --off (a running host picks the surface up live)\n');
290
- process.exit(0);
291
- })().catch((err) => {
292
- process.stderr.write('[toolfunnel] wrap failed: ' + ((err && err.message) || err) + '\n');
293
- process.exit(1);
294
- });
295
- } else if (positionals[0] === 'install-oauth') {
296
- // Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
297
- // at zero runtime dependencies - you only ever fetch jose if you decide you need OAuth.
298
- const { installJose } = require('../src/auth/install');
299
- const { JOSE_PIN } = require('../src/auth/resource-server');
300
- process.stderr.write('[toolfunnel] installing the optional OAuth dependency jose@' + JOSE_PIN + ' ...\n');
301
- installJose()
302
- .then((res) => {
303
- if (res.stdout) process.stdout.write(res.stdout);
304
- if (res.stderr) process.stderr.write(res.stderr);
305
- process.stderr.write('[toolfunnel] ' + res.message + '\n');
306
- if (res.ok) {
307
- process.stderr.write('[toolfunnel] OAuth is ready to enable - turn it on in the UI (Auth panel) or auth/auth.config.json.\n');
308
- }
309
- process.exit(res.ok ? 0 : 1);
310
- })
311
- .catch((err) => {
312
- process.stderr.write('[toolfunnel] install-oauth failed: ' + ((err && err.message) || err) + '\n');
313
- process.exit(1);
314
- });
315
- } else if (positionals.length) {
316
- // An unrecognised COMMAND word must refuse loudly - starting the default server instead left
317
- // a mistyped/misplaced subcommand running a gateway the user never asked for, with zero
318
- // feedback (cold-start field test, 2026-07-18).
319
- process.stderr.write('[toolfunnel] unknown command "' + positionals[0] + '" - nothing started. See toolfunnel --help.\n');
320
- process.exit(1);
321
- } else if (args.includes('--ui')) {
322
- const { createUiServer } = require('../src/ui/server');
323
- const { loadServerConfig } = require('../src/core/server-config');
324
- const { resolveConfigHome } = require('../src/core/config-home');
325
- const cfg = loadServerConfig(resolveConfigHome());
326
- const host = flagValue('--host', '127.0.0.1');
327
- // Precedence: --port flag > toolfunnel.json uiPort > 9777 (loadServerConfig folds in the default).
328
- const parsedPort = parseInt(flagValue('--port', String(cfg.uiPort)), 10);
329
- const port = Number.isInteger(parsedPort) ? parsedPort : cfg.uiPort;
330
- const server = createUiServer({ host, port });
331
- server
332
- .start()
333
- .then((info) => {
334
- process.stderr.write('[toolfunnel] config UI listening on ' + info.url + '\n');
335
- })
336
- .catch((err) => {
337
- process.stderr.write('[toolfunnel] failed to start config UI: ' + ((err && err.message) || err) + '\n');
338
- process.exit(1);
339
- });
340
- const shutdown = () => {
341
- server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
342
- };
343
- process.on('SIGINT', shutdown);
344
- process.on('SIGTERM', shutdown);
345
- } else if (args.includes('--http')) {
346
- const { createHttpMcpServer } = require('../src/mcp/http-transport');
347
- const { loadServerConfig } = require('../src/core/server-config');
348
- const { resolveConfigHome } = require('../src/core/config-home');
349
- const cfg = loadServerConfig(resolveConfigHome());
350
- const host = flagValue('--host', '127.0.0.1');
351
- // Precedence: --port flag > toolfunnel.json httpPort > 9998 (loadServerConfig folds in the default).
352
- const parsedPort = parseInt(flagValue('--port', String(cfg.httpPort)), 10);
353
- const port = Number.isInteger(parsedPort) ? parsedPort : cfg.httpPort;
354
- const server = createHttpMcpServer({ host, port });
355
- server
356
- .start()
357
- .then((info) => {
358
- process.stderr.write('[toolfunnel] HTTP MCP host listening on ' + info.url + '\n');
359
- })
360
- .catch((err) => {
361
- process.stderr.write('[toolfunnel] failed to start HTTP host: ' + ((err && err.message) || err) + '\n');
362
- process.exit(1);
363
- });
364
- const shutdown = () => {
365
- server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
366
- };
367
- process.on('SIGINT', shutdown);
368
- process.on('SIGTERM', shutdown);
369
- } else {
370
- // Default: stdio MCP server. main() builds the protocol, connects upstreams, runs the loop.
371
- require('../src/mcp/server').main();
372
- }
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
+ }