toolfunnel 0.4.0 → 0.5.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.
@@ -1,225 +1,33 @@
1
- # ToolFunnel — 0.4.0 Board (bugs + features)
1
+ # Known issues & roadmap
2
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.
3
+ ToolFunnel has no known correctness bugs at this release. This file tracks the upcoming
4
+ compatibility work and any limitations worth knowing about.
12
5
 
13
- ---
6
+ ## Coming in 0.6.0 — the 2026-07-28 MCP specification
14
7
 
15
- ## The board
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.
16
13
 
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) |
14
+ ToolFunnel 0.5.0 speaks the current `2024-11-05` protocol and works with every MCP client shipping
15
+ today. **0.6.0** will add **dual-era** support — answering both the current handshake-based clients
16
+ *and* the 2026-07-28 modern clients — built against the finalised spec after 28 July.
28
17
 
29
- ---
18
+ Planned for 0.6.0:
30
19
 
31
- ## 9. The packaging story (the 0.4.0 headline)
20
+ - **Dual-era transport** (current + 2026-07-28) so old and new clients both keep working.
21
+ - `server/discover`, the new request-header validation, and the `subscriptions/listen` stream.
22
+ - An **opt-in, per-upstream legacy-protocol shim**: keep an ageing MCP server working past the
23
+ cutover by pinning the protocol version ToolFunnel speaks *to that upstream* — off by default,
24
+ it names the exact version it drops to and warns at startup and on every call.
32
25
 
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.
26
+ ## Current limitations
35
27
 
36
- **Four pieces, in dependency order:**
28
+ - The OAuth 2.1 resource-server and Streamable-HTTP support are recent and less battle-tested than
29
+ the core — test your setup before relying on them in a networked deployment.
30
+ - No Prometheus / OpenTelemetry metrics — a toggleable JSONL audit log and in-memory `/health`
31
+ call counters only.
37
32
 
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*).
33
+ Found a bug, or want to help with the 0.6.0 work? Issues and PRs are welcome.
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "toolfunnel",
3
- "version": "0.4.0",
3
+ "version": "0.5.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",
@@ -300,6 +300,8 @@ function createHttpMcpServer(opts = {}) {
300
300
  /** @type {boolean} latch so stop() is idempotent and start() can't double-bind. */
301
301
  let started = false;
302
302
  let stopping = false;
303
+ /** @type {(() => void)|null} teardown for the config hot-reload watchers (armed in start()). */
304
+ let configWatchersStop = null;
303
305
  /**
304
306
  * The result of the LAST connectAll() (from start() or reload()), surfaced by health() so a
305
307
  * silent upstream connect-failure becomes VISIBLE (otherwise it only reaches stderr via log()).
@@ -414,6 +416,16 @@ function createHttpMcpServer(opts = {}) {
414
416
  return pushToSse({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
415
417
  }
416
418
 
419
+ /**
420
+ * Wire the CURRENT build's aggregator "tools changed" signal (a background reconnect finally
421
+ * winning an upstream) to the SSE broadcast, so an HTTP client honouring listChanged re-fetches.
422
+ * Re-called after every build (re)creation because a fresh aggregator has no listener — the stdio
423
+ * path does the equivalent in main(). NEVER throws.
424
+ */
425
+ function wireAggregatorNotify() {
426
+ if (build && build.aggregator) build.aggregator.onToolsChanged = () => broadcastToolsListChanged();
427
+ }
428
+
417
429
  // ── OAuth 2.1 resource-server gate (OPTIONAL; default OFF) ──────────────────────────────────
418
430
  // The validator is memoised and rebuilt ONLY when the relevant auth config changes — so a live
419
431
  // enable/disable/edit takes effect on the next request WITHOUT discarding jose's JWKS cache in
@@ -782,6 +794,7 @@ function createHttpMcpServer(opts = {}) {
782
794
  // the very first request. connectAll() NEVER throws (failures land in failed[]); with the
783
795
  // default EMPTY expose.json it is an instant no-op.
784
796
  build = buildFactory();
797
+ wireAggregatorNotify();
785
798
  if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
786
799
  try {
787
800
  const res = await build.aggregator.connectAll();
@@ -829,6 +842,15 @@ function createHttpMcpServer(opts = {}) {
829
842
  boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
830
843
  started = true;
831
844
  stopping = false;
845
+ // Arm config hot-reload now the build is live + bound. Pass a GETTER so the reloaders
846
+ // always target the CURRENT build (reload() rebuilds it); send = pushToSse so a
847
+ // hook/register/expose change broadcasts notifications/tools/list_changed to SSE clients.
848
+ // This is the wiring the HTTP host was missing — stdio's main() has always done it.
849
+ try {
850
+ configWatchersStop = require('./server').startConfigWatchers(() => build, (n) => pushToSse(n));
851
+ } catch (e) {
852
+ log('config watchers failed to arm (host runs, without hot-reload):', (e && e.message) || String(e));
853
+ }
832
854
  // Swap the one-shot bind-error handler for a steady-state one so a later socket error
833
855
  // (a client RST, etc.) is logged, not thrown.
834
856
  httpServer.on('error', (e) => log('http server error:', (e && e.message) || String(e)));
@@ -855,6 +877,14 @@ function createHttpMcpServer(opts = {}) {
855
877
  }
856
878
  stopping = true;
857
879
 
880
+ // 0. Disarm the config watchers FIRST, so no debounced reload can fire against the build we're
881
+ // about to tear down (a reload mutating a half-closed aggregator would be a benign no-op, but
882
+ // disarming first is the clean order).
883
+ if (configWatchersStop) {
884
+ try { configWatchersStop(); } catch (_e) { /* never throw */ }
885
+ configWatchersStop = null;
886
+ }
887
+
858
888
  // 1. Tear down SSE clients: clear their keep-alive timers and end the responses so their
859
889
  // sockets close (an open SSE socket would otherwise keep the server from closing).
860
890
  for (const res of Array.from(sseClients)) {
@@ -934,6 +964,7 @@ function createHttpMcpServer(opts = {}) {
934
964
  await closeAggregatorSafely();
935
965
  // 2. Rebuild from the edited config + reconnect.
936
966
  build = buildFactory();
967
+ wireAggregatorNotify();
937
968
  if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
938
969
  try {
939
970
  const res = await build.aggregator.connectAll();
package/src/mcp/server.js CHANGED
@@ -216,7 +216,9 @@ function makeRegistryAdapter(registry, opts) {
216
216
  if (!desc) return null;
217
217
 
218
218
  // Reference mode: nothing executes here. Hand back the instructions (no execute thunk)
219
- // so protocol.runTool short-circuits BEFORE gatedRun — no spawn, no gate.
219
+ // so protocol.runTool takes the no-spawn path (execute() is never called). Note this is
220
+ // NOT ungated: protocol.runTool still fires PreToolUse on the instruction handoff, so a
221
+ // deny withholds the instructions. What's absent is server-side EXECUTION, not the gate.
220
222
  if (desc.mode === 'reference') {
221
223
  return {
222
224
  mode: 'reference',
@@ -934,6 +936,10 @@ function emitToolsListChanged(send) {
934
936
  * @returns {Promise<void>}
935
937
  */
936
938
  async function reloadExpose(build, send) {
939
+ // NEVER-throws contract. A null/absent build means the caller (e.g. the HTTP host mid-teardown,
940
+ // which nulls its build) has moved on — do nothing rather than deref null. (reloadHooks/
941
+ // reloadRegister already guard this; expose was the one unguarded reloader — a proven crash.)
942
+ if (!build || typeof build !== 'object') return;
937
943
  let store;
938
944
  try {
939
945
  store = loadExposeStore(EXPOSE_PATH);
@@ -953,12 +959,20 @@ async function reloadExpose(build, send) {
953
959
  } catch (err) {
954
960
  logErr('reloadExpose: connectAll threw (ignored):', (err && err.message) || String(err));
955
961
  }
956
- const prev = build.aggregator;
957
- build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
958
- if (prev && typeof prev.closeAll === 'function') {
959
- try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
962
+ // The swap/close/notify is guarded too: even a surprise (a build torn down DURING the async
963
+ // connectAll above) must be a caught no-op, not an escaped rejection that crashes the host.
964
+ try {
965
+ const prev = build.aggregator;
966
+ build.aggregator = next; // swap BEFORE closing the old one (handleMessage reads build fresh)
967
+ if (prev && typeof prev.closeAll === 'function') {
968
+ try { await prev.closeAll(); } catch (_e) { /* closeAll never throws, but guard teardown */ }
969
+ }
970
+ emitToolsListChanged(send);
971
+ } catch (err) {
972
+ logErr('reloadExpose: swap failed (keeping last-good):', (err && err.message) || String(err));
973
+ try { await next.closeAll(); } catch (_e) { /* orphan-avoidance; never throw */ }
974
+ return;
960
975
  }
961
- emitToolsListChanged(send);
962
976
  // Activity log (self-gating): mark the reload trigger; the per-upstream connect/disconnect lines
963
977
  // (from the aggregator) carry the detail. Together they record the reconnect.
964
978
  logger.log({
@@ -1014,20 +1028,34 @@ function reloadHooks(build) {
1014
1028
  }
1015
1029
 
1016
1030
  /**
1017
- * Watch the config directories and hot-reload on change. Watches the DIRECTORIES (robust to the
1018
- * atomic temp+rename writes expose-store / hook-loader use, which replace the file inode) and
1019
- * filters by basename. Debounced (a temp+rename burst → one reload), with a re-entrancy guard so a
1020
- * mid-flight expose reload can't overlap itself. A watch failure (an fs that doesn't support
1021
- * fs.watch) is logged and ignored — the server still runs, just without auto-reload. NEVER throws.
1031
+ * Watch the config and hot-reload on change. TWO mechanisms, belt-and-braces:
1032
+ * 1. fs.watch on the DIRECTORIES — fast, event-driven, filtered by basename.
1033
+ * 2. fs.watchFile POLLING on each specific config file — the reliable fallback.
1034
+ * The directory watch alone is NOT enough: fs.watch is event-based and on Windows frequently
1035
+ * MISSES the atomic temp+rename writes the stores use (the target's inode is replaced without a
1036
+ * usable change event on the directory), so a `tf_hook_set` / UI toggle could fail to hot-reload
1037
+ * until restart. fs.watchFile polls each file's stat, so it catches every create/modify (including
1038
+ * atomic rename) on every platform. Both paths feed the SAME debounced schedulers, so a change
1039
+ * that trips both still collapses to one reload. Debounced (a temp+rename burst → one reload),
1040
+ * with a re-entrancy guard so a mid-flight expose reload can't overlap itself. A watch failure (an
1041
+ * fs that supports neither) is logged and ignored — the server still runs, just without
1042
+ * auto-reload. NEVER throws.
1022
1043
  *
1023
1044
  * @param {object} build the live build object (mutated by the reloaders)
1024
1045
  * @param {(obj:object)=>void} send the stdio writer
1025
1046
  * @returns {void}
1026
1047
  */
1027
- function startConfigWatchers(build, send) {
1048
+ function startConfigWatchers(buildArg, send) {
1049
+ // buildArg may be the build OBJECT (stdio: one stable object, mutated in place) OR a GETTER
1050
+ // () => build. The HTTP host REBUILDS its build (reload()/stop() reassign it), so it passes a
1051
+ // getter — the reloaders then always target the LIVE build and a rebuild can never desync the
1052
+ // watchers. Backward-compatible: a plain object is wrapped in a getter, so stdio is unchanged.
1053
+ const getBuild = typeof buildArg === 'function' ? buildArg : () => buildArg;
1028
1054
  const DEBOUNCE_MS = 150;
1029
1055
  const mcpDir = path.dirname(EXPOSE_PATH);
1030
1056
  const hooksDir = path.dirname(MANIFEST_PATH);
1057
+ const openWatchers = []; // fs.watch handles, closed by the returned stop()
1058
+ const polledFiles = []; // fs.watchFile paths, unwatched by the returned stop()
1031
1059
 
1032
1060
  let exposeTimer = null;
1033
1061
  let reloadingExpose = false;
@@ -1037,7 +1065,12 @@ function startConfigWatchers(build, send) {
1037
1065
  exposeTimer = null;
1038
1066
  if (reloadingExpose) { scheduleExpose(); return; } // a reload is mid-flight; re-arm
1039
1067
  reloadingExpose = true;
1040
- try { await reloadExpose(build, send); } finally { reloadingExpose = false; }
1068
+ // reloadExpose is contracted never to throw, but a debounced setTimeout callback has no
1069
+ // caller to catch an escaped rejection — so guard here too (an unhandled rejection would
1070
+ // crash the host on default Node). Belt and braces.
1071
+ try { await reloadExpose(getBuild(), send); }
1072
+ catch (e) { logErr('scheduleExpose: reload rejected (should not):', (e && e.message) || String(e)); }
1073
+ finally { reloadingExpose = false; }
1041
1074
  }, DEBOUNCE_MS);
1042
1075
  if (exposeTimer && typeof exposeTimer.unref === 'function') exposeTimer.unref(); // don't pin the loop
1043
1076
  }
@@ -1045,14 +1078,14 @@ function startConfigWatchers(build, send) {
1045
1078
  let hooksTimer = null;
1046
1079
  function scheduleHooks() {
1047
1080
  if (hooksTimer) clearTimeout(hooksTimer);
1048
- hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(build); }, DEBOUNCE_MS);
1081
+ hooksTimer = setTimeout(() => { hooksTimer = null; reloadHooks(getBuild()); }, DEBOUNCE_MS);
1049
1082
  if (hooksTimer && typeof hooksTimer.unref === 'function') hooksTimer.unref();
1050
1083
  }
1051
1084
 
1052
1085
  let registerTimer = null;
1053
1086
  function scheduleRegister() {
1054
1087
  if (registerTimer) clearTimeout(registerTimer);
1055
- registerTimer = setTimeout(() => { registerTimer = null; reloadRegister(build, send); }, DEBOUNCE_MS);
1088
+ registerTimer = setTimeout(() => { registerTimer = null; reloadRegister(getBuild(), send); }, DEBOUNCE_MS);
1056
1089
  if (registerTimer && typeof registerTimer.unref === 'function') registerTimer.unref();
1057
1090
  }
1058
1091
 
@@ -1074,6 +1107,7 @@ function startConfigWatchers(build, send) {
1074
1107
  // unref so an FSWatcher never keeps the process alive after the client closes stdin — the
1075
1108
  // gateway must still exit naturally when its transport closes (it did before this feature).
1076
1109
  if (w && typeof w.unref === 'function') w.unref();
1110
+ openWatchers.push(w);
1077
1111
  logErr('startConfigWatchers: watching', mcpDir, 'for expose.json changes');
1078
1112
  } catch (err) {
1079
1113
  logErr('startConfigWatchers: cannot watch', mcpDir + ':', (err && err.message) || String(err));
@@ -1085,6 +1119,7 @@ function startConfigWatchers(build, send) {
1085
1119
  if (!filename || base === 'hooks.manifest.json' || base === 'hooks.state.json') scheduleHooks();
1086
1120
  });
1087
1121
  if (w && typeof w.unref === 'function') w.unref();
1122
+ openWatchers.push(w);
1088
1123
  logErr('startConfigWatchers: watching', hooksDir, 'for hook changes');
1089
1124
  } catch (err) {
1090
1125
  logErr('startConfigWatchers: cannot watch', hooksDir + ':', (err && err.message) || String(err));
@@ -1102,10 +1137,58 @@ function startConfigWatchers(build, send) {
1102
1137
  if (!filename || base === 'tools.state.json') scheduleStateNotify();
1103
1138
  });
1104
1139
  if (w && typeof w.unref === 'function') w.unref();
1140
+ openWatchers.push(w);
1105
1141
  logErr('startConfigWatchers: watching', toolsDir, 'for register/state changes');
1106
1142
  } catch (err) {
1107
1143
  logErr('startConfigWatchers: cannot watch', toolsDir + ':', (err && err.message) || String(err));
1108
1144
  }
1145
+
1146
+ // ── Reliable fallback: fs.watchFile POLLS each config file's stat, so it catches every
1147
+ // create/modify — including the atomic temp+rename writes that fs.watch misses on Windows.
1148
+ // A 1s interval is ample for config (no sub-second reload need); the schedulers debounce, so a
1149
+ // change caught by BOTH the directory watch and the file poll still yields a single reload.
1150
+ // The state overlays (hooks.state.json / tools.state.json) may not exist yet — watchFile fires
1151
+ // when they first appear (a tf_hook_set / tf_tool_set toggle CREATES them), which is the case
1152
+ // the directory watch was dropping.
1153
+ const pollWatch = (file, schedule) => {
1154
+ try {
1155
+ // Retain the listener ref so stop() can unwatchFile(file, listener) — a bare
1156
+ // unwatchFile(file) would remove ALL listeners on that path, clobbering a second host/watcher
1157
+ // set in the same process (parallel tests, an embedder running two hosts).
1158
+ const listener = (curr, prev) => {
1159
+ if (curr.mtimeMs !== prev.mtimeMs || curr.size !== prev.size) schedule();
1160
+ };
1161
+ const sw = fs.watchFile(file, { interval: 1000 }, listener);
1162
+ if (sw && typeof sw.unref === 'function') sw.unref(); // never pin the event loop
1163
+ polledFiles.push({ file, listener });
1164
+ } catch (err) {
1165
+ logErr('startConfigWatchers: cannot poll-watch', file + ':', (err && err.message) || String(err));
1166
+ }
1167
+ };
1168
+ pollWatch(EXPOSE_PATH, scheduleExpose);
1169
+ pollWatch(MANIFEST_PATH, scheduleHooks);
1170
+ pollWatch(path.join(hooksDir, 'hooks.state.json'), scheduleHooks);
1171
+ pollWatch(REGISTER_PATH, scheduleRegister);
1172
+ pollWatch(path.join(toolsDir, 'tools.state.json'), scheduleStateNotify);
1173
+ logErr('startConfigWatchers: fs.watchFile polling fallback armed (Windows-safe)');
1174
+
1175
+ // Teardown handle. The stdio path never calls it (the process dies when stdin closes and the
1176
+ // watchers are all unref'd); the HTTP host calls it in stop() so a started→stopped host leaves
1177
+ // no dangling directory watchers or file pollers.
1178
+ return function stopConfigWatchers() {
1179
+ // Clear any PENDING debounced reload so a change caught just before teardown can't fire a
1180
+ // reload against a torn-down build after the caller has moved on. (It would be a caught no-op,
1181
+ // but a truly-stopped watcher must not schedule work.)
1182
+ for (const t of [exposeTimer, hooksTimer, registerTimer, stateTimer]) {
1183
+ if (t) { try { clearTimeout(t); } catch (_e) { /* ignore */ } }
1184
+ }
1185
+ for (const w of openWatchers) {
1186
+ try { if (w && typeof w.close === 'function') w.close(); } catch (_e) { /* ignore */ }
1187
+ }
1188
+ for (const p of polledFiles) {
1189
+ try { fs.unwatchFile(p.file, p.listener); } catch (_e) { /* ignore */ }
1190
+ }
1191
+ };
1109
1192
  }
1110
1193
 
1111
1194
  // ─────────────────────────────────────────────────────────────────────────────────────────────
@@ -397,8 +397,10 @@ class Registry {
397
397
 
398
398
  // ---- reference mode: nothing runs here. ----------------------------------
399
399
  // The connected AI performs the action in its own environment. We hand back the
400
- // tool's instructions (no `run`) so the run-path can short-circuit BEFORE the gate
401
- // — a reference tool never spawns and never needs gating (nothing executes).
400
+ // tool's instructions (no `run`) so the run-path takes the no-spawn branch — a
401
+ // reference tool never spawns server-side. The instruction HANDOFF is still gated
402
+ // in protocol.runTool (a PreToolUse deny withholds the instructions); it's execution,
403
+ // not the gate, that's absent here.
402
404
  if (mode === 'reference') {
403
405
  return {
404
406
  type: 'reference',