toolfunnel 0.7.1 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -4
- package/bin/toolfunnel.js +103 -24
- package/docs/KNOWN_BUGS.md +48 -28
- package/docs/MANUAL.pdf +0 -0
- package/docs/design.md +1 -1
- package/docs/packaging.md +2 -1
- package/mcp/servers/mock-modern/server.js +1 -1
- package/mcp/servers/mock-upstream/server.js +3 -4
- package/package.json +1 -1
- package/src/auth/config.js +2 -4
- package/src/auth/resource-server.js +6 -1
- package/src/core/atomic-write.js +97 -0
- package/src/core/config-home.js +100 -8
- package/src/core/hook-engine.js +29 -54
- package/src/core/hook-loader.js +3 -61
- package/src/core/hook-runner.js +92 -43
- package/src/core/logger.js +5 -7
- package/src/core/metrics.js +17 -2
- package/src/core/output-cap.js +31 -0
- package/src/core/rename-migration.js +36 -21
- package/src/extend/add-hook.md +4 -1
- package/src/extend/create-tool.md +1 -1
- package/src/extend/package.md +1 -1
- package/src/mcp/aggregator.js +583 -54
- package/src/mcp/expose-store.js +246 -68
- package/src/mcp/http-transport.js +111 -17
- package/src/mcp/mcp-client.js +130 -26
- package/src/mcp/modern.js +9 -6
- package/src/mcp/protocol.js +12 -7
- package/src/mcp/server.js +268 -63
- package/src/mcp/tool-approvals.js +396 -0
- package/src/tools/registry.js +33 -87
- package/src/tools/tool-state.js +1 -1
- package/src/ui/public/app.js +60 -1
- package/src/ui/server.js +142 -16
- package/tools/scripts/tf-accept-change.js +67 -0
- package/tools/scripts/tf-mcp-add.js +28 -15
- package/tools/tools.register.json +16 -5
package/README.md
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|

|
|
12
12
|
|
|
13
|
-
**📖 Full documentation: [the ToolFunnel User Manual](docs/MANUAL.pdf)** - installation, the web UI, writing tools, attaching MCP servers, the policy gate, packaging, and troubleshooting, all illustrated with screenshots and worked examples. This README is the overview; the manual is the reference.
|
|
13
|
+
**📖 Full documentation: [the ToolFunnel User Manual](docs/MANUAL.pdf)** - installation, the web UI, writing tools, attaching MCP servers, the policy gate, packaging, and troubleshooting, all illustrated with screenshots and worked examples. This README is the overview; the manual is the reference. The manual covers 0.7.0, with a supplement for what changed in 0.8.0.
|
|
14
14
|
|
|
15
15
|
## The problem
|
|
16
16
|
|
|
@@ -112,25 +112,35 @@ gatedRun({ engine, ctx, toolName, args, execute })
|
|
|
112
112
|
|
|
113
113
|
Hooks speak the Claude-Code hook protocol (event JSON on stdin, exit `2` to block), so an existing hook travels here unchanged. The load-bearing invariant, proven by test: a PreToolUse deny means `execute()` is never called. The shipped example hooks are **disabled by default** - enable one in `hooks/hooks.manifest.json` (or the UI's Hooks tab) to turn policy on. Writing hooks, matchers, and worked examples: [manual](docs/MANUAL.pdf).
|
|
114
114
|
|
|
115
|
+
### Tool changes wait for you
|
|
116
|
+
|
|
117
|
+
A tool's description goes straight into your model's context whether or not the tool is ever called, so an MCP server that quietly changes what its tools *say* after you've attached it can steer your model. ToolFunnel records every upstream tool's description and schema the first time it connects, and from then on:
|
|
118
|
+
|
|
119
|
+
- **Same server version, changed or new tool** - held. The gateway keeps serving the text you approved (a new tool stays hidden), and calls to that tool are refused until you accept the change. Everything else keeps working.
|
|
120
|
+
- **New server version** - approved and reported, because that is what an update looks like. Set `"strictApproval": true` on an upstream to hold every change for acceptance instead.
|
|
121
|
+
|
|
122
|
+
The refusal tells the model which tool changed and which fields - never the new text. You read that with `toolfunnel changes` or in the UI, and accept it there, with `toolfunnel accept <upstream> [tool]`, or through the agent: a 2026-07-28 client that supports elicitation just asks you in the chat, and any other client can run `tf_accept_change` once you've said yes. Either way an agent-side accept passes your PreToolUse gate as `tf_accept_change`, so a hook can forbid them.
|
|
123
|
+
|
|
115
124
|
## Why it's different
|
|
116
125
|
|
|
117
126
|
Hosting tools and proxying other MCP servers is a crowded space - but for my own use case, which is why I rolled my own solution, I wanted something different.
|
|
118
127
|
|
|
119
|
-
A quick feature comparison as of Aug 2nd 2026:
|
|
128
|
+
A quick feature comparison as of Aug 2nd 2026 (the tool-approval, protocol and wrap rows re-checked against each project's own docs on Sep 24th 2026):
|
|
120
129
|
|
|
121
130
|
| Capability | ToolFunnel | FastMCP | mcpproxy-go | MetaMCP | mcp-anything |
|
|
122
131
|
|---|:--:|:--:|:--:|:--:|:--:|
|
|
123
132
|
| Config-declared polyglot tools (no SDK) | ✓ | ✗ (decorators) | partial (proxy only) | ✗ | ✓ |
|
|
124
133
|
| Lean server-side exposure (briefs + schema on demand) | ✓ | partial | ✓ | ✗ | ✗ |
|
|
125
134
|
| Server-side fail-closed policy gate (Pre/Post) | ✓ | ✓ | ✓ | ✓ | ✗ |
|
|
135
|
+
| Changed tool descriptions held for your approval | ✓ | ✗ | ✓ | ✗ | ✗ |
|
|
126
136
|
| Live attach / hot-reload, no restart | ✓ | ✗ | partial | partial | ✓ |
|
|
127
137
|
| Non-Docker self-healing reconnect | ✓ | ✗ | partial (Docker) | partial (circuit-breaker) | ✗ |
|
|
128
138
|
| Visibility **matrix** - lean default + per-tool promote-to-every-turn + hide | ✓ | ✗ | filter only | cherry-pick only | ✗ |
|
|
129
139
|
| Zero runtime dependencies\* | ✓ (Node, no SDK) | ✗ (framework) | ✓ (Go) | ✗ (Docker) | ✓ (Go) |
|
|
130
140
|
| Runtime dependencies (installed / bundled) | **0\*** | many | 40+ (bundled) | many | bundled (Go) |
|
|
131
141
|
| Config web UI | ✓ | ✗ | ✓ | ✓ | ✗ |
|
|
132
|
-
| Dual-era protocol (2026-07-28 + legacy), client & server side | ✓ |
|
|
133
|
-
| Transparent single-server wrap (invisible, era-bridging) | ✓ |
|
|
142
|
+
| Dual-era protocol (2026-07-28 + legacy), client & server side | ✓ | ✓ (4.0) | ✗ | ✗ | ✗ |
|
|
143
|
+
| Transparent single-server wrap (invisible, era-bridging) | ✓ | partial (4.0: era pinning, no bridge) | ✗ | ✗ | ✗ |
|
|
134
144
|
|
|
135
145
|
Beyond the dependency count, four capability choices set ToolFunnel apart from a pure proxy like mcpproxy-go:
|
|
136
146
|
|
package/bin/toolfunnel.js
CHANGED
|
@@ -23,11 +23,9 @@
|
|
|
23
23
|
const args = process.argv.slice(2);
|
|
24
24
|
|
|
25
25
|
/**
|
|
26
|
-
* Read a flag's value, accepting BOTH `--flag value` and `--flag=value
|
|
27
|
-
*
|
|
28
|
-
*
|
|
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.
|
|
26
|
+
* Read a flag's value, accepting BOTH `--flag value` and `--flag=value`. The `=` form is what most
|
|
27
|
+
* people type first; ignoring it would silently fall back to the package root as the config home,
|
|
28
|
+
* writing config somewhere they were not looking.
|
|
31
29
|
*/
|
|
32
30
|
function flagValue(name, fallback) {
|
|
33
31
|
// ONE left-to-right scan, LAST occurrence wins whichever form it uses. Scanning the = form
|
|
@@ -48,25 +46,31 @@ function flagValue(name, fallback) {
|
|
|
48
46
|
process.exit(2);
|
|
49
47
|
}
|
|
50
48
|
value = v;
|
|
51
|
-
} else if (a === name
|
|
52
|
-
value
|
|
49
|
+
} else if (a === name) {
|
|
50
|
+
// The space form needs a value too: a following FLAG, or nothing at all, is not one. Reading
|
|
51
|
+
// the next flag as the value made `--config-dir --port 0` a config home named "--port", and
|
|
52
|
+
// a trailing bare flag a silent fallback to the package root.
|
|
53
|
+
const v = i + 1 < args.length ? args[i + 1] : undefined;
|
|
54
|
+
if (typeof v !== 'string' || v.length === 0 || v[0] === '-') {
|
|
55
|
+
process.stderr.write(`toolfunnel: ${name} requires a value (write ${name}=<value> or ${name} <value>)\n`);
|
|
56
|
+
process.exit(2);
|
|
57
|
+
}
|
|
58
|
+
value = v;
|
|
53
59
|
}
|
|
54
60
|
}
|
|
55
61
|
return value;
|
|
56
62
|
}
|
|
57
63
|
|
|
58
64
|
// POSITIONAL arguments - the subcommand and its target - recognised regardless of where global
|
|
59
|
-
// flags sit
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
// WITH their value; any other -/-- token is skipped alone.
|
|
63
|
-
const VALUE_FLAGS = new Set(['--config-dir', '--port', '--host', '--as']);
|
|
65
|
+
// flags sit, so `toolfunnel --config-dir X wrap Y` wraps Y instead of reading args[0] as the
|
|
66
|
+
// command. Value-taking flags are skipped WITH their value; any other -/-- token is skipped alone.
|
|
67
|
+
const VALUE_FLAGS = new Set(['--config-dir', '--port', '--host', '--as', '--hash']);
|
|
64
68
|
const positionals = [];
|
|
65
69
|
for (let i = 0; i < args.length; i++) {
|
|
66
70
|
const a = args[i];
|
|
67
71
|
// `--flag value` consumes the next token; `--flag=value` is self-contained and is skipped by the
|
|
68
72
|
// generic leading-dash rule below, so it must NOT also eat the following token.
|
|
69
|
-
if (VALUE_FLAGS.has(a)) { i++; continue; }
|
|
73
|
+
if (VALUE_FLAGS.has(a)) { if (typeof args[i + 1] === 'string' && args[i + 1][0] !== '-') i++; continue; } // a following flag is not its value
|
|
70
74
|
if (typeof a === 'string' && a.length && a[0] === '-') continue;
|
|
71
75
|
positionals.push(a);
|
|
72
76
|
}
|
|
@@ -92,6 +96,10 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
92
96
|
' surface (meta-tools hidden), every call still gated. The',
|
|
93
97
|
' gateway answers both MCP eras, so a legacy-only MCP keeps',
|
|
94
98
|
' working with modern clients. `wrap --off` restores normal.',
|
|
99
|
+
' toolfunnel changes List tool description changes held back for acceptance',
|
|
100
|
+
' toolfunnel accept <upstream> [tool] [--hash <h>]',
|
|
101
|
+
' Accept a held change (every one of that upstream without',
|
|
102
|
+
' a tool). --hash: only if it is still the change you saw.',
|
|
95
103
|
' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
|
|
96
104
|
' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
|
|
97
105
|
' toolfunnel --version Print the version',
|
|
@@ -101,7 +109,8 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
101
109
|
'the config home - by default the package root (a git clone works as before), or the value',
|
|
102
110
|
'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
|
|
103
111
|
'defaults on first use and is NEVER overwritten after - so an npm update cannot eat your',
|
|
104
|
-
'tools.
|
|
112
|
+
'tools. (A tool new in a release is added to your register once; nothing else changes.)',
|
|
113
|
+
'This is also how a wrapped MCP ships its own bundled setup.',
|
|
105
114
|
'',
|
|
106
115
|
'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
|
|
107
116
|
' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
|
|
@@ -117,18 +126,18 @@ if (args.includes('--help') || args.includes('-h')) {
|
|
|
117
126
|
// same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
|
|
118
127
|
// Whether the user EXPLICITLY placed the home must be captured BEFORE init: initConfigHome
|
|
119
128
|
// exports the resolved home into process.env.TOOLFUNNEL_HOME for the child tools it spawns,
|
|
120
|
-
// so reading the env afterwards
|
|
129
|
+
// so reading the env afterwards would always be true.
|
|
121
130
|
const explicitHome = !!(flagValue('--config-dir', '') || process.env.TOOLFUNNEL_HOME);
|
|
122
131
|
const { initConfigHome } = require('../src/core/config-home');
|
|
123
|
-
const configHome = initConfigHome({ dir: flagValue('--config-dir', '') })
|
|
132
|
+
const { home: configHome, added: addedTools } = initConfigHome({ dir: flagValue('--config-dir', '') });
|
|
124
133
|
|
|
125
|
-
// One
|
|
126
|
-
//
|
|
127
|
-
// the relocation hint.
|
|
134
|
+
// One stderr line so nobody discovers the config home by accident. Defaulted homes get the
|
|
135
|
+
// relocation hint.
|
|
128
136
|
process.stderr.write('[toolfunnel] config home: ' + configHome +
|
|
129
137
|
(explicitHome
|
|
130
138
|
? '\n'
|
|
131
139
|
: ' (default: the package root - use --config-dir <dir> or TOOLFUNNEL_HOME to relocate)\n'));
|
|
140
|
+
if (addedTools.length) process.stderr.write('[toolfunnel] added tool(s) new in this release to the register: ' + addedTools.join(', ') + '\n');
|
|
132
141
|
|
|
133
142
|
// Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, ...)
|
|
134
143
|
// for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
|
|
@@ -285,8 +294,14 @@ if (positionals[0] === 'wrap') {
|
|
|
285
294
|
const versions = disc && Array.isArray(disc.supportedVersions) ? disc.supportedVersions : [];
|
|
286
295
|
const who = (disc && disc._meta && disc._meta['io.modelcontextprotocol/serverInfo'] &&
|
|
287
296
|
disc._meta['io.modelcontextprotocol/serverInfo'].name) || target;
|
|
288
|
-
const
|
|
289
|
-
|
|
297
|
+
const { MODERN_PROTOCOL_VERSION } = require('../src/mcp/modern');
|
|
298
|
+
const legacyCapable = versions.some((v) => typeof v === 'string' && v < MODERN_PROTOCOL_VERSION);
|
|
299
|
+
if (versions.length === 0) {
|
|
300
|
+
// An answered discover with NO version list is neither dual-era nor modern-only: say so
|
|
301
|
+
// instead of reporting "MODERN-ONLY (speaks )".
|
|
302
|
+
process.stderr.write('[toolfunnel] era probe: "' + who + '" answered server/discover without a supportedVersions list - ' +
|
|
303
|
+
'treating it as MODERN (it answered the modern probe); check the server if that is unexpected.\n');
|
|
304
|
+
} else if (legacyCapable) {
|
|
290
305
|
process.stderr.write('[toolfunnel] era probe: "' + who + '" is DUAL-ERA (speaks ' + versions.join(', ') +
|
|
291
306
|
') - it already survives the 2026-07-28 cutover on its own; the wrap still curates + gates it.\n');
|
|
292
307
|
} else {
|
|
@@ -371,6 +386,71 @@ if (positionals[0] === 'wrap') {
|
|
|
371
386
|
process.stderr.write('[toolfunnel] wrap failed: ' + ((err && err.message) || err) + '\n');
|
|
372
387
|
process.exit(1);
|
|
373
388
|
});
|
|
389
|
+
} else if (positionals[0] === 'changes') {
|
|
390
|
+
// Tool descriptor approval: what waits for acceptance, straight from the approvals file
|
|
391
|
+
// (the gateway writes pending entries there), plus recent upgrade approvals. Read-only.
|
|
392
|
+
const path = require('node:path');
|
|
393
|
+
const approvals = require('../src/mcp/tool-approvals');
|
|
394
|
+
const file = path.join(configHome, 'mcp', 'tool-approvals.json');
|
|
395
|
+
let doc;
|
|
396
|
+
try {
|
|
397
|
+
doc = approvals.loadApprovals(file);
|
|
398
|
+
} catch (err) {
|
|
399
|
+
process.stderr.write('[toolfunnel] ' + ((err && err.message) || err) + '\n');
|
|
400
|
+
process.exit(1);
|
|
401
|
+
}
|
|
402
|
+
const arg = (s) => (/^[A-Za-z0-9_.:@/-]+$/.test(s) ? s : JSON.stringify(s));
|
|
403
|
+
const lines = [];
|
|
404
|
+
for (const p of approvals.listPending(doc)) {
|
|
405
|
+
lines.push(` ${p.upstream} / ${p.tool} (${p.reason}, waiting since ${p.since || 'unknown'})`);
|
|
406
|
+
lines.push(` approved: ${p.approvedDescription == null ? '(none - a new tool)' : (p.approvedDescription || '(no description)')}`);
|
|
407
|
+
lines.push(` new: ${p.newDescription || '(no description)'}`);
|
|
408
|
+
lines.push(` changed: ${p.changed.join(', ')}`);
|
|
409
|
+
for (const [f, v] of Object.entries(p.fields)) {
|
|
410
|
+
lines.push(` ${f} approved: ${JSON.stringify(v.approved)}`);
|
|
411
|
+
lines.push(` ${f} new: ${JSON.stringify(v.new)}`);
|
|
412
|
+
}
|
|
413
|
+
lines.push(` accept: toolfunnel accept ${arg(p.upstream)} ${arg(p.tool)} --hash ${p.hash}`);
|
|
414
|
+
}
|
|
415
|
+
const upgrades = approvals.listUpgrades(doc, 10);
|
|
416
|
+
if (lines.length) {
|
|
417
|
+
process.stdout.write('Tool changes waiting for acceptance (the server version did not change, or strictApproval is on):\n' + lines.map(approvals.printable).join('\n') + '\n');
|
|
418
|
+
} else {
|
|
419
|
+
process.stdout.write('No tool changes are waiting.\n');
|
|
420
|
+
}
|
|
421
|
+
if (upgrades.length) {
|
|
422
|
+
process.stdout.write('Approved automatically on a server version upgrade (latest first):\n' +
|
|
423
|
+
upgrades.map((u) => approvals.printable(` ${u.upstream} / ${u.tool} (${u.at || 'unknown'})`)).join('\n') + '\n');
|
|
424
|
+
}
|
|
425
|
+
process.exit(0);
|
|
426
|
+
} else if (positionals[0] === 'accept') {
|
|
427
|
+
// Accept a held tool change through the same engine function every route uses (acceptInFile).
|
|
428
|
+
// A running gateway applies it from the next call.
|
|
429
|
+
const path = require('node:path');
|
|
430
|
+
const approvals = require('../src/mcp/tool-approvals');
|
|
431
|
+
const upstream = positionals[1];
|
|
432
|
+
const tool = positionals[2];
|
|
433
|
+
if (!upstream) {
|
|
434
|
+
process.stderr.write('Usage: toolfunnel accept <upstream> [tool] [--hash <sha256:...>]\n');
|
|
435
|
+
process.exit(1);
|
|
436
|
+
}
|
|
437
|
+
const hash = flagValue('--hash', undefined);
|
|
438
|
+
let res;
|
|
439
|
+
try {
|
|
440
|
+
res = approvals.acceptInFile(path.join(configHome, 'mcp', 'tool-approvals.json'), upstream, tool, hash);
|
|
441
|
+
} catch (err) {
|
|
442
|
+
process.stderr.write('[toolfunnel] ' + ((err && err.message) || err) + '\n');
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
const why = approvals.acceptFailure(res, upstream, tool);
|
|
446
|
+
if (why) {
|
|
447
|
+
process.stderr.write(approvals.printable('[toolfunnel] ' + why + (res.stale ? '. Run `toolfunnel changes` to see the current one.' : '')) + '\n');
|
|
448
|
+
process.exit(1);
|
|
449
|
+
}
|
|
450
|
+
process.stdout.write(approvals.printable('Accepted: ' + res.accepted.join(', ') + '.' +
|
|
451
|
+
(res.pending.length ? ' Still waiting: ' + res.pending.join(', ') + '.' : '') +
|
|
452
|
+
' A running gateway applies this from the next call.') + '\n');
|
|
453
|
+
process.exit(0);
|
|
374
454
|
} else if (positionals[0] === 'install-oauth') {
|
|
375
455
|
// Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
|
|
376
456
|
// at zero runtime dependencies - you only ever fetch jose if you decide you need OAuth.
|
|
@@ -392,9 +472,8 @@ if (positionals[0] === 'wrap') {
|
|
|
392
472
|
process.exit(1);
|
|
393
473
|
});
|
|
394
474
|
} else if (positionals.length) {
|
|
395
|
-
// An unrecognised COMMAND word must refuse loudly - starting the default server instead
|
|
396
|
-
// a mistyped/misplaced subcommand running a gateway the user never asked for
|
|
397
|
-
// feedback (cold-start field test, 2026-07-18).
|
|
475
|
+
// An unrecognised COMMAND word must refuse loudly - starting the default server instead would
|
|
476
|
+
// leave a mistyped/misplaced subcommand running a gateway the user never asked for.
|
|
398
477
|
process.stderr.write('[toolfunnel] unknown command "' + positionals[0] + '" - nothing started. See toolfunnel --help.\n');
|
|
399
478
|
process.exit(1);
|
|
400
479
|
} else if (args.includes('--ui')) {
|
package/docs/KNOWN_BUGS.md
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
1
|
# Known bugs
|
|
2
2
|
|
|
3
|
-
The **Open** list is what remains open as of 0.
|
|
4
|
-
0.
|
|
5
|
-
0.6.0 reader can see their fate. The full change list is in the
|
|
3
|
+
The **Open** list is what remains open as of 0.8.0. **Fixed in 0.8.0** marks the defects a
|
|
4
|
+
0.7.x user could meet that this release closed. The full change list is in the
|
|
6
5
|
[release notes](https://github.com/Rendeverance/toolfunnel/releases).
|
|
7
6
|
|
|
8
7
|
## Open
|
|
@@ -13,7 +12,7 @@ The **Open** list is what remains open as of 0.7.0. **Fixed in 0.7.0** marks the
|
|
|
13
12
|
upstream is in scope.
|
|
14
13
|
- **OPEN - The elicitation bridge does not cover legacy clients.** A wrapped upstream's mid-call
|
|
15
14
|
`elicitation/create` bridges to MODERN clients as MRTR (`input_required` + retry), form and url
|
|
16
|
-
mode, gated on the caller's declared capability
|
|
15
|
+
mode, gated on the caller's declared capability. A LEGACY client gets an automatic
|
|
17
16
|
decline instead - relaying backwards requests to legacy clients is unbuilt - and
|
|
18
17
|
`sampling/createMessage` / `roots/list` from upstreams are answered `-32601`. An elicitation can
|
|
19
18
|
only be bound to a call when exactly ONE wrapped call is in flight for that upstream (always
|
|
@@ -26,32 +25,45 @@ The **Open** list is what remains open as of 0.7.0. **Fixed in 0.7.0** marks the
|
|
|
26
25
|
and wrapped tool calls; the remaining best-effort windows there are sub-millisecond (a cancel
|
|
27
26
|
racing the instant a forward is issued) plus the PreToolUse gate-evaluation phase of a tool
|
|
28
27
|
call (registered at upstream-issue time, post-gate).
|
|
28
|
+
- **OPEN - A wrapped server's `instructions` are not covered by tool approval.** Tool descriptor
|
|
29
|
+
approval pins each tool's model-facing fields (`name`, `title`, `description`, `inputSchema`,
|
|
30
|
+
`outputSchema`, `annotations`). In wrap mode the wrapped server's `instructions` also reach the
|
|
31
|
+
model, unpinned, and a tool's `_meta`, `execution` and `icons` are forwarded without being
|
|
32
|
+
hashed. Pinning `instructions` is planned.
|
|
33
|
+
- **OPEN - A command on a drive mounted after startup stops being retried.** A missing command is a
|
|
34
|
+
permanent failure: about five minutes of retries, then the upstream stops (named on stderr and
|
|
35
|
+
in `/health.stopped`) until `expose.json` is saved, the gateway restarts, or the upstream is
|
|
36
|
+
reconnected. A drive or share that appears later than that is not noticed on its own.
|
|
37
|
+
- **OPEN (minor) - A very large approvals file slows refused calls.** Each refused call re-reads
|
|
38
|
+
`mcp/tool-approvals.json`; at 22 MB that costs about half a second per refused call. A home
|
|
39
|
+
with two real upstreams holds about 250 KB.
|
|
29
40
|
|
|
30
|
-
## Fixed in 0.
|
|
41
|
+
## Fixed in 0.8.0
|
|
31
42
|
|
|
32
|
-
- ***FIXED*** - **
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
`test/
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
- ***FIXED*** - **
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
Pinned by `test/
|
|
44
|
-
- ***FIXED*** - **
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
43
|
+
- ***FIXED*** - **A hook's decision was ignored when the hook printed anything before its JSON.**
|
|
44
|
+
The exit code decided instead, so a gate that logged a line and then denied, exiting 0, let the
|
|
45
|
+
call through. The decision is now read wherever it sits, and two different decisions (a hook
|
|
46
|
+
that echoes model-supplied text after its own answer) fail closed. Pinned by
|
|
47
|
+
`test/gate-semantics.test.js`.
|
|
48
|
+
- ***FIXED*** - **The hook engine substituted stand-ins when one of its parts failed to load.** It
|
|
49
|
+
now refuses to load and names the missing part. Pinned by `test/hook-engine-requires.test.js`.
|
|
50
|
+
- ***FIXED*** - **Child hooks saw the wrong `HOOKS_DIR` and `CLAUDE_PROJECT_DIR`.** Both now point
|
|
51
|
+
at the config home. Pinned by `test/hook-env.test.js`.
|
|
52
|
+
- ***FIXED*** - **A relative upstream `cwd` was checked against one directory and spawned in
|
|
53
|
+
another.** The isolation guard and the spawn now both resolve it against the config home.
|
|
54
|
+
Pinned by `test/upstream-cwd.test.js`.
|
|
55
|
+
- ***FIXED*** - **Stopping a stdio gateway could leave its upstreams running (POSIX).** SIGINT or
|
|
56
|
+
SIGTERM now closes every upstream, then exits 130 or 143. Pinned by
|
|
57
|
+
`test/stdio-shutdown.test.js`.
|
|
58
|
+
- ***FIXED*** - **A permanent startup failure retried forever, and on Windows a missing command
|
|
59
|
+
looked like a crashing server.** A missing command, a missing working directory or an isolation
|
|
60
|
+
refusal now stops after about five minutes with one line naming the cause, and shows in
|
|
61
|
+
`/health.stopped`. Pinned by `test/permanent-failure-ceiling.test.js`.
|
|
62
|
+
- ***FIXED*** - **A wrong-typed config field was coerced or dropped silently.** The config now
|
|
63
|
+
refuses to load, naming the file, the entry and the field. Pinned by
|
|
64
|
+
`test/fail-loud-config.test.js`.
|
|
65
|
+
- ***FIXED*** - **The legacy dispatcher answered notifications.** A notification now never gets a
|
|
66
|
+
reply. Pinned by `test/legacy-notifications.test.js`.
|
|
55
67
|
|
|
56
68
|
## By design (not bugs)
|
|
57
69
|
|
|
@@ -76,5 +88,13 @@ These answer "why doesn't it do X". They are considered decisions, not defects.
|
|
|
76
88
|
- **Discover works on a DISABLED upstream**, so its tools can be inspected before enabling. An
|
|
77
89
|
explicit allowance on the discover path only; the disabled upstream never reaches the tool
|
|
78
90
|
surface.
|
|
91
|
+
- **Upstreams connect all at once, with no concurrency cap.** Right for the handful to tens of
|
|
92
|
+
upstreams a gateway holds.
|
|
93
|
+
- **The legacy SSE transport's dead-client prune branch never runs.** The stream's close and error
|
|
94
|
+
handlers already clean up; the only effect is a counter that reads high. Cosmetic.
|
|
95
|
+
- **Tool approval trusts a version bump.** A change at the same server version waits for
|
|
96
|
+
acceptance; a changed version is approved and reported, because that is what an update looks
|
|
97
|
+
like. A maintainer can bump the version too: `strictApproval: true` on an upstream holds every
|
|
98
|
+
change for acceptance.
|
|
79
99
|
|
|
80
100
|
Found a bug, or want to help with the open work? Issues and PRs are welcome.
|
package/docs/MANUAL.pdf
CHANGED
|
Binary file
|
package/docs/design.md
CHANGED
|
@@ -63,7 +63,7 @@ caller speaks - stripping a server's own fields would be data loss and a wrapper
|
|
|
63
63
|
|
|
64
64
|
Everything mutable (tools, upstreams, hooks, identity, logs) lives in the config home,
|
|
65
65
|
relocatable with `--config-dir` or `TOOLFUNNEL_HOME`, seeded once, never overwritten by
|
|
66
|
-
updates. One folder to back up, one folder to package, one folder to hand a colleague. The
|
|
66
|
+
updates (a tool new in a release is added to the register once). One folder to back up, one folder to package, one folder to hand a colleague. The
|
|
67
67
|
gateway prints the resolved home at every start so there is never any doubt about where state
|
|
68
68
|
lives.
|
|
69
69
|
|
package/docs/packaging.md
CHANGED
|
@@ -19,7 +19,8 @@ TOOLFUNNEL_HOME=D:\my-setup toolfunnel # env var
|
|
|
19
19
|
An empty home is **seeded** on first use from the shipped defaults (the register + management
|
|
20
20
|
scripts, an empty `mcp/expose.json`, the default hooks manifest) and is **never overwritten**
|
|
21
21
|
afterwards - which is the point: with the home outside the package, `npm update toolfunnel`
|
|
22
|
-
can never eat your tools.
|
|
22
|
+
can never eat your tools. The one thing an update adds is a tool new in that release, appended
|
|
23
|
+
to your register once; a shipped tool you removed is never put back.
|
|
23
24
|
|
|
24
25
|
```
|
|
25
26
|
<home>/
|
|
@@ -21,7 +21,7 @@
|
|
|
21
21
|
* Tools (real upstream names): ping -> "pong", add {a,b} -> a+b, echo {text} -> text.
|
|
22
22
|
*
|
|
23
23
|
* JSON-RPC 2.0 over stdio, newline-delimited (one compact JSON object per line). Node built-ins
|
|
24
|
-
* only, CommonJS.
|
|
24
|
+
* only, CommonJS.
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
27
|
const MODERN_VERSION = '2026-07-28';
|
|
@@ -15,8 +15,7 @@
|
|
|
15
15
|
*
|
|
16
16
|
* Protocol: JSON-RPC 2.0 over stdio with NEWLINE-DELIMITED framing - one compact JSON object per
|
|
17
17
|
* line, "\n"-terminated, the MCP stdio transport every real SDK-based server speaks. We read lines
|
|
18
|
-
* on stdin and answer in kind on stdout.
|
|
19
|
-
* matching an old client bug; both were corrected together so the mock represents a REAL server.)
|
|
18
|
+
* on stdin and answer in kind on stdout.
|
|
20
19
|
*
|
|
21
20
|
* Tools exposed (real upstream names):
|
|
22
21
|
* - ping -> "pong"
|
|
@@ -33,7 +32,7 @@
|
|
|
33
32
|
|
|
34
33
|
const PROTOCOL_VERSION = '2024-11-05';
|
|
35
34
|
// `title` is a fidelity fixture: the 2025-06-18 spec added serverInfo.title, and the wrap must
|
|
36
|
-
// deliver it VERBATIM
|
|
35
|
+
// deliver it VERBATIM.
|
|
37
36
|
const SERVER_INFO = { name: 'mock-upstream', version: '1.0.0', title: 'Mock Upstream Fixture' };
|
|
38
37
|
|
|
39
38
|
const TOOLS = [
|
|
@@ -44,7 +43,7 @@ const TOOLS = [
|
|
|
44
43
|
},
|
|
45
44
|
{
|
|
46
45
|
// title + annotations are fidelity fixtures: real servers ship them (and newer fields), and a
|
|
47
|
-
// transparent wrap must advertise the def BYTE-IDENTICAL
|
|
46
|
+
// transparent wrap must advertise the def BYTE-IDENTICAL.
|
|
48
47
|
name: 'add',
|
|
49
48
|
title: 'Add Numbers',
|
|
50
49
|
description: 'Add two numbers and return the sum.',
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolfunnel",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "A zero-dependency MCP gateway: host your own tools, forward and curate tools from other MCP servers, expose them leanly to cut agent token cost, and gate every call through your own policy hooks before it runs.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
package/src/auth/config.js
CHANGED
|
@@ -39,6 +39,7 @@ const path = require('node:path');
|
|
|
39
39
|
/** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root - see
|
|
40
40
|
* src/core/config-home.js). Auth config is user-state, so it lives with the home. */
|
|
41
41
|
const { resolveConfigHome } = require('../core/config-home');
|
|
42
|
+
const { atomicWriteJson } = require('../core/atomic-write'); // THE atomic writer, shared by every store
|
|
42
43
|
const ROOT = resolveConfigHome();
|
|
43
44
|
|
|
44
45
|
/** The toggle/config file. NOT created until setConfig() writes it. */
|
|
@@ -163,10 +164,7 @@ function setConfig(patch) {
|
|
|
163
164
|
};
|
|
164
165
|
|
|
165
166
|
fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
|
|
166
|
-
|
|
167
|
-
const tmp = CONFIG_PATH + '.' + process.pid + '.' + Date.now() + '.tmp';
|
|
168
|
-
fs.writeFileSync(tmp, JSON.stringify(next, null, 2) + '\n');
|
|
169
|
-
fs.renameSync(tmp, CONFIG_PATH);
|
|
167
|
+
atomicWriteJson(CONFIG_PATH, next); // temp + fsync + rename, failure-path unlink, stale sweep
|
|
170
168
|
|
|
171
169
|
return next;
|
|
172
170
|
}
|
|
@@ -125,16 +125,21 @@ async function resolveJwksUri(cfg, timeoutMs) {
|
|
|
125
125
|
/* malformed URL on either side -> treat as cross-origin -> refuse */
|
|
126
126
|
}
|
|
127
127
|
if (!sameOrigin) {
|
|
128
|
-
|
|
128
|
+
const refusal = new Error(
|
|
129
129
|
`discovered jwks_uri "${doc.jwks_uri}" is not on the issuer origin - refusing the ` +
|
|
130
130
|
'cross-origin discovery result. If your IdP intentionally hosts JWKS on a separate ' +
|
|
131
131
|
'origin, state that trust explicitly by setting "jwksUri" in auth/auth.config.json.'
|
|
132
132
|
);
|
|
133
|
+
refusal.code = 'JWKS_ORIGIN_REFUSED'; // an operator-config verdict: no later candidate can remedy it
|
|
134
|
+
throw refusal;
|
|
133
135
|
}
|
|
134
136
|
return doc.jwks_uri;
|
|
135
137
|
}
|
|
136
138
|
lastErr = new Error(`discovery doc at ${url} has no jwks_uri`);
|
|
137
139
|
} catch (e) {
|
|
140
|
+
// Last-error-wins hid the actionable refusal behind whatever the LAST candidate said (usually a
|
|
141
|
+
// plain 404). The refusal is final: surface it now.
|
|
142
|
+
if (e && e.code === 'JWKS_ORIGIN_REFUSED') throw e;
|
|
138
143
|
lastErr = e;
|
|
139
144
|
}
|
|
140
145
|
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* atomic-write.js - THE atomic file writer (temp in the same directory + fsync + rename).
|
|
5
|
+
*
|
|
6
|
+
* One implementation for every store the gateway persists: the tool register and its scripts,
|
|
7
|
+
* the tool-state overlay, the expose store, the hooks manifest / state / scripts, toolfunnel.json
|
|
8
|
+
* and the rename migration. Four copies of this pattern had drifted apart (one lacked the
|
|
9
|
+
* failure-path unlink and the stale sweep; three wrote with a single writeSync where the loader's
|
|
10
|
+
* looped) - the exact "hardening decision made in one place and never carried to its sibling"
|
|
11
|
+
* fingerprint. orphan-tmp.test.js pins both the contract below and the fact that this is the
|
|
12
|
+
* ONLY module under src/ that performs the rename.
|
|
13
|
+
*
|
|
14
|
+
* Contract:
|
|
15
|
+
* - A crash leaves either the old file or the complete new file, never a half-written one: the
|
|
16
|
+
* data goes to `.<base>.<pid>.<ts>.tmp` beside the target (same volume, so the rename is
|
|
17
|
+
* atomic), is fsync'd, then renamed over the target.
|
|
18
|
+
* - The temp NEVER outlives a failure: any throw (a refused rename - win32 scanners hold targets
|
|
19
|
+
* briefly - a full disk, a write error) unlinks it before rethrowing.
|
|
20
|
+
* - STALE strandings from writers that died mid-flight (same temp shape, older than
|
|
21
|
+
* STALE_TMP_MS) are swept on the next write of the same target; a FRESH sibling (a concurrent
|
|
22
|
+
* writer's in-flight temp) is left alone. Sweeping is best-effort and never fails a write.
|
|
23
|
+
* - writeFileSync(fd, ...) loops until every byte is written (a bare writeSync may return short).
|
|
24
|
+
* - Bytes are written exactly as given (no line-ending normalisation - shell scripts may be
|
|
25
|
+
* CRLF-sensitive).
|
|
26
|
+
*
|
|
27
|
+
* CommonJS, Node built-ins only.
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
const fs = require('node:fs');
|
|
31
|
+
const path = require('node:path');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A sibling temp older than this is a STRANDING (a crashed/killed writer), not an in-flight
|
|
35
|
+
* write - a live writer's temp exists for milliseconds. The generous age keeps a concurrent
|
|
36
|
+
* writer safe.
|
|
37
|
+
*/
|
|
38
|
+
const STALE_TMP_MS = 60 * 60 * 1000;
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Remove STALE stranded temps for `base` in `dir` (the `.<base>.<pid>.<ts>.tmp` shape the
|
|
42
|
+
* writer below produces). Scoped to the target's own temp shape - never another file's - and
|
|
43
|
+
* entirely best-effort: sweeping must never fail a write.
|
|
44
|
+
*/
|
|
45
|
+
function sweepStaleTmp(dir, base) {
|
|
46
|
+
let names;
|
|
47
|
+
try { names = fs.readdirSync(dir); } catch (_e) { return; }
|
|
48
|
+
const prefix = `.${base}.`;
|
|
49
|
+
const now = Date.now();
|
|
50
|
+
for (const name of names) {
|
|
51
|
+
if (!name.startsWith(prefix) || !name.endsWith('.tmp')) continue;
|
|
52
|
+
const p = path.join(dir, name);
|
|
53
|
+
try {
|
|
54
|
+
if (now - fs.statSync(p).mtimeMs > STALE_TMP_MS) fs.unlinkSync(p);
|
|
55
|
+
} catch (_e) { /* a concurrent writer renamed/removed it - fine */ }
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Atomically write text to `targetPath`. See the module contract.
|
|
61
|
+
* @param {string} targetPath absolute destination
|
|
62
|
+
* @param {string|Buffer} data
|
|
63
|
+
* @param {string} [tmpDir] directory for the temp file (default: the target's own; must be the
|
|
64
|
+
* same volume as the target for the rename to be atomic)
|
|
65
|
+
*/
|
|
66
|
+
function atomicWriteText(targetPath, data, tmpDir) {
|
|
67
|
+
const dir = tmpDir || path.dirname(targetPath);
|
|
68
|
+
const base = path.basename(targetPath);
|
|
69
|
+
sweepStaleTmp(dir, base);
|
|
70
|
+
const tmp = path.join(dir, `.${base}.${process.pid}.${Date.now()}.tmp`);
|
|
71
|
+
try {
|
|
72
|
+
const fd = fs.openSync(tmp, 'w');
|
|
73
|
+
try {
|
|
74
|
+
fs.writeFileSync(fd, data); // loops until every byte is written
|
|
75
|
+
try { fs.fsyncSync(fd); } catch (_e) { /* fsync may be refused on some filesystems; the rename is still ordered */ }
|
|
76
|
+
} finally {
|
|
77
|
+
try { fs.closeSync(fd); } catch (_e) { /* ignore */ }
|
|
78
|
+
}
|
|
79
|
+
// rename is atomic on the same filesystem and overwrites the destination on POSIX; on Windows
|
|
80
|
+
// it overwrites too as long as the target isn't locked.
|
|
81
|
+
fs.renameSync(tmp, targetPath);
|
|
82
|
+
} catch (err) {
|
|
83
|
+
try { fs.unlinkSync(tmp); } catch (_e) { /* best-effort - the error below is the story */ }
|
|
84
|
+
throw err;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Atomically write a JS object as pretty JSON (2-space, trailing newline). See the module contract.
|
|
90
|
+
* @param {string} targetPath absolute destination
|
|
91
|
+
* @param {object} obj
|
|
92
|
+
*/
|
|
93
|
+
function atomicWriteJson(targetPath, obj) {
|
|
94
|
+
atomicWriteText(targetPath, JSON.stringify(obj, null, 2) + '\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
module.exports = { atomicWriteJson, atomicWriteText, sweepStaleTmp, STALE_TMP_MS };
|