toolfunnel 0.3.0 → 0.4.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
@@ -4,6 +4,10 @@
4
4
 
5
5
  > 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.
6
6
 
7
+ **Zero code, zero dependencies: your own MCP server in 60 seconds** — from three ordinary scripts to a named, packaged, policy-gated MCP server (everything on screen is real output; see [demo/](demo/)):
8
+
9
+ ![ToolFunnel in 60 seconds: scripts → register → identity → live MCP server → npm package](demo/toolfunnel-demo.gif)
10
+
7
11
 
8
12
  ## The problem
9
13
 
@@ -25,7 +29,7 @@ ToolFunnel is one small MCP server that sits between your agent and everything e
25
29
  2. **Forwards other MCP servers - leanly.** Attach an upstream MCP and its tools appear in the same lean register as your own (briefs + instructions-on-demand), runnable through `toolfunnel_run_tool` and the gate. Curate which appear, promote a chosen few to top-level "every-turn" tools, or leave them lean by default. Every forwarded call still passes the gate.
26
30
  3. **Lean register** - the agent sees short tool *briefs*; the full instructions for a tool are fetched **on demand**, so context stays small. *(This is the token saver.)*
27
31
  4. **Server-side policy gate** - every server-side execution path fires your PreToolUse / PostToolUse hooks **inside the gateway**, so your policy works on *any* client, not just hosts that support hooks. The gate travels with the gateway, and **fails closed**.
28
- 5. **Configure by file, UI, or in-band** - plain JSON files, an optional loopback web UI (`node bin/toolfunnel.js --ui`), or eight in-band **management functions** all add / curate / toggle tools, upstreams, and hooks - live, no restart.
32
+ 5. **Configure by file, UI, or in-band** - plain JSON files, an optional loopback web UI (`node bin/toolfunnel.js --ui`), or nine in-band **management functions** all add / curate / toggle tools, upstreams, and hooks - live, no restart - and package the whole setup for deployment.
29
33
  6. **Audit when you want it** - a toggleable JSONL log (**default off**) records tool runs, every gate allow/deny decision, and every upstream connect / disconnect / reconnect.
30
34
  7. **Live & self-healing** - attach/curate/toggle tools, upstreams, and hooks on a *running* gateway with no restart; and if an attached MCP's process dies, the gateway detects it and reconnects in the background with backoff.
31
35
 
@@ -84,7 +88,7 @@ They're just here to get you started - turn any of them off or delete them as yo
84
88
 
85
89
  ### Management functions
86
90
 
87
- The gateway can manage **itself**. Eight first-party tools in the `management` category let an agent (or the web UI) add, curate, and toggle the gateway's own configuration. They are ordinary register tools - reached through `toolfunnel_run_tool`, so every call passes the same gate as any other gateway tool (a single PreToolUse matcher such as `tf_.*` locks the whole management surface down, since each is named `tf_*`).
91
+ The gateway can manage **itself**. Nine first-party tools in the `management` category let an agent (or the web UI) add, curate, toggle, and package the gateway's own configuration. They are ordinary register tools - reached through `toolfunnel_run_tool`, so every call passes the same gate as any other gateway tool (a single PreToolUse matcher such as `tf_.*` locks the whole management surface down, since each is named `tf_*`).
88
92
 
89
93
  | Function | What it does |
90
94
  |----------------|------------------------------------------------------------------------------|
@@ -96,6 +100,7 @@ The gateway can manage **itself**. Eight first-party tools in the `management` c
96
100
  | `tf_hook_set` | Enable, disable, or remove a hook (live toggles via the `hooks.state.json` overlay). |
97
101
  | `tf_list` | List register tools, upstream MCPs, or hooks with their live active state (read-only). Hidden tools are omitted by default - pass `{ includeHidden: true }` to show them. |
98
102
  | `tf_log` | Enable, disable, or check the [audit log](#activity--audit-log) (`{ action: enable\|disable\|status }`). |
103
+ | `tf_pack` | Package the live setup into a deployment artifact under `dist/` - a portable config home, or a publishable npm package wrapping toolfunnel. See [Packaging](#packaging-ship-your-own-mcp). |
99
104
 
100
105
  Tool / hook register edits are **live** (re-read per call, no reconnect). Upstream attach/curate changes are picked up live too: the gateway watches its config and reloads - reconnecting upstreams and emitting `notifications/tools/list_changed` - with no restart.
101
106
 
@@ -301,7 +306,7 @@ toolfunnel/
301
306
  └─ toolfunnel.log.jsonl # the JSONL records (default path)
302
307
  ```
303
308
 
304
- Rule of thumb: `src/` is the gateway machinery; `tools/`, `mcp/`, and `hooks/` are where your configuration lives. The engine resolves these top-level paths relative to the repo root.
309
+ Rule of thumb: `src/` is the gateway machinery; `tools/`, `mcp/`, and `hooks/` are where your configuration lives. By default these resolve relative to the repo root — or point them anywhere with a **config home** (`--config-dir` / `TOOLFUNNEL_HOME`): an empty home is seeded from the shipped defaults on first use and never overwritten after, so an `npm update` can never eat your tools. See [docs/packaging.md](docs/packaging.md).
305
310
 
306
311
  ## Configuration
307
312
 
@@ -309,16 +314,29 @@ All config is plain JSON under the repo - edit it by hand, through the [config w
309
314
 
310
315
  | File | Purpose |
311
316
  |-----------------------------------|-------------------------------------------------------------------------|
312
- | `tools/tools.register.json` | The first-party tool register (`{ version, description, tools: [...] }`) - 7 demos + 8 management functions |
317
+ | `tools/tools.register.json` | The first-party tool register (`{ version, description, tools: [...] }`) - 7 demos + 9 management functions. Entries may carry an `inputSchema`, advertised verbatim when the tool is promoted hot |
313
318
  | `tools/tools.state.json` | The visibility-matrix overlay, keyed by surfaced name → `{ enabled?, hidden?, hot? }`. Default-ON for `enabled` (empty `{}` enables everything), default-OFF for `hot`/`hidden` (meta-tools default `hot`-ON). Read fresh per call - no restart needed |
314
319
  | `hooks/hooks.manifest.json` | The policy gate (`{ version, hooks: [] }`). An empty `hooks` array = allow-all |
315
320
  | `hooks/hooks.state.json` | Optional live enable/disable overlay for hooks; an entry here wins over the manifest's seed `enabled` |
316
321
  | `mcp/expose.json` | Upstream MCP servers + the curated set of their tools to expose (`{ version, upstreams: [], expose: [] }`). **Empty by default**, so the gateway connects to nothing |
317
322
  | `mcp/expose.example.json` | An annotated sample showing how to wire an upstream and expose two of its tools |
318
323
  | `logs/log.config.json` | The audit-log toggle (`{ enabled, path }`). **Default OFF**; created only when logging is enabled |
324
+ | `toolfunnel.json` | Optional identity + defaults: `{ serverName, serverVersion, httpPort, uiPort, requires }`. Absent = the gateway is plain `toolfunnel`. This is how a packaged MCP introduces itself as ITSELF |
325
+
326
+ ## Packaging: ship your own MCP
327
+
328
+ Everything you build here — tools, curation, upstream selections, policy hooks, identity — lives
329
+ in one folder (the config home), and one management call packages it:
330
+
331
+ - `tf_pack { format: "home" }` → a portable setup: zip it, git it, or run it with `--config-dir`.
332
+ - `tf_pack { format: "npm", name: "my-mcp" }` → a **publishable npm package** that depends on
333
+ toolfunnel (never forks it), bundles your setup, and gives your users `npx my-mcp` — your
334
+ server, your name in the handshake, your tools and schemas, **your gate enforced on their
335
+ machine regardless of client**.
336
+
337
+ Declare runtime needs (`python`, `git`, …) in `toolfunnel.json` `requires` and the gateway warns
338
+ at startup about anything missing. Full story: [docs/packaging.md](docs/packaging.md).
319
339
 
320
340
  ## License
321
341
 
322
342
  [MIT](LICENSE)
323
- </content>
324
- </invoke>
package/bin/toolfunnel.js CHANGED
@@ -39,13 +39,40 @@ if (args.includes('--help') || args.includes('-h')) {
39
39
  ' toolfunnel --ui Start the config web UI (127.0.0.1:9777)',
40
40
  ' toolfunnel --ui --port <n> Bind a specific UI port (0 = OS-assigned)',
41
41
  ' toolfunnel install-oauth Install the optional OAuth 2.1 dependency (jose)',
42
+ ' toolfunnel --config-dir <dir> Use <dir> as the CONFIG HOME (see below)',
42
43
  ' toolfunnel --help Show this help',
43
44
  '',
45
+ 'CONFIG HOME: the mutable config (tools/ mcp/ hooks/ auth/ logs/ toolfunnel.json) lives in',
46
+ 'the config home — by default the package root (a git clone works as before), or the value',
47
+ 'of --config-dir / the TOOLFUNNEL_HOME env var. An empty home is seeded from the shipped',
48
+ 'defaults on first use and is NEVER overwritten after — so an npm update cannot eat your',
49
+ 'tools. This is also how a wrapped MCP ships its own bundled setup.',
50
+ '',
51
+ 'Identity + port defaults can be set in an OPTIONAL toolfunnel.json in the config home:',
52
+ ' { "serverName": "my-mcp", "serverVersion": "1.0.0", "httpPort": 9998, "uiPort": 9777 }',
53
+ 'A --port flag always wins over the config value.',
54
+ '',
44
55
  ].join('\n')
45
56
  );
46
57
  process.exit(0);
47
58
  }
48
59
 
60
+ // Resolve + seed the config home FIRST — before any src/ module is required — so every
61
+ // module-load-time anchor (register/expose/hooks paths, auth + log config, identity) reads the
62
+ // same resolved home. Precedence: --config-dir > TOOLFUNNEL_HOME env > the package root.
63
+ const { initConfigHome } = require('../src/core/config-home');
64
+ const configHome = initConfigHome({ dir: flagValue('--config-dir', '') }).home;
65
+
66
+ // Advisory runtime preflight: the home's toolfunnel.json may declare `requires` (python, git, …)
67
+ // for the tools it ships. A missing/old runtime breaks only the tools that need it, so each
68
+ // problem is one loud stderr line and the gateway starts anyway. Never fatal, never throws.
69
+ try {
70
+ const { checkRequires } = require('../src/core/requires');
71
+ for (const p of checkRequires(configHome)) {
72
+ process.stderr.write('[toolfunnel] REQUIRES: ' + p.problem + '\n');
73
+ }
74
+ } catch (_e) { /* preflight must never stop the gateway */ }
75
+
49
76
  if (args[0] === 'install-oauth') {
50
77
  // Opt-in OAuth: pull the single audited dependency (jose) on demand. Keeps the default install
51
78
  // at zero runtime dependencies — you only ever fetch jose if you decide you need OAuth.
@@ -68,9 +95,13 @@ if (args[0] === 'install-oauth') {
68
95
  });
69
96
  } else if (args.includes('--ui')) {
70
97
  const { createUiServer } = require('../src/ui/server');
98
+ const { loadServerConfig } = require('../src/core/server-config');
99
+ const { resolveConfigHome } = require('../src/core/config-home');
100
+ const cfg = loadServerConfig(resolveConfigHome());
71
101
  const host = flagValue('--host', '127.0.0.1');
72
- const parsedPort = parseInt(flagValue('--port', '9777'), 10);
73
- const port = Number.isInteger(parsedPort) ? parsedPort : 9777;
102
+ // Precedence: --port flag > toolfunnel.json uiPort > 9777 (loadServerConfig folds in the default).
103
+ const parsedPort = parseInt(flagValue('--port', String(cfg.uiPort)), 10);
104
+ const port = Number.isInteger(parsedPort) ? parsedPort : cfg.uiPort;
74
105
  const server = createUiServer({ host, port });
75
106
  server
76
107
  .start()
@@ -88,9 +119,13 @@ if (args[0] === 'install-oauth') {
88
119
  process.on('SIGTERM', shutdown);
89
120
  } else if (args.includes('--http')) {
90
121
  const { createHttpMcpServer } = require('../src/mcp/http-transport');
122
+ const { loadServerConfig } = require('../src/core/server-config');
123
+ const { resolveConfigHome } = require('../src/core/config-home');
124
+ const cfg = loadServerConfig(resolveConfigHome());
91
125
  const host = flagValue('--host', '127.0.0.1');
92
- const parsedPort = parseInt(flagValue('--port', '9998'), 10);
93
- const port = Number.isInteger(parsedPort) ? parsedPort : 9998;
126
+ // Precedence: --port flag > toolfunnel.json httpPort > 9998 (loadServerConfig folds in the default).
127
+ const parsedPort = parseInt(flagValue('--port', String(cfg.httpPort)), 10);
128
+ const port = Number.isInteger(parsedPort) ? parsedPort : cfg.httpPort;
94
129
  const server = createHttpMcpServer({ host, port });
95
130
  server
96
131
  .start()
@@ -0,0 +1,225 @@
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*).
@@ -0,0 +1,115 @@
1
+ # Packaging: ship what you built
2
+
3
+ ToolFunnel's config is a folder. That single fact is the whole packaging story: everything you
4
+ build in a gateway — your tools and their scripts, which of them are promoted hot or hidden, the
5
+ upstream MCPs you attached and the tools you chose to expose from them, the policy hooks that gate
6
+ every call, and the identity the server introduces itself with — lives in one directory tree, the
7
+ **config home**. Ship the tree and you have shipped the product.
8
+
9
+ ## The config home
10
+
11
+ By default the home is the package/clone root — a git clone works exactly as it always did. Point
12
+ the gateway elsewhere and the config lives there instead:
13
+
14
+ ```
15
+ toolfunnel --config-dir D:\my-setup # flag (wins)
16
+ TOOLFUNNEL_HOME=D:\my-setup toolfunnel # env var
17
+ ```
18
+
19
+ An empty home is **seeded** on first use from the shipped defaults (the register + management
20
+ scripts, an empty `mcp/expose.json`, the default hooks manifest) and is **never overwritten**
21
+ afterwards — which is the point: with the home outside the package, `npm update toolfunnel`
22
+ can never eat your tools.
23
+
24
+ ```
25
+ <home>/
26
+ toolfunnel.json # identity (serverName/serverVersion), port defaults, `requires`
27
+ tools/
28
+ tools.register.json # your tool entries (incl. their inputSchema)
29
+ tools.state.json # the hot/hidden/enabled curation — part of the product
30
+ scripts/ # the tool scripts
31
+ mcp/expose.json # upstream references + curated exposure
32
+ hooks/ # the policy gate: manifest + hook scripts
33
+ ```
34
+
35
+ ## `tf_pack` — one call to a deployment artifact
36
+
37
+ The `tf_pack` management tool snapshots the LIVE setup into `<home>/dist/<name>/` — always a
38
+ separate location, never the live tree. `auth/` (environment-specific) and `logs/` never ship.
39
+
40
+ **`{ format: "home" }`** → a portable config home. Zip it, commit it to a repo, or run it as-is:
41
+
42
+ ```
43
+ toolfunnel --config-dir path/to/the-pack
44
+ ```
45
+
46
+ **`{ format: "npm", name: "my-mcp", version: "1.0.0" }`** → a publishable npm package:
47
+
48
+ ```
49
+ dist/my-mcp/
50
+ package.json # name, bin, files, dependencies: { "toolfunnel": "^0.4.0" }
51
+ bin/my-mcp.js # 2 lines: pin --config-dir at ../home, delegate to toolfunnel
52
+ home/ # the bundled config home (identity rewritten to my-mcp@1.0.0)
53
+ README.md # a stub to make your own
54
+ ```
55
+
56
+ Review it, then `cd dist/my-mcp && npm publish`. Your users run `npx my-mcp` and get **your**
57
+ server: your name in the MCP `initialize` handshake, your tools with your schemas, your curation,
58
+ and — the part nobody else can say — **your gate**. The hooks travel inside the pack and enforce
59
+ your policy on the recipient's machine regardless of which MCP client calls it.
60
+
61
+ ### Depend, never copy
62
+
63
+ The generated package **depends on** `toolfunnel` (caret range) rather than vendoring it. Your own
64
+ runtime dependencies go in **your** `package.json` exactly as with any npm package — npm installs
65
+ the whole tree on `npx my-mcp`; toolfunnel itself adds zero. Why depending matters:
66
+
67
+ - security and bug fixes reach every wrapped MCP through a normal `npm update` — no stale forks
68
+ carrying old bugs;
69
+ - your package stays a few KB of config instead of a copied engine;
70
+ - (and honestly) every install of your MCP counts as a toolfunnel download, which keeps the thing
71
+ you depend on alive.
72
+
73
+ Your users can move the bundled home out of `node_modules` (so *their* edits survive *your*
74
+ updates) via the `<NAME>_HOME` env var the generated launcher supports.
75
+
76
+ ## Declaring runtimes: `requires`
77
+
78
+ Your tools may need runtimes the gateway doesn't (python, git, ffmpeg…). Declare them in the
79
+ home's `toolfunnel.json` and they travel with every pack:
80
+
81
+ ```json
82
+ {
83
+ "serverName": "my-mcp",
84
+ "requires": [
85
+ { "command": "python", "min": "3.10", "why": "the pdf-extract tools" },
86
+ { "command": "git" }
87
+ ]
88
+ }
89
+ ```
90
+
91
+ The gateway probes them once at startup and prints one clear stderr line per problem — found
92
+ version vs needed, plus your `why`. Advisory by design: a missing runtime breaks only the tools
93
+ that need it, so the server still starts and everything else keeps working.
94
+
95
+ ## Composite packs
96
+
97
+ A pack is not just your own scripts. Because `mcp/expose.json` lives in the home, a pack carries
98
+ **upstream references too**: "attach `@example/weather-mcp` (pin the version) and expose exactly
99
+ these four tools under these names." The upstream itself travels as a *reference* — `npx` fetches
100
+ it on the recipient's machine — while your **curation** and your **gate** travel as config. One
101
+ pack = your tools + other people's servers, curated, renamed, and policed, as a single artifact.
102
+
103
+ ## Audit honesty
104
+
105
+ Packs spawn commands — that is what they are for. Before you run anyone's pack (including one of
106
+ ours), read **three files** — every command it can run is declared in plain JSON:
107
+
108
+ - `tools/tools.register.json` — the tool invokes (scripts and shell commands);
109
+ - `mcp/expose.json` — the upstream server commands it will spawn;
110
+ - `toolfunnel.json` — the `requires` probes. Note these run **at gateway startup** (each declared
111
+ program is executed once with its version flag), which is why a `requires` command must be a
112
+ bare program name — the gateway rejects paths, spaces, and shell characters outright.
113
+
114
+ Ship your own packs expecting the same scrutiny, and say so in your README (the `tf_pack` stub
115
+ already does).
@@ -0,0 +1,31 @@
1
+ # Releasing
2
+
3
+ The entire release surface is one command:
4
+
5
+ ```bash
6
+ npm run release # patch bump: test → commit → tag → push → GitHub Release → npm publish
7
+ npm run release -- minor # minor bump (or: major)
8
+ npm run release -- --notes "headline" # headline paragraph for the release notes
9
+ npm run release -- --no-npm # GitHub-only release (skip npm publish)
10
+ npm run release -- --dry-run # print the full plan, execute nothing
11
+ ```
12
+
13
+ What it does, in order:
14
+
15
+ 1. **Preflight** — refuses to run on a dirty tree, off `main`, or behind `origin/main`.
16
+ 2. **Tests** — the full suite must pass or the release aborts before anything mutates.
17
+ 3. **Bump** — rewrites `package.json` (the server's `serverInfo.version` follows it automatically).
18
+ 4. **Commit + tag** — one commit (`vX.Y.Z: <headline>`), one annotated tag, both pushed.
19
+ 5. **GitHub Release** — created via the API. Auth comes per-use from `git credential fill`;
20
+ the token is never written to disk or printed. Release body = your `--notes` headline
21
+ plus the commit subjects since the previous tag.
22
+ 6. **npm publish** — interactive (you may be prompted for an OTP). If it fails, the GitHub
23
+ side is already complete; re-run `npm publish` by hand when ready.
24
+
25
+ Policy: releases only ever **add**. Existing tags, GitHub Releases and published npm
26
+ versions are never deleted, overwritten, or re-pointed. If a release is bad, ship a fixed
27
+ version on top.
28
+
29
+ Directory listings (Glama, awesome-mcp-servers and similar) crawl the GitHub side on
30
+ their own schedules — publishing a GitHub Release is the strongest signal they consume.
31
+ Nothing extra to do per release.
package/docs/setup.md CHANGED
@@ -92,7 +92,7 @@ for you (section 5). A quick smoke test:
92
92
  echo '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}' | node bin/toolfunnel.js
93
93
  ```
94
94
 
95
- You should get one JSON line back with `"serverInfo":{"name":"toolfunnel","version":"0.3.0"}`
95
+ You should get one JSON line back with `"serverInfo":{"name":"toolfunnel","version":"<current version>"}`
96
96
  and `"protocolVersion":"2024-11-05"`.
97
97
 
98
98
  > **Working directory doesn't matter.** ToolFunnel resolves its config (`tools/`, `mcp/`,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolfunnel",
3
- "version": "0.3.0",
3
+ "version": "0.4.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",
@@ -28,9 +28,11 @@
28
28
  },
29
29
  "scripts": {
30
30
  "start": "node bin/toolfunnel.js",
31
+ "build": "echo toolfunnel has no build step - zero-dependency, runs from source",
31
32
  "http": "node bin/toolfunnel.js --http",
32
33
  "install-oauth": "node bin/toolfunnel.js install-oauth",
33
34
  "test": "node test/run-all.js",
35
+ "release": "node scripts/release.js",
34
36
  "test:integration": "node test/integration-real-mcp.test.js && node test/integration-http-client.test.js"
35
37
  },
36
38
  "dependencies": {},
@@ -36,8 +36,10 @@
36
36
  const fs = require('node:fs');
37
37
  const path = require('node:path');
38
38
 
39
- /** Repo root: <root>/src/auth/config.js -> two dirs up. */
40
- const ROOT = path.resolve(__dirname, '..', '..');
39
+ /** The CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package root — see
40
+ * src/core/config-home.js). Auth config is user-state, so it lives with the home. */
41
+ const { resolveConfigHome } = require('../core/config-home');
42
+ const ROOT = resolveConfigHome();
41
43
 
42
44
  /** The toggle/config file. NOT created until setConfig() writes it. */
43
45
  const CONFIG_PATH = path.join(ROOT, 'auth', 'auth.config.json');
@@ -112,7 +112,27 @@ async function resolveJwksUri(cfg, timeoutMs) {
112
112
  for (const url of candidates) {
113
113
  try {
114
114
  const doc = await fetchJson(url, timeoutMs);
115
- if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0) return doc.jwks_uri;
115
+ if (doc && typeof doc.jwks_uri === 'string' && doc.jwks_uri.length > 0) {
116
+ // ORIGIN-PIN the discovered value. The ISSUER is operator-trusted config, but the discovery
117
+ // DOCUMENT arrives over the network — it must not be able to point key fetching at a
118
+ // foreign origin (key-substitution + SSRF-adjacent surface). Cross-origin JWKS hosting is
119
+ // real (e.g. Google's issuer and JWKS live on different hosts), so the escape hatch is an
120
+ // EXPLICIT trust statement: set "jwksUri" in the auth config and discovery is skipped.
121
+ let sameOrigin = false;
122
+ try {
123
+ sameOrigin = new URL(doc.jwks_uri).origin === new URL(cfg.issuer).origin;
124
+ } catch (_e) {
125
+ /* malformed URL on either side → treat as cross-origin → refuse */
126
+ }
127
+ if (!sameOrigin) {
128
+ throw new Error(
129
+ `discovered jwks_uri "${doc.jwks_uri}" is not on the issuer origin — refusing the ` +
130
+ 'cross-origin discovery result. If your IdP intentionally hosts JWKS on a separate ' +
131
+ 'origin, state that trust explicitly by setting "jwksUri" in auth/auth.config.json.'
132
+ );
133
+ }
134
+ return doc.jwks_uri;
135
+ }
116
136
  lastErr = new Error(`discovery doc at ${url} has no jwks_uri`);
117
137
  } catch (e) {
118
138
  lastErr = e;
@@ -121,13 +141,18 @@ async function resolveJwksUri(cfg, timeoutMs) {
121
141
  throw lastErr || new Error('OIDC discovery failed');
122
142
  }
123
143
 
124
- /** GET + parse JSON with a hard timeout. Throws on non-2xx, network error, or bad JSON. */
144
+ /**
145
+ * GET + parse JSON with a hard timeout. Throws on non-2xx, network error, bad JSON — or a REDIRECT:
146
+ * discovery metadata must come from the exact well-known URL derived from the trusted issuer
147
+ * (following a redirect would let a bounced endpoint serve the document from anywhere, hollowing
148
+ * out the origin pin above). Only discovery uses this helper; the JWKS fetch itself is jose's.
149
+ */
125
150
  async function fetchJson(url, timeoutMs) {
126
151
  const ac = new AbortController();
127
152
  const ms = Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : 10000;
128
153
  const t = setTimeout(() => ac.abort(), ms);
129
154
  try {
130
- const res = await fetch(url, { signal: ac.signal, redirect: 'follow' });
155
+ const res = await fetch(url, { signal: ac.signal, redirect: 'error' });
131
156
  if (!res || !res.ok) throw new Error(`GET ${url} -> HTTP ${res ? res.status : 'no-response'}`);
132
157
  return await res.json();
133
158
  } finally {