toolfunnel 0.5.0 → 0.6.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 (69) hide show
  1. package/README.md +231 -167
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -33
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -1012
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1405
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
package/bin/toolfunnel.js CHANGED
@@ -1,147 +1,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 --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
- if (args.includes('--help') || args.includes('-h')) {
30
- process.stdout.write(
31
- [
32
- 'toolfunnel — a zero-dependency MCP gateway',
33
- '',
34
- 'Usage:',
35
- ' toolfunnel Start the stdio MCP server (default)',
36
- ' toolfunnel --http Start the HTTP/SSE MCP host (127.0.0.1:9998)',
37
- ' toolfunnel --http --port <n> Bind a specific port (0 = OS-assigned)',
38
- ' toolfunnel --http --host <h> Bind a specific loopback host',
39
- ' toolfunnel --ui Start the config web UI (127.0.0.1:9777)',
40
- ' toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)',
41
- ' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
42
- ' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
43
- ' toolfunnel --help Show this help',
44
- '',
45
- 'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
46
- 'the config home — by default the package root (a git clone works as before), or the value',
47
- 'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
48
- 'defaults on first use and is NEVER overwritten after — so an npm update cannot eat your',
49
- 'tools. This is also how a wrapped MCP ships its own bundled setup.',
50
- '',
51
- 'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
52
- ' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
53
- 'A --port flag always wins over the config value.',
54
- '',
55
- ].join('\n')
56
- );
57
- process.exit(0);
58
- }
59
-
60
- // Resolve + seed the config home FIRST — before any src/ module is required — so every
61
- // module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
62
- // same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
63
- const { initConfigHome } = require('../src/core/config-home');
64
- const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
65
-
66
- // Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, …)
67
- // for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
68
- // problem is one loud stderr line and the gateway starts anyway. Never fatal, never throws.
69
- try {
70
- const { checkRequires } = require('../src/core/requires');
71
- for (const p of checkRequires(configHome)) {
72
- process.stderr.write('[toolfunnel] REQUIRES: ' + p.problem + '\n');
73
- }
74
- } catch (_e) { /* preflight must never stop the gateway */ }
75
-
76
- if (args[0] === 'install-oauth') {
77
- // Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
78
- // at zero runtime dependencies — you only ever fetch jose if you decide you need OAuth.
79
- const { installJose } = require('../src/auth/install');
80
- const { JOSE_PIN } = require('../src/auth/resource-server');
81
- process.stderr.write('[toolfunnel] installing the optional OAuth dependency jose@' + JOSE_PIN + ' …\n');
82
- installJose()
83
- .then((res) => {
84
- if (res.stdout) process.stdout.write(res.stdout);
85
- if (res.stderr) process.stderr.write(res.stderr);
86
- process.stderr.write('[toolfunnel] ' + res.message + '\n');
87
- if (res.ok) {
88
- process.stderr.write('[toolfunnel] OAuth is ready to enable — turn it on in the UI (Auth panel) or auth/auth.config.json.\n');
89
- }
90
- process.exit(res.ok ? 0 : 1);
91
- })
92
- .catch((err) => {
93
- process.stderr.write('[toolfunnel] install-oauth failed: ' + ((err && err.message) || err) + '\n');
94
- process.exit(1);
95
- });
96
- } else if (args.includes('--ui')) {
97
- const { createUiServer } = require('../src/ui/server');
98
- const { loadServerConfig } = require('../src/core/server-config');
99
- const { resolveConfigHome } = require('../src/core/config-home');
100
- const cfg = loadServerConfig(resolveConfigHome());
101
- const host = flagValue('--host', '127.0.0.1');
102
- // Precedence: --port flag > toolfunnel.json uiPort > 9777 (loadServerConfig folds in the default).
103
- const parsedPort = parseInt(flagValue('--port', String(cfg.uiPort)), 10);
104
- const port = Number.isInteger(parsedPort) ? parsedPort : cfg.uiPort;
105
- const server = createUiServer({ host, port });
106
- server
107
- .start()
108
- .then((info) => {
109
- process.stderr.write('[toolfunnel] config UI listening on ' + info.url + '\n');
110
- })
111
- .catch((err) => {
112
- process.stderr.write('[toolfunnel] failed to start config UI: ' + ((err && err.message) || err) + '\n');
113
- process.exit(1);
114
- });
115
- const shutdown = () => {
116
- server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
117
- };
118
- process.on('SIGINT', shutdown);
119
- process.on('SIGTERM', shutdown);
120
- } else if (args.includes('--http')) {
121
- const { createHttpMcpServer } = require('../src/mcp/http-transport');
122
- const { loadServerConfig } = require('../src/core/server-config');
123
- const { resolveConfigHome } = require('../src/core/config-home');
124
- const cfg = loadServerConfig(resolveConfigHome());
125
- const host = flagValue('--host', '127.0.0.1');
126
- // Precedence: --port flag > toolfunnel.json httpPort > 9998 (loadServerConfig folds in the default).
127
- const parsedPort = parseInt(flagValue('--port', String(cfg.httpPort)), 10);
128
- const port = Number.isInteger(parsedPort) ? parsedPort : cfg.httpPort;
129
- const server = createHttpMcpServer({ host, port });
130
- server
131
- .start()
132
- .then((info) => {
133
- process.stderr.write('[toolfunnel] HTTP MCP host listening on ' + info.url + '\n');
134
- })
135
- .catch((err) => {
136
- process.stderr.write('[toolfunnel] failed to start HTTP host: ' + ((err && err.message) || err) + '\n');
137
- process.exit(1);
138
- });
139
- const shutdown = () => {
140
- server.stop().then(() => process.exit(0)).catch(() => process.exit(0));
141
- };
142
- process.on('SIGINT', shutdown);
143
- process.on('SIGTERM', shutdown);
144
- } else {
145
- // Default: stdio MCP server. main() builds the protocol, connects upstreams, runs the loop.
146
- require('../src/mcp/server').main();
147
- }
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
+ }