toolfunnel 0.7.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.
- package/README.md +228 -235
- package/SECURITY.md +84 -84
- package/bin/toolfunnel.js +451 -451
- package/docs/KNOWN_BUGS.md +80 -80
- package/docs/design.md +84 -84
- package/docs/packaging.md +115 -115
- package/hooks/hooks.manifest.json +27 -27
- package/hooks/scripts/example-audit.js +21 -21
- package/hooks/scripts/example-deny-dangerous.js +39 -39
- package/mcp/expose.example.json +30 -30
- package/mcp/servers/mock-modern/server.js +147 -147
- package/mcp/servers/mock-upstream/server.js +380 -380
- package/package.json +67 -67
- package/src/auth/config.js +174 -174
- package/src/auth/install.js +181 -181
- package/src/auth/resource-server.js +353 -353
- package/src/core/child-reaper.js +61 -61
- package/src/core/config-home.js +134 -134
- package/src/core/events.js +105 -105
- package/src/core/hook-engine.js +329 -329
- package/src/core/hook-loader.js +824 -824
- package/src/core/hook-runner.js +566 -566
- package/src/core/logger.js +297 -297
- package/src/core/matcher.js +100 -100
- package/src/core/metrics.js +75 -75
- package/src/core/rename-migration.js +163 -163
- package/src/core/requires.js +138 -138
- package/src/core/server-config.js +98 -98
- package/src/extend/add-hook.md +152 -152
- package/src/extend/add-mcp.md +119 -119
- package/src/extend/configure.md +88 -88
- package/src/extend/create-tool.md +168 -168
- package/src/extend/howto.js +102 -102
- package/src/extend/package.md +190 -190
- package/src/extend/wrap.md +99 -99
- package/src/mcp/aggregator.js +1719 -1674
- package/src/mcp/expose-store.js +577 -577
- package/src/mcp/gated-run.js +210 -210
- package/src/mcp/http-transport.js +1563 -1563
- package/src/mcp/mcp-client.js +1070 -1070
- package/src/mcp/modern.js +406 -406
- package/src/mcp/protocol.js +468 -468
- package/src/mcp/server.js +3112 -3112
- package/src/tools/registry.js +661 -661
- package/src/tools/tool-state.js +170 -170
- package/src/ui/public/app.js +1187 -1187
- package/src/ui/public/index.html +286 -286
- package/src/ui/public/styles.css +535 -535
- package/src/ui/server.js +1616 -1616
- package/tools/scripts/base64.js +139 -139
- package/tools/scripts/danger.js +61 -61
- package/tools/scripts/echo.js +39 -39
- package/tools/scripts/hash.js +101 -101
- package/tools/scripts/json.js +109 -109
- package/tools/scripts/text-stats.js +131 -131
- package/tools/scripts/tf-env.js +43 -43
- package/tools/scripts/tf-hook-add.js +151 -151
- package/tools/scripts/tf-hook-set.js +120 -120
- package/tools/scripts/tf-list.js +192 -192
- package/tools/scripts/tf-log.js +86 -86
- package/tools/scripts/tf-mcp-add.js +117 -117
- package/tools/scripts/tf-mcp-set.js +121 -121
- package/tools/scripts/tf-pack.js +237 -237
- package/tools/scripts/tf-tool-add.js +134 -134
- package/tools/scripts/tf-tool-set.js +142 -142
- package/tools/scripts/tf-wrap.js +103 -103
- package/tools/scripts/uuid.js +102 -102
package/bin/toolfunnel.js
CHANGED
|
@@ -1,451 +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 --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` (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
|
+
}
|