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 CHANGED
@@ -10,7 +10,7 @@
10
10
 
11
11
  ![ToolFunnel in 60 seconds: scripts → register → identity → live MCP server → npm package](demo/toolfunnel-demo.gif)
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 | ✓ | ✗<br>✓(beta) | ✗ | ✗ | ✗ |
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` (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.
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 && i + 1 < args.length) {
52
- value = args[i + 1];
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. `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']);
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. This is also how a wrapped MCP ships its own bundled setup.',
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 is always-true and the hint branch was dead (0.7.0).
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', '') }).home;
132
+ const { home: configHome, added: addedTools } = initConfigHome({ dir: flagValue('--config-dir', '') });
124
133
 
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.
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 legacyCapable = versions.some((v) => typeof v === 'string' && v < '2026-07-28');
289
- if (legacyCapable) {
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 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).
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')) {
@@ -1,8 +1,7 @@
1
1
  # Known bugs
2
2
 
3
- The **Open** list is what remains open as of 0.7.0. **Fixed in 0.7.0** marks the items from
4
- 0.6.0's file that this release closed - kept and marked rather than silently deleted, so a
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 (0.7.0). A LEGACY client gets an automatic
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.7.0
41
+ ## Fixed in 0.8.0
31
42
 
32
- - ***FIXED*** - **Legacy version negotiation.** 0.6.0 answered every `initialize` with
33
- 2024-11-05 whatever the client asked for, and supported exactly one legacy revision. The
34
- gateway now echoes the requested version when it supports it (else answers its latest legacy),
35
- and the legacy set spans 2024-11-05 / 2025-03-26 / 2025-06-18 / 2025-11-25. Pinned by
36
- `test/negotiation.test.js`. One direction remains conservative: the client still offers
37
- 2024-11-05 to legacy upstreams (field fidelity is unaffected; only the advertised version
38
- string is older than either end requires).
39
- - ***FIXED*** - **Header validation used a generic error code for an unsupported version.** An
40
- unsupported `MCP-Protocol-Version` header answered the generic `-32600`; it now answers
41
- `-32022` with `data.supported` and `data.requested` - the same code and shape the `_meta`
42
- path already used for the same condition, so a client can renegotiate without parsing prose.
43
- Pinned by `test/header-enforcement.test.js`.
44
- - ***FIXED*** - **The wrap's identity-mirror reconnect re-ran the full era negotiation** (worst
45
- case roughly 3 s per respawn against an upstream that ignores `server/discover`). The
46
- negotiated era is now remembered per upstream, and a death-driven reconnect goes straight to
47
- the era it knows. A failed hinted connect or an explicit reconnect re-negotiates in full, so
48
- an upstream upgraded across a restart is never pinned down an era. Pinned by
49
- `test/reconnect-era-memo.test.js`.
50
- - ***FIXED*** - **`io.modelcontextprotocol/logLevel` was stripped on wrapped forwards.** Now
51
- era-keyed at the client boundary: a modern upstream gets the caller's level verbatim in
52
- `_meta`; a legacy upstream that declared the `logging` capability gets `logging/setLevel`
53
- issued before the call (and the key never leaks into legacy `_meta`). Pinned by
54
- `test/loglevel.test.js`.
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. Built to the RC spec (locked 2026-05-21); reconcile minor final deltas 28 July.
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. (This fixture used to parse LSP Content-Length framing,
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 (a whitelist in wrappedIdentity used to eat it - found wire-testing the wrap, 2026-07-17).
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 (a projection used to drop both).
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.7.1",
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",
@@ -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
- // Atomic write: unique temp file in the same dir, then rename over the target.
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
- throw new Error(
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 };