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.
- package/README.md +167 -342
- package/docs/KNOWN_BUGS.md +24 -216
- package/docs/MANUAL.pdf +0 -0
- package/package.json +1 -1
- package/src/mcp/http-transport.js +31 -0
- package/src/mcp/server.js +98 -15
- package/src/tools/registry.js +4 -2
- package/docs/admin-ui.md +0 -404
- package/docs/configuration.md +0 -402
- package/docs/hooks-and-gating.md +0 -428
- package/docs/logging.md +0 -223
- package/docs/management.md +0 -429
- package/docs/oauth.md +0 -103
- package/docs/releasing.md +0 -31
- package/docs/setup.md +0 -352
- package/docs/tools-and-upstreams.md +0 -469
package/docs/KNOWN_BUGS.md
CHANGED
|
@@ -1,225 +1,33 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Known issues & roadmap
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
package/docs/MANUAL.pdf
ADDED
|
Binary file
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "toolfunnel",
|
|
3
|
-
"version": "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
|
|
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
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
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
|
|
1018
|
-
*
|
|
1019
|
-
*
|
|
1020
|
-
*
|
|
1021
|
-
*
|
|
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(
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
// ─────────────────────────────────────────────────────────────────────────────────────────────
|
package/src/tools/registry.js
CHANGED
|
@@ -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
|
|
401
|
-
//
|
|
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',
|