toolfunnel 0.4.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (78) hide show
  1. package/README.md +102 -213
  2. package/bin/toolfunnel.js +372 -147
  3. package/docs/KNOWN_BUGS.md +99 -225
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/design.md +70 -0
  6. package/docs/packaging.md +115 -115
  7. package/hooks/hooks.manifest.json +27 -27
  8. package/hooks/scripts/example-audit.js +21 -21
  9. package/hooks/scripts/example-deny-dangerous.js +31 -31
  10. package/mcp/expose.example.json +30 -30
  11. package/mcp/servers/mock-modern/server.js +147 -0
  12. package/mcp/servers/mock-upstream/server.js +192 -12
  13. package/package.json +1 -1
  14. package/src/auth/config.js +174 -174
  15. package/src/auth/install.js +181 -181
  16. package/src/auth/resource-server.js +344 -344
  17. package/src/core/config-home.js +134 -134
  18. package/src/core/events.js +105 -105
  19. package/src/core/hook-engine.js +275 -275
  20. package/src/core/hook-loader.js +791 -791
  21. package/src/core/hook-runner.js +346 -346
  22. package/src/core/logger.js +171 -171
  23. package/src/core/matcher.js +51 -51
  24. package/src/core/metrics.js +75 -75
  25. package/src/core/requires.js +138 -138
  26. package/src/core/server-config.js +98 -77
  27. package/src/core/tool-registry.js +180 -180
  28. package/src/extend/add-hook.md +152 -142
  29. package/src/extend/add-mcp.md +122 -121
  30. package/src/extend/configure.md +87 -0
  31. package/src/extend/create-tool.md +168 -152
  32. package/src/extend/howto.js +102 -98
  33. package/src/extend/package.md +187 -187
  34. package/src/extend/wrap.md +99 -0
  35. package/src/mcp/aggregator.js +1246 -807
  36. package/src/mcp/expose-store.js +460 -430
  37. package/src/mcp/gated-run.js +201 -194
  38. package/src/mcp/http-transport.js +1311 -981
  39. package/src/mcp/mcp-client.js +907 -530
  40. package/src/mcp/modern.js +372 -0
  41. package/src/mcp/protocol.js +468 -466
  42. package/src/mcp/server.js +2688 -1322
  43. package/src/packages/packager.js +715 -715
  44. package/src/tools/drift.js +138 -138
  45. package/src/tools/registry.js +513 -511
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1174 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1348 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +112 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +227 -216
  70. package/docs/admin-ui.md +0 -404
  71. package/docs/configuration.md +0 -402
  72. package/docs/hooks-and-gating.md +0 -428
  73. package/docs/logging.md +0 -223
  74. package/docs/management.md +0 -429
  75. package/docs/oauth.md +0 -103
  76. package/docs/releasing.md +0 -31
  77. package/docs/setup.md +0 -352
  78. package/docs/tools-and-upstreams.md +0 -469
@@ -1,225 +1,99 @@
1
- # ToolFunnel — 0.4.0 Board (bugs + features)
2
-
3
- > Items 1–5: findings from a full source review on **2026-07-03** (v0.3.0, commit `1c5841c`),
4
- > each ranked, located, explained, and paired with a proposed fix.
5
- > **All five FIXED 2026-07-06** in commit `6c9821c` (suite 23/23) — the detail sections below are
6
- > kept as the record of what was wrong and why the fix took the shape it did.
7
- > Items 6–9: the 0.4.0 feature board (identity, verification passes, packaging).
8
- >
9
- > **Context for future-me:** the core gate architecture reviewed as *sound* — see
10
- > "What is already solid" at the bottom before re-auditing. Do not re-review the
11
- > whole tree; start from this list.
12
-
13
- ---
14
-
15
- ## The board
16
-
17
- | # | Kind | Area | One-liner | Status |
18
- |---|------|------|-----------|--------|
19
- | 1 | **High** bug | UI server | Config UI could bind off-loopback with NO auth and NO refusal — unauthenticated LAN RCE surface | **FIXED** `6c9821c` |
20
- | 2 | Low bug | mcp-client (win) | `winLaunch` cmd.exe metacharacter interpretation — "no injection surface" comment was overstated | **FIXED** `6c9821c` (honest caveat) |
21
- | 3 | Low bug | aggregator | `_scheduleReconnect` logged `reconnect_slow` every 30s forever instead of once at the transition | **FIXED** `6c9821c` |
22
- | 4 | Low bug | http-transport | Streaming (chunked) over-cap body didn't get the clean `-32700` — and the "engineered" declared-CL path had the same RST flaw | **FIXED** `6c9821c` (half-close) |
23
- | 5 | Defensive | auth/resource-server | OIDC discovery followed redirects with no origin-check on the discovered `jwks_uri` | **FIXED** `6c9821c` (origin pin) |
24
- | 6 | Feature | mcp/server | Configurable server identity — `serverName`/`serverVersion` **and the default `--http`/`--ui` ports** — so a wrapped MCP introduces itself under ITS name and ships its own port defaults. Defaults unchanged. | **DONE** `a734f4b` (toolfunnel.json) |
25
- | 7 | Verify | protocol/register | Confirm the SCHEMA a hot-promoted register tool advertises is the author's real inputSchema (README promises "your own tools and schemas") | **DONE** `4c219e4` — plumbing was sound; tf_tool_add DROPPED the field (fixed), validated + documented now |
26
- | 8 | Verify | reload | Confirm `tools.register.json` edits are picked up live — the file-watchers are proven for `mcp/expose.json` + hooks; direct register edits may need a reload path | **DONE** `4c219e4` — was a REAL BUG (startup snapshot); Registry.reload() + tools/ watcher + state list_changed |
27
- | 9 | Feature | packaging | External config home (`TOOLFUNNEL_HOME` / `--config-dir`) + the full packaging story — see below | **DONE** `108db4e` (home) + `bc88c53` (tf_pack + requires) + `a1fa845` (docs) |
28
-
29
- ---
30
-
31
- ## 9. The packaging story (the 0.4.0 headline)
32
-
33
- **The problem:** an npm/npx install keeps its config *inside the package directory* — an
34
- `npm update` EATS the user's tools, hooks, and curation. Config must live in a home of its own.
35
-
36
- **Four pieces, in dependency order:**
37
-
38
- 1. **Config home** — `TOOLFUNNEL_HOME` env var / `--config-dir` flag; default stays the repo root
39
- for a git clone (unchanged), but an npm-installed gateway defaults to a per-user home. The
40
- enabler for everything below.
41
- 2. **Tool-pack** — a pack IS a zipped config home. Docs, not code: the layout already travels
42
- (`tools/` + `tools.register.json` + `mcp/expose.json` + `hooks/`).
43
- 3. **npm-wrap** — docs + a worked example, no core code: a third party publishes THEIR package
44
- with **`toolfunnel` as a dependency (caret range — DEPEND, NEVER COPY)**, their pack bundled,
45
- and a 2-line `bin` that points toolfunnel at the bundled config home → `npx their-mcp`.
46
- - Their own runtime deps go in THEIR `package.json` — npm installs the whole tree on npx;
47
- script tools resolve `require()` relative to themselves. We add no machinery.
48
- - Why depend-not-copy: every install of their MCP is a toolfunnel download; our security
49
- fixes reach every wrapped MCP via normal `npm update`; no stale forks. (This batch's UI-bind
50
- fix is the worked example of why.)
51
- 4. **requires-preflight** — a `requires` field in the pack manifest (JSON only) checked with
52
- `child_process` version probes + a hand-rolled version compare; friendly missing-runtime
53
- errors ("this pack needs python ≥3.10").
54
-
55
- **★ HARD CONSTRAINT: ToolFunnel itself gains ZERO runtime dependencies from any of this.**
56
- JSON manifest (no yaml), hand-rolled version compare (no semver), node built-ins only.
57
-
58
- **Packs are COMPOSITE** (already true today — `mcp/expose.json` lives in the config home):
59
- own scripts + upstream MCP references + curation in one pack.
60
- - Upstreams travel as REFERENCES (npx self-fetches them; pin versions in the pack).
61
- - Curation travels — ship the 4 chosen tools, not the whole upstream server.
62
- - **THE GATE TRAVELS** — a shipped pack enforces its policy hooks on the recipient's machine
63
- regardless of which MCP client they use. Nobody else can say this sentence.
64
- - Audit honesty: packs spawn commands. The docs say it plainly — read the `expose.json` of
65
- anything you install.
66
-
67
- ---
68
-
69
- ## 1. [HIGH] UI server binds off-loopback with no auth and no refusal
70
-
71
- **Files:** `src/ui/server.js` (`createUiServer().start()`, `isLoopbackHost`), `bin/toolfunnel.js` (`--ui --host`)
72
-
73
- **The asymmetry.** `src/mcp/http-transport.js` `start()` refuses to bind a non-loopback host
74
- unless OAuth is enabled:
75
-
76
- > `refusing to bind non-loopback host "<host>" without OAuth enabled — enable auth (and install jose) before exposing the gateway off localhost.`
77
-
78
- The **UI server has no equivalent guard.** `createUiServer().start()` binds whatever `host`
79
- it is handed, and `bin/toolfunnel.js` passes `--ui --host <anything>` straight through to it.
80
-
81
- **Why the existing check does not cover it.** `isLoopbackHost()` inside the UI's `onRequest`
82
- tests only the **`Host` request header**, which is fully attacker-controlled. It defends against
83
- *browser* DNS-rebinding (the browser sets the Host to the attacker's domain), but does **nothing**
84
- against a direct, non-browser client that simply sends `Host: localhost` / `Host: 127.0.0.1`.
85
-
86
- **Why it matters more than the transport.** The config UI is the one surface that can *spawn
87
- processes and mutate the security posture*:
88
- - `POST /api/mcp/add` + `POST /api/mcp/discover` construct an `Aggregator` and **connect the
89
- configured upstream — i.e. spawn the configured command** (`aggregator.discover` → `_connectOne`
90
- → `clientFactory` → `McpClient.connect`).
91
- - `POST /api/tools/add` / `/api/tools/update` **write tool scripts** to `tools/scripts/`.
92
- - `POST /api/tools/hook` / `/api/hooks/*` **toggle the gate hooks.**
93
-
94
- So `toolfunnel --ui --host 0.0.0.0` (a plausible "let me reach the config UI from my other
95
- machine" move) exposes an **unauthenticated, process-spawning, script-writing console to the
96
- LAN**. The HTTP transport learned this exact lesson; the UI never got the same stitch.
97
-
98
- **Fix.** The UI has no auth path at all, so the correct behaviour is a hard refusal, mirroring the
99
- transport's guard. In `createUiServer().start()`, before `httpServer.listen(...)`:
100
-
101
- ```js
102
- if (!isLoopbackHost(host)) {
103
- return Promise.reject(new Error(
104
- `refusing to bind the config UI to non-loopback host "${host}" — the UI is ` +
105
- `unauthenticated and can spawn processes / write scripts. It is loopback-only by design.`
106
- ));
107
- }
108
- ```
109
-
110
- (Note: `isLoopbackHost` currently returns `true` for an *empty/missing* value — fine for a Host
111
- *header*, but for a *bind address* an empty host should not be treated as loopback. Use a stricter
112
- check on the bind arg, or default-and-validate the bind host explicitly.)
113
-
114
- **Test to add:** `createUiServer({ host: '0.0.0.0' }).start()` rejects; `'127.0.0.1'`, `'localhost'`,
115
- `'::1'` resolve.
116
-
117
- ---
118
-
119
- ## 2. [LOW] `winLaunch` cmd.exe metacharacter interpretation — comment overstates safety
120
-
121
- **File:** `src/mcp/mcp-client.js` (`winLaunch`)
122
-
123
- On Windows, a non-`.exe` upstream command is routed through `cmd.exe /c <command> <args…>` with
124
- `shell:false` (the CVE-2024-27980 `.cmd` workaround). The code comment claims:
125
-
126
- > "The args stay DISCRETE (Node escapes each one under shell:false) so nothing is shell-concatenated
127
- > — no injection surface on this privileged path."
128
-
129
- **That guarantee is not quite real.** With `shell:false` and the command being `cmd.exe` (an
130
- `.exe`), Node applies its *standard argv quoting*, **not** cmd.exe metacharacter (`^`) escaping —
131
- that special-casing only fires when the *command itself* is the `.cmd`/`.bat`. So a config arg
132
- containing `& | > < "` can be reinterpreted by cmd.exe.
133
-
134
- **Real-world risk: near-zero.** These args come from `expose.json`, authored by the operator — the
135
- "attacker" would be the person who owns the config. But the comment claims a property the code does
136
- not provide.
137
-
138
- **Fix (pick one):**
139
- - Downgrade the comment to state the residual cmd.exe-parsing caveat honestly, **or**
140
- - Defensively quote each arg for cmd.exe, **or**
141
- - Prefer resolving the real interpreter path (`where npx` → the `node` + `npm-cli.js` shape, as
142
- `auth/install.js` already does for npm) and spawn that directly with `shell:false`, avoiding
143
- `cmd.exe` entirely.
144
-
145
- ---
146
-
147
- ## 3. [LOW] `_scheduleReconnect` spams `reconnect_slow` every 30s forever
148
-
149
- **File:** `src/mcp/aggregator.js` (`_scheduleReconnect`)
150
-
151
- Past `MAX_FAST_ATTEMPTS` (6) the method re-schedules with `Math.min(attempt + 1, MAX_FAST_ATTEMPTS)`
152
- → the attempt counter **sticks at 6**. The entry logs `if (attempt === MAX_FAST_ATTEMPTS)`, so a
153
- permanently-dead-but-enabled upstream emits a `reconnect_slow` log line **every 30 seconds
154
- indefinitely**, rather than once at the fast→slow transition.
155
-
156
- **Fix.** Log the transition only. E.g. only log when entering the slow phase from the fast phase
157
- (track the previous attempt, or log inside the `attempt + 1` escalation exactly when it crosses
158
- `MAX_FAST_ATTEMPTS`, not on every subsequent capped re-entry).
159
-
160
- ---
161
-
162
- ## 4. [LOW] Streaming over-cap body misses the clean `-32700` the declared-CL path produces
163
-
164
- **File:** `src/mcp/http-transport.js` (`readBody`, `handleMcpPost`)
165
-
166
- The **declared-Content-Length** over-cap path is carefully engineered (`sendJsonAndClose`: announce
167
- `Connection: close`, flush the `-32700` body, *then* destroy the request in the `res.end` callback)
168
- so the client receives a clean parse-error before the close.
169
-
170
- The **streaming/chunked** over-cap path is not held to the same standard: `readBody` rejects and
171
- immediately `req.destroy()`s. Because `req` and `res` share the socket, the follow-up
172
- `sendJson(res, 200, makeError(...))` in `handleMcpPost`'s catch writes to a torn-down socket → the
173
- client sees `ECONNRESET` instead of the tidy `-32700`.
174
-
175
- **Not a crash** — every path here is defensive and the server survives. It is an inconsistency: two
176
- over-cap paths that should behave identically don't.
177
-
178
- **Fix.** Route the streaming over-cap case through the same flush-then-close sequence as
179
- `sendJsonAndClose` (don't `req.destroy()` before the response body has flushed).
180
-
181
- ---
182
-
183
- ## 5. [DEFENSIVE] OIDC discovery follows redirects with no origin-check on discovered `jwks_uri`
184
-
185
- **File:** `src/auth/resource-server.js` (`resolveJwksUri`, `fetchJson`)
186
-
187
- `fetchJson` uses `redirect: 'follow'`, and `resolveJwksUri` accepts whatever `jwks_uri` the issuer's
188
- discovery document advertises with no check that it shares the issuer's origin. The issuer is
189
- operator-configured (trusted), and audience binding is still enforced on the verified token, so this
190
- is standard OIDC behaviour and low-risk — but a defensive origin-pin on the discovered `jwks_uri`
191
- (and/or `redirect: 'error'` on discovery) would tighten the SSRF-adjacent surface.
192
-
193
- **Fix (optional hardening):** assert the discovered `jwks_uri` origin matches the issuer origin, or
194
- make it opt-out-able. Document the decision either way.
195
-
196
- ---
197
-
198
- ## What is already solid (do NOT re-audit from scratch)
199
-
200
- The review found the **gate architecture sound**. Confirmed on 2026-07-03:
201
-
202
- - **`gatedRun` fails CLOSED on every bad-wiring path** — no engine / junk engine return / engine
203
- throw all → `blocked:true`, `execute()` never called. The load-bearing invariant ("a PreToolUse
204
- deny prevents `execute()` from ever running") is real and centralised in one auditable place
205
- (`src/mcp/gated-run.js`).
206
- - **The engine proxy reads `build.engine` live** (`src/mcp/server.js`), so a hot-reloaded hook gates
207
- BOTH the `toolfunnel_run_tool`/lean path AND the curated-direct path — no stale-engine gap.
208
- - **OAuth validator does the things that matter** (`src/auth/resource-server.js`): pins the algorithm
209
- allowlist (blocks alg-confusion + `alg:none`), enforces `audience` (RFC 8707 confused-deputy
210
- defence) and `issuer`, requires `exp` present, never reads the token header to choose the alg.
211
- - **Isolation guard on path-shaped upstream args** (`aggregator.defaultClientFactory` / `isInside` /
212
- `looksLikePath`) — a vendored upstream can't point outside the gateway root; the interpreter slot
213
- is correctly exempt.
214
- - **Path-escape defences** in `registry.writeScript` and `defaultRunScript` (basename + in-root
215
- assertion), and **atomic temp+rename writes** across registry / tool-state / expose-store / auth
216
- config / logger.
217
- - **The hook matcher is correctly full-anchored** (`^(?:…)$` in `src/core/matcher.js`), so a gate on
218
- `read` does NOT leak onto `read_file`; combined with the UI's `escapeRegex` on gate writes, that
219
- path is right.
220
- - **`McpClient` framing was fixed** in v0.3.0 to newline-delimited JSON (was LSP `Content-Length`),
221
- with dual-drain read tolerance — proven against `server-everything` / `server-filesystem` + the
222
- official SDK client. Zombie-guard tree-kill on close.
223
-
224
- The v0.3.0 adversarial pass (backlog task #49) did real work on the OAuth/transport surface. Finding
225
- #1 above is the one gap that pass did not close (it hardened the *transport*, not the *UI*).
1
+ # Known issues & roadmap
2
+
3
+ This file tracks the state of the 0.6.0 dual-era work, the deliberately-deferred items, and any
4
+ limitations worth knowing about.
5
+
6
+ ## 0.6.0 - the 2026-07-28 MCP specification
7
+
8
+ The 2026-07-28 MCP revision is a **breaking** change to the protocol: it removes the `initialize`
9
+ handshake, protocol-level sessions, and the standalone GET/SSE endpoint, and adds new required
10
+ behaviours - per-request protocol metadata, `Mcp-Method` / `Mcp-Name` request headers, a
11
+ `server/discover` endpoint, a `subscriptions/listen` change-notification stream, and
12
+ `resultType` / `ttlMs` / `cacheScope` result fields.
13
+
14
+ Implemented in 0.6.0 (built against the RC spec; a reconciliation pass against the finalised
15
+ spec lands after 28 July):
16
+
17
+ - **Dual-era transport** (2024-11-05 + 2026-07-28) - old and new clients both work.
18
+ - `server/discover`, request-header validation, and the `subscriptions/listen` stream.
19
+ - A **dual-era client**: ToolFunnel speaks modern to modern upstream servers (stdio).
20
+ - An **opt-in, per-upstream legacy-protocol shim** (`legacyPin`) - off by default, names the exact
21
+ version it drops to, warns at startup and on every call, and is enforced (skips the era probe).
22
+ - **Passthrough wrap** (`toolfunnel wrap <upstreamId>`): one command turns the gateway into a
23
+ transparent, both-era wrapper for one upstream MCP - verbatim identity, tool definitions,
24
+ results, errors, notifications, and per-URI resource subscriptions (forwarded, and replayed
25
+ across silent upstream reconnects), with every call still passing the PreToolUse gate.
26
+ - **Elicitation bridging**: a wrapped legacy server's mid-call questions are translated into the
27
+ modern `input_required` + retry pattern and back - multi-round, single-use tokens, TTL'd -
28
+ tested end-to-end against real published elicitation servers.
29
+ - **Cancel translation** (stdio): a client's cancel reaches the upstream in the upstream's own
30
+ request-id space, for forwarded methods and wrapped tool calls alike.
31
+ - **Identity settings + wrap mirroring**: `toolfunnel.json` gains `clientName`/`clientVersion`
32
+ (the identity upstreams see); under a wrap on stdio the real downstream client's identity is
33
+ mirrored upstream automatically.
34
+ - **UI**: a Settings tab (identity + ports), a per-upstream legacy-pin toggle, and the wrap
35
+ security notice surfaced on the UI wrap path (parity with the CLI).
36
+ - **Agent-facing docs**: `toolfunnel_howto` gains `wrap` and `configure` topics, so a plain agent
37
+ can learn the wrap and the whole no-code config map from inside the protocol.
38
+ - **Method-class timeouts**: tool calls (`tools/call`, `prompts/get`, `resources/read`) wait
39
+ 120 s by default - configurable per upstream via `"timeoutMs"` - and a progress report from
40
+ the tool re-arms the window, so a slow-but-alive tool never dies to the clock. The 10 s
41
+ handshake/list window stays fixed as the dead-upstream detector.
42
+ - **Async shell execution**: `shell`-invoke register tools no longer block the event loop
43
+ (was `spawnSync` since 0.5.0) - concurrent HTTP clients keep being served during a long
44
+ shell tool.
45
+ - **Config-home visibility**: every start prints the resolved config home to stderr, with a
46
+ relocation hint when it defaulted to the package root - running from a git clone no longer
47
+ writes config into the repo silently.
48
+ - **Bounded listen streams**: the HTTP transport caps concurrent `subscriptions/listen`
49
+ streams (64) and refuses new ones past the cap with a clear error; existing streams are
50
+ never evicted.
51
+
52
+ ## Deliberately deferred (documented dispositions, target: the 28-July reconciliation pass)
53
+
54
+ - **Legacy version family**: ToolFunnel currently speaks the oldest legacy dialect (`2024-11-05`)
55
+ to legacy upstreams and clamps a wrapped handshake to what the upstream negotiated. A client and
56
+ upstream that both speak a newer legacy revision (e.g. `2025-06-18`) are negotiated down.
57
+ Fidelity of the *fields* is unaffected (verified against real servers - nothing version-gated is
58
+ dropped), but the advertised version string is older than either end requires. DECIDED
59
+ (2026-07-17): best-version negotiation in BOTH directions - newest each peer speaks, modern
60
+ first, legacy fallback. Lands with the reconciliation pass, sequenced after the elicitation
61
+ bridge (a newer legacy offer invites server-initiated requests the client must handle first).
62
+ - **Modern-upstream subscriptions**: the client does not yet open a `subscriptions/listen` stream
63
+ to modern upstreams, so their change-notifications are not received; the listen ack honestly
64
+ refuses `resourceSubscriptions` when no legacy subscribe-capable upstream is in scope.
65
+ - **Elicitation bridge scope**: a wrapped upstream's mid-call `elicitation/create` bridges to
66
+ modern clients as MRTR (`input_required` + retry - built and wire-tested). A LEGACY client
67
+ gets an automatic decline instead (relaying backwards requests to legacy clients is future
68
+ work), and `sampling/createMessage` / `roots/list` from upstreams are answered -32601 for now.
69
+ An elicitation can only be bound to a call when exactly ONE wrapped call is in flight for that
70
+ upstream (always true on stdio); ambiguous concurrent HTTP calls decline rather than guess.
71
+ - Header validation reports `-32020` where `-32022` would be more specific in one path.
72
+ - Two-store wrap state read (expose.json + tools.state.json) has a benign TOCTOU window.
73
+ - A meta-less `server/discover` is answered rather than refused (permissive-by-design).
74
+ - The wrap's identity-mirror reconnect re-runs the full era negotiation - worst case ~3 s extra
75
+ on the first handshake against an upstream that ignores `server/discover`. Reusing the
76
+ already-known era on reconnect is a deferred optimisation.
77
+ - HTTP cancel translation: the sessionless modern era gives a POST no connection identity, so a
78
+ client `notifications/cancelled` over HTTP is dropped rather than risk cancelling another
79
+ client's call. stdio (one client per pipe) translates and forwards cancels for both forwarded
80
+ methods and wrapped tools/call; the remaining best-effort windows are sub-millisecond (a cancel
81
+ racing the instant a forward is issued) plus the PreToolUse gate-evaluation phase of a tool
82
+ call (registered at upstream-issue time, post-gate).
83
+ - `io.modelcontextprotocol/logLevel` (the modern per-request log level) is stripped with the rest
84
+ of the protocol `_meta` keys on wrapped forwards and not re-injected for modern upstreams -
85
+ part of the modern-upstream work above.
86
+ - Discover on a DISABLED upstream works (inspect its tools before enabling - an explicit
87
+ allowance on the discover path only; the disabled upstream never reaches the tool surface).
88
+
89
+ ## Current limitations
90
+
91
+ - The OAuth 2.1 resource-server and Streamable-HTTP support are recent and less battle-tested than
92
+ the core - test your setup before relying on them in a networked deployment.
93
+ - No Prometheus / OpenTelemetry metrics - a toggleable JSONL audit log and in-memory `/health`
94
+ call counters only.
95
+ - One gateway instance per process: the bridge/cancel state is process-global, so embedding two
96
+ gateway instances in one Node process is not a supported configuration (running two processes
97
+ is fine, and is the normal deployment).
98
+
99
+ Found a bug, or want to help with the 0.6.0 work? Issues and PRs are welcome.
Binary file
package/docs/design.md ADDED
@@ -0,0 +1,70 @@
1
+ # Design decisions
2
+
3
+ Short notes on the decisions that shape ToolFunnel, so you can judge the reasoning without
4
+ reading the source. Each one is also documented at the place in the code where it bites.
5
+
6
+ ## 1. Zero runtime dependencies
7
+
8
+ ToolFunnel sits in a privileged position: it executes tools and enforces policy. Every
9
+ transitive dependency in that position is something you have to trust or audit. So the gateway
10
+ uses Node built-ins only; "dependencies" in package.json is empty and stays empty. The one
11
+ exception is deliberate and opt-in: OAuth token validation uses jose, installed only when you
12
+ run `toolfunnel install-oauth`, version-pinned, because hand-rolled JWT crypto is how security
13
+ holes are born. Dev tooling for the test suite (jose, the MCP SDK for interop tests) never
14
+ ships.
15
+
16
+ ## 2. No SDK
17
+
18
+ The gateway hand-rolls JSON-RPC over stdio and HTTP rather than building on the official MCP
19
+ SDK. Two reasons. First, the zero-dependency stance above. Second, ToolFunnel has to speak two
20
+ protocol eras at once, on both sides, which the SDK does not do; owning the wire layer is what
21
+ makes the dual-era serving and the wrap possible at all.
22
+
23
+ ## 3. The gate fails closed
24
+
25
+ Every server-side execution path goes through one function, gatedRun, and that function treats
26
+ every failure as a deny: a hook that blocks, a hook engine that crashes, a malformed engine
27
+ result, all of them mean the tool does not run. Denies carry a flag distinguishing operator
28
+ policy ("your hook said no") from wiring failure ("the gate itself broke"), so a broken gate
29
+ can never be mistaken for an approving one. The invariant has its own test.
30
+
31
+ ## 4. The isolation boundary
32
+
33
+ Upstream server definitions can name any executable but their path-shaped arguments must stay
34
+ inside the config home. The command is the interpreter you chose; the args are what it can
35
+ reach, so the args are what the guard checks. Wrapping suspends the guard for the wrapped
36
+ server only, because a wrap is an explicit statement that this one server is your entire
37
+ surface, and it warns you when that happens.
38
+
39
+ ## 5. Two eras, served per request
40
+
41
+ The 2026-07-28 revision removes the handshake that defines the older protocol, so "which era"
42
+ is a property of each request, and that is how the gateway treats it: era detection per
43
+ request, no mode switch, no configuration. Legacy answers stay byte-identical to what a
44
+ pre-0.6.0 client saw; the gateway's own modern decoration only ever appears on modern
45
+ requests. (A wrapped modern upstream's results are relayed verbatim, whatever era the
46
+ caller speaks - stripping a server's own fields would be data loss and a wrapper tell.)
47
+
48
+ ## 6. Config lives in one folder
49
+
50
+ Everything mutable (tools, upstreams, hooks, identity, logs) lives in the config home,
51
+ relocatable with `--config-dir` or `TOOLFUNNEL_HOME`, seeded once, never overwritten by
52
+ updates. One folder to back up, one folder to package, one folder to hand a colleague. The
53
+ gateway prints the resolved home at every start so there is never any doubt about where state
54
+ lives.
55
+
56
+ ## 7. Single operator by design
57
+
58
+ Each ToolFunnel instance serves one operator. That is a decision, not an oversight: one
59
+ instance per user gives process isolation, crash containment, per-user wrap state, and a
60
+ security boundary you can reason about in one sentence. Multi-user deployment composes *in
61
+ front of* the gateway rather than inside it - an authenticating proxy at the OAuth boundary
62
+ (ToolFunnel is already an OAuth 2.1 resource server) routing each authenticated user to their
63
+ own instance, with the per-request `_meta` lane carrying user context through to upstreams
64
+ that want it. Nothing in the core couples the operator to a token subject, so that door stays
65
+ open without the core ever growing user-shaped complexity.
66
+
67
+ A Team edition built on this pattern - per-user identity, audit trails, quotas, per-user
68
+ policy - is on the roadmap if there is interest. If your team wants that sooner, or you need a
69
+ bespoke gateway built on ToolFunnel for your own requirements, open an issue on this
70
+ repository titled `enquiry` - tailored builds are available.