toolfunnel 0.3.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.
Files changed (40) hide show
  1. package/README.md +167 -324
  2. package/bin/toolfunnel.js +39 -4
  3. package/docs/KNOWN_BUGS.md +33 -0
  4. package/docs/MANUAL.pdf +0 -0
  5. package/docs/packaging.md +115 -0
  6. package/package.json +3 -1
  7. package/src/auth/config.js +4 -2
  8. package/src/auth/resource-server.js +28 -3
  9. package/src/core/config-home.js +134 -0
  10. package/src/core/logger.js +4 -2
  11. package/src/core/requires.js +138 -0
  12. package/src/core/server-config.js +77 -0
  13. package/src/extend/create-tool.md +24 -4
  14. package/src/extend/package.md +33 -0
  15. package/src/mcp/aggregator.js +12 -7
  16. package/src/mcp/http-transport.js +71 -21
  17. package/src/mcp/mcp-client.js +9 -3
  18. package/src/mcp/server.js +181 -16
  19. package/src/packages/packager.js +5 -3
  20. package/src/tools/registry.js +47 -2
  21. package/src/ui/server.js +49 -3
  22. package/tools/scripts/tf-env.js +43 -0
  23. package/tools/scripts/tf-hook-add.js +4 -5
  24. package/tools/scripts/tf-hook-set.js +4 -5
  25. package/tools/scripts/tf-list.js +9 -12
  26. package/tools/scripts/tf-log.js +4 -1
  27. package/tools/scripts/tf-mcp-add.js +4 -6
  28. package/tools/scripts/tf-mcp-set.js +4 -6
  29. package/tools/scripts/tf-pack.js +237 -0
  30. package/tools/scripts/tf-tool-add.js +16 -16
  31. package/tools/scripts/tf-tool-set.js +5 -11
  32. package/tools/tools.register.json +46 -1
  33. package/docs/admin-ui.md +0 -404
  34. package/docs/configuration.md +0 -402
  35. package/docs/hooks-and-gating.md +0 -428
  36. package/docs/logging.md +0 -223
  37. package/docs/management.md +0 -429
  38. package/docs/oauth.md +0 -103
  39. package/docs/setup.md +0 -352
  40. package/docs/tools-and-upstreams.md +0 -469
@@ -0,0 +1,77 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * server-config.js — OPTIONAL gateway-level configuration: identity + default ports.
5
+ *
6
+ * Reads `toolfunnel.json` at the gateway root (absent by default). This is what lets a
7
+ * third party ship an MCP built on toolfunnel that introduces itself as ITSELF: the
8
+ * `initialize` handshake and `/health` report THEIR serverName/serverVersion, and their
9
+ * pack can carry its own default ports so `npx their-mcp` never collides with another
10
+ * gateway on the user's machine. CLI flags still win: precedence is flag > config > default.
11
+ *
12
+ * Shape (every field optional — an empty/absent file IS the default identity):
13
+ * {
14
+ * "serverName": "my-mcp", // initialize serverInfo.name (default "toolfunnel")
15
+ * "serverVersion": "1.0.0", // initialize serverInfo.version (default: package.json)
16
+ * "httpPort": 9998, // --http default port (default 9998)
17
+ * "uiPort": 9777 // --ui default port (default 9777)
18
+ * }
19
+ *
20
+ * TOLERANT by design, field by field: a missing file, bad JSON, or an invalid FIELD falls
21
+ * back to that field's default — a broken identity file must never stop the gateway from
22
+ * starting as plain "toolfunnel" (the same fail-open-to-default posture as log.config.json;
23
+ * contrast auth config, which fails CLOSED because it guards a boundary). No new deps.
24
+ */
25
+
26
+ const fs = require('node:fs');
27
+ const path = require('node:path');
28
+
29
+ /** The compiled-in defaults. version comes from package.json (single source of truth). */
30
+ function defaults() {
31
+ let version = '0.0.0';
32
+ try {
33
+ version = require('../../package.json').version || version;
34
+ } catch (_e) {
35
+ /* unreadable package.json → a placeholder beats a crash */
36
+ }
37
+ return { serverName: 'toolfunnel', serverVersion: version, httpPort: 9998, uiPort: 9777 };
38
+ }
39
+
40
+ /** A valid TCP port for a bind default: an integer 1..65535 (0 = OS-assigned stays flag-only —
41
+ * an ephemeral DEFAULT would make the gateway un-findable across restarts). */
42
+ function isPort(v) {
43
+ return Number.isInteger(v) && v >= 1 && v <= 65535;
44
+ }
45
+
46
+ /**
47
+ * Load the gateway config, merged over the defaults.
48
+ * @param {string} rootDir the gateway root (the directory that holds toolfunnel.json)
49
+ * @returns {{serverName:string, serverVersion:string, httpPort:number, uiPort:number}}
50
+ */
51
+ function loadServerConfig(rootDir) {
52
+ const out = defaults();
53
+ let raw;
54
+ try {
55
+ raw = fs.readFileSync(path.join(rootDir, 'toolfunnel.json'), 'utf8');
56
+ } catch (_e) {
57
+ return out; // absent — the normal case
58
+ }
59
+ let cfg;
60
+ try {
61
+ cfg = JSON.parse(raw);
62
+ } catch (_e) {
63
+ return out; // bad JSON — start as the default identity rather than not at all
64
+ }
65
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) return out;
66
+ if (typeof cfg.serverName === 'string' && cfg.serverName.trim().length > 0) {
67
+ out.serverName = cfg.serverName.trim();
68
+ }
69
+ if (typeof cfg.serverVersion === 'string' && cfg.serverVersion.trim().length > 0) {
70
+ out.serverVersion = cfg.serverVersion.trim();
71
+ }
72
+ if (isPort(cfg.httpPort)) out.httpPort = cfg.httpPort;
73
+ if (isPort(cfg.uiPort)) out.uiPort = cfg.uiPort;
74
+ return out;
75
+ }
76
+
77
+ module.exports = { loadServerConfig };
@@ -2,8 +2,9 @@
2
2
 
3
3
  This is the instruction served by `toolfunnel_howto({ topic: "create-tool" })`. It tells you — the
4
4
  model — how to add a new tool to the register so it becomes callable through `toolfunnel_run_tool`
5
- (and listable via `toolfunnel_list_tools` / `toolfunnel_tool_instructions`). No restart is needed: the register
6
- is read live every time the meta-tools are called.
5
+ (and listable via `toolfunnel_list_tools` / `toolfunnel_tool_instructions`). No restart is needed: a
6
+ running gateway watches `tools.register.json` and hot-reloads its register on change (debounced
7
+ ~150 ms), the same way `mcp/expose.json` and the hook files reload.
7
8
 
8
9
  A tool is exactly two things:
9
10
  1. **A register entry** in `tools/tools.register.json` (structured metadata + how to invoke it).
@@ -23,6 +24,12 @@ A tool is exactly two things:
23
24
  "summary": "Uppercases the supplied text and echoes it back.",
24
25
  "category": "demo",
25
26
  "instructions": "Pass { text: string }. Returns the text uppercased. Use this only as a sanity check of the run path.",
27
+ "inputSchema": {
28
+ "type": "object",
29
+ "properties": { "text": { "type": "string", "description": "The text to uppercase." } },
30
+ "required": ["text"],
31
+ "additionalProperties": false
32
+ },
26
33
  "invoke": { "type": "script", "path": "scripts/echo-upper.sh" }
27
34
  }
28
35
  ```
@@ -36,6 +43,7 @@ Field-by-field — these names are fixed (registry.js reads exactly these):
36
43
  | `summary` | string | One line. This is ALL that `toolfunnel_list_tools` returns for the tool — keep it tight and discriminating so the model can pick the right tool from the brief alone. |
37
44
  | `category` | string | Grouping label (e.g. `demo`, `memory`, `messaging`, `media`). Used by `toolfunnel_list_tools({ category })` to filter. |
38
45
  | `instructions` | string | Full usage doc returned by `toolfunnel_tool_instructions({ name })`. Describe the args object, what comes back, and any safety caveats. This is the long-tail payload — be complete here, terse in `summary`. |
46
+ | `inputSchema` | object (optional) | A JSON Schema for the tool's args. **This is what makes the tool a first-class MCP citizen when promoted hot:** the top-level `tools/list` advertises it VERBATIM, so a connected client knows the exact arg shape without reading `instructions`. Omit it and a hot-promoted tool falls back to a free-form `{ "type": "object" }` (callable, but the client gets no arg hints). Must be an object — the register rejects any other shape at authoring time. |
39
47
  | `invoke` | object | How `run` executes the tool. See below. |
40
48
 
41
49
  ### The `invoke` object — two forms
@@ -128,5 +136,17 @@ matches its `tool_name` (see `toolfunnel_howto({ topic: "add-hook" })`), not omi
128
136
  5. Test with `toolfunnel_run_tool({ name, args })`. Verify the gate behaves: a benign call returns
129
137
  `{ ok:true, output }`; if a matching PreToolUse hook should deny it, confirm it does.
130
138
 
131
- No reconnect is needed — the register is dynamic. (Only *curated-direct* tools negotiated at MCP
132
- connect need a reconnect; register tools reached via the meta-tools are always live.)
139
+ No reconnect is needed — the register is dynamic: the running gateway hot-reloads
140
+ `tools.register.json` on change and emits `notifications/tools/list_changed`, exactly like an
141
+ `expose.json` or hook edit.
142
+
143
+ ---
144
+
145
+ ## 5. Promoting your tool to the top-level surface (the "instant MCP" recipe)
146
+
147
+ By default your tool lives behind the lean meta-tools (discovered via `toolfunnel_list_tools`).
148
+ Set `hot: true` for its id in `tools/tools.state.json` (or via the UI / `tf_tool_set`) and it is
149
+ advertised DIRECTLY in the MCP `tools/list` — under its own `id`, with your `summary` as the
150
+ description and your `inputSchema` as the advertised schema. Author a real `inputSchema` before
151
+ promoting: that schema is the difference between a client calling your tool correctly first time
152
+ and it guessing at a free-form object.
@@ -5,6 +5,39 @@ tools, hooks, and MCP configs into a **shareable package** and how import/export
5
5
  **byproduct** of the gateway design: because every unit is already structured and
6
6
  self-documented, a package is just a folder that lists its units and carries their files.
7
7
 
8
+ ---
9
+
10
+ ## 0. The WHOLE-SETUP path — `tf_pack` (start here)
11
+
12
+ Since 0.4.0 the fastest way to ship is not per-unit packaging but the **whole setup**: your entire
13
+ config home (tools + scripts, hot/hidden curation, upstream references, policy hooks, identity) is
14
+ already the product. One call packages it into a deployment-ready artifact under `<home>/dist/`:
15
+
16
+ - `tf_pack { format: "home" }` → a **portable config home**. Zip it, git-init it, or run it
17
+ directly: `toolfunnel --config-dir <dir>`. The receiving gateway seeds nothing — the pack IS the
18
+ config, including the hooks: **the gate travels**, enforcing your policy on the recipient's
19
+ machine regardless of which MCP client they use.
20
+ - `tf_pack { format: "npm", name: "my-mcp" }` → a **publishable npm package**: a generated
21
+ `package.json` that depends on `toolfunnel` (caret range — DEPEND, never copy the engine), a
22
+ 2-line bin launcher pointing `--config-dir` at the bundled home, and a README stub.
23
+ `cd dist/my-mcp && npm publish` → your users run `npx my-mcp` and get YOUR server, under YOUR
24
+ name (the bundled `toolfunnel.json` identity), with YOUR gate.
25
+
26
+ Declare the runtimes your tools need in the home's `toolfunnel.json` — they travel with the pack
27
+ and the receiving gateway warns at startup about anything missing:
28
+
29
+ ```json
30
+ "requires": [ { "command": "python", "min": "3.10", "why": "the pdf tools" } ]
31
+ ```
32
+
33
+ Audit honesty (both directions): packs spawn commands. Tell your users to read your pack's
34
+ `expose.json`, `tools.register.json`, AND `toolfunnel.json` (its `requires` probes execute at
35
+ gateway startup — bare program names only, the loader rejects anything shell-shaped) — and read
36
+ all three yourself in anything you install.
37
+
38
+ The per-UNIT packaging below remains the right shape for sharing a single tool/hook/MCP selection
39
+ rather than a whole gateway.
40
+
8
41
  > Phase note: packages are Phase 2 (`packages/` directory). The structure below is what the importer
9
42
  > and exporter target; defining it now means the units authored today are already package-ready.
10
43
 
@@ -49,10 +49,12 @@ const path = require('node:path');
49
49
  const { McpClient } = require('./mcp-client');
50
50
  const logger = require('../core/logger');
51
51
 
52
- // The sandbox root, resolved exactly the way server.js does (this file lives in
53
- // src/mcp/, so two levels up is the gateway root). Used as the isolation boundary the
54
- // default clientFactory enforces. A caller may override via opts.v3Root for tests.
55
- const DEFAULT_ROOT = path.resolve(__dirname, '..', '..');
52
+ // The sandbox root = the CONFIG HOME (TOOLFUNNEL_HOME / --config-dir; defaults to the package
53
+ // root — see src/core/config-home.js). Used as the isolation boundary the default clientFactory
54
+ // enforces: a pack's path-shaped upstream args live under the home, so the home is the boundary.
55
+ // A caller may override via opts.v3Root for tests.
56
+ const { resolveConfigHome } = require('../core/config-home');
57
+ const DEFAULT_ROOT = resolveConfigHome();
56
58
 
57
59
  /** True for a non-empty string. */
58
60
  function isNonEmptyString(v) {
@@ -522,9 +524,6 @@ class Aggregator {
522
524
  const upstream = this._store.getUpstream(upstreamId);
523
525
  if (!upstream || upstream.enabled !== true) return; // detached/disabled — stop trying
524
526
  const MAX_FAST_ATTEMPTS = 6; // exponential phase; beyond it, a slow keepalive at the 30s cap
525
- if (attempt === MAX_FAST_ATTEMPTS) {
526
- logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: attempt });
527
- }
528
527
  const delay = Math.min(1000 * Math.pow(2, attempt), 30000);
529
528
  const timer = setTimeout(async () => {
530
529
  this._reconnectTimers.delete(upstreamId);
@@ -537,6 +536,12 @@ class Aggregator {
537
536
  this._signalToolsChanged(upstreamId);
538
537
  } catch (err) {
539
538
  logger.log({ type: 'mcp', event: 'reconnect_failed', upstream: upstreamId, attempt, error: errMsg(err) });
539
+ // Log the fast→slow transition exactly ONCE — when the counter FIRST reaches the cap. The
540
+ // old guard sat on the re-entry side, where the capped counter re-satisfied it every 30s
541
+ // forever against a permanently-dead upstream (log spam, not information).
542
+ if (attempt + 1 === MAX_FAST_ATTEMPTS) {
543
+ logger.log({ type: 'mcp', event: 'reconnect_slow', upstream: upstreamId, attempts: MAX_FAST_ATTEMPTS });
544
+ }
540
545
  // Keep escalating to the cap, then keep retrying slowly (cap the counter so the delay stays
541
546
  // at 30s) — never permanently give up while the upstream stays enabled.
542
547
  this._scheduleReconnect(upstreamId, Math.min(attempt + 1, MAX_FAST_ATTEMPTS));
@@ -145,7 +145,14 @@ function isLoopbackHost(hostHeader) {
145
145
  return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
146
146
  }
147
147
 
148
- /** Read a request body up to MAX_BODY_BYTES. Resolves the buffered string, or rejects 'too large'. */
148
+ /**
149
+ * Read a request body up to MAX_BODY_BYTES. Resolves the buffered string, or rejects 'too large'.
150
+ * On over-cap it does NOT destroy the request — destroying here tears down the shared req/res
151
+ * socket before the caller's -32700 reply can flush (the client saw ECONNRESET instead of the clean
152
+ * parse error the declared-Content-Length path produces). Instead: stop BUFFERING (later chunks are
153
+ * discarded, so memory stays bounded) and reject; the caller replies via sendJsonAndClose, which
154
+ * flushes the error body first and destroys the request only in the res.end callback.
155
+ */
149
156
  function readBody(req) {
150
157
  return new Promise((resolve, reject) => {
151
158
  let size = 0;
@@ -157,15 +164,11 @@ function readBody(req) {
157
164
  fn(val);
158
165
  };
159
166
  req.on('data', (chunk) => {
167
+ if (done) return; // over-cap already signalled — discard, don't buffer
160
168
  size += chunk.length;
161
169
  if (size > MAX_BODY_BYTES) {
162
- // Stop reading; the caller turns this into a parse-error response.
170
+ // Stop buffering; the caller flush-then-closes with the same -32700 as the declared-CL path.
163
171
  finish(reject, new Error('request body too large'));
164
- try {
165
- req.destroy();
166
- } catch (_e) {
167
- /* ignore */
168
- }
169
172
  return;
170
173
  }
171
174
  chunks.push(chunk);
@@ -193,16 +196,19 @@ function sendJson(res, status, obj) {
193
196
 
194
197
  /**
195
198
  * Send a JSON response and then CLOSE the connection, draining the unread request body WITHOUT
196
- * buffering it. Used by the Content-Length pre-check (handleMcpPost): when a client announces an
197
- * oversized body we must reply cleanly (the documented -32700) *without* reading the multi-MiB
198
- * payload off the socket. The naive fix — `req.destroy()` straight after `res.end()` — resets the
199
- * socket before the response bytes flush, surfacing to the client as ECONNRESET instead of the
200
- * clean parse-error body. The robust sequence is:
199
+ * buffering it. Used by both over-cap paths (the Content-Length pre-check and readBody's streaming
200
+ * cap): the client must receive the documented -32700 cleanly while the oversized payload never
201
+ * touches memory. The naive close — `req.destroy()`, even deferred to the res.end flush callback —
202
+ * is NOT safe: destroy() sends a TCP RST whenever request bytes are still unread/in-flight, and on
203
+ * RST Windows discards the peer's receive queue, eating the response we just flushed (the client
204
+ * saw ECONNRESET instead of the -32700; proven by test 5 in http.test.js). The robust sequence:
201
205
  * 1. announce `Connection: close` so the client/keep-alive agent expects the socket to end here,
202
- * 2. write the full response body, and
203
- * 3. ONLY in the res.end(...) flush callback destroy the request — by then the response has been
204
- * handed to the kernel, so the client receives the -32700 body, THEN sees the close. The
205
- * unread request body is discarded (never buffered → no DoS) rather than politely resumed.
206
+ * 2. write the full response body, then in the res.end(...) flush callback:
207
+ * 3. resume() the request so any still-arriving body is DISCARDED (never buffered → no DoS),
208
+ * 4. HALF-CLOSE the socket (socket.end()) — the FIN travels in-order AFTER the response bytes,
209
+ * so the client always reads the -32700 first, and
210
+ * 5. arm an unref'd hard-destroy timer as the backstop: a client that never stops sending and
211
+ * never closes gets its socket torn down after a grace window (bounded lifetime, no leak).
206
212
  * Defensive: like sendJson, a serialise failure degrades to a 500-shaped error body; the whole
207
213
  * function is wrapped so a write-after-headers race can never throw out of handleMcpPost.
208
214
  * @param {import('node:http').IncomingMessage} req
@@ -233,9 +239,20 @@ function sendJsonAndClose(req, res, status, obj) {
233
239
  // pipelined follow-up (whose body would collide with the one we are about to discard).
234
240
  Connection: 'close',
235
241
  });
236
- // Destroy the request ONLY after the response body has flushed to the kernel, so the client
237
- // receives the clean -32700 first and the unread oversized body is dropped, not buffered.
238
- res.end(body, destroyReq);
242
+ res.end(body, () => {
243
+ // The response has flushed to the kernel. Close down WITHOUT an RST (see the doc above):
244
+ try {
245
+ req.resume(); // discard (never buffer) whatever request body is still arriving
246
+ if (req.socket && !req.socket.destroyed) req.socket.end(); // FIN, in-order after the body
247
+ } catch (_e) {
248
+ return destroyReq(); // a torn socket at this point → plain destroy is all that is left
249
+ }
250
+ // Backstop: bound the socket's lifetime if the client neither finishes nor closes. The
251
+ // timer is unref'd so it never keeps the process alive; a normal close clears it.
252
+ const t = setTimeout(destroyReq, 10000);
253
+ if (t && typeof t.unref === 'function') t.unref();
254
+ if (req.socket) req.socket.once('close', () => clearTimeout(t));
255
+ });
239
256
  } catch (_e) {
240
257
  // writeHead/end can throw if headers were already sent by a racing path — fall back to a
241
258
  // straight destroy so the socket can never be left half-open.
@@ -283,6 +300,8 @@ function createHttpMcpServer(opts = {}) {
283
300
  /** @type {boolean} latch so stop() is idempotent and start() can't double-bind. */
284
301
  let started = false;
285
302
  let stopping = false;
303
+ /** @type {(() => void)|null} teardown for the config hot-reload watchers (armed in start()). */
304
+ let configWatchersStop = null;
286
305
  /**
287
306
  * The result of the LAST connectAll() (from start() or reload()), surfaced by health() so a
288
307
  * silent upstream connect-failure becomes VISIBLE (otherwise it only reaches stderr via log()).
@@ -397,6 +416,16 @@ function createHttpMcpServer(opts = {}) {
397
416
  return pushToSse({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
398
417
  }
399
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
+
400
429
  // ── OAuth 2.1 resource-server gate (OPTIONAL; default OFF) ──────────────────────────────────
401
430
  // The validator is memoised and rebuilt ONLY when the relevant auth config changes — so a live
402
431
  // enable/disable/edit takes effect on the next request WITHOUT discarding jose's JWKS cache in
@@ -501,9 +530,11 @@ function createHttpMcpServer(opts = {}) {
501
530
  try {
502
531
  raw = await readBody(req);
503
532
  } catch (err) {
504
- // Oversized / aborted body → a clean parse error, server stays alive.
533
+ // Oversized / aborted body → the SAME flush-then-close -32700 as the Content-Length pre-check
534
+ // (readBody no longer destroys the request, so the reply can actually reach the client; a
535
+ // genuinely-dead socket is fine too — sendJsonAndClose's writes are defensively wrapped).
505
536
  log('POST /mcp body read failed:', (err && err.message) || String(err));
506
- return sendJson(res, 200, makeError(ERR.PARSE, 'Parse error: ' + ((err && err.message) || 'bad body')));
537
+ return sendJsonAndClose(req, res, 200, makeError(ERR.PARSE, 'Parse error: ' + ((err && err.message) || 'bad body')));
507
538
  }
508
539
 
509
540
  let msg;
@@ -763,6 +794,7 @@ function createHttpMcpServer(opts = {}) {
763
794
  // the very first request. connectAll() NEVER throws (failures land in failed[]); with the
764
795
  // default EMPTY expose.json it is an instant no-op.
765
796
  build = buildFactory();
797
+ wireAggregatorNotify();
766
798
  if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
767
799
  try {
768
800
  const res = await build.aggregator.connectAll();
@@ -810,6 +842,15 @@ function createHttpMcpServer(opts = {}) {
810
842
  boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
811
843
  started = true;
812
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
+ }
813
854
  // Swap the one-shot bind-error handler for a steady-state one so a later socket error
814
855
  // (a client RST, etc.) is logged, not thrown.
815
856
  httpServer.on('error', (e) => log('http server error:', (e && e.message) || String(e)));
@@ -836,6 +877,14 @@ function createHttpMcpServer(opts = {}) {
836
877
  }
837
878
  stopping = true;
838
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
+
839
888
  // 1. Tear down SSE clients: clear their keep-alive timers and end the responses so their
840
889
  // sockets close (an open SSE socket would otherwise keep the server from closing).
841
890
  for (const res of Array.from(sseClients)) {
@@ -915,6 +964,7 @@ function createHttpMcpServer(opts = {}) {
915
964
  await closeAggregatorSafely();
916
965
  // 2. Rebuild from the edited config + reconnect.
917
966
  build = buildFactory();
967
+ wireAggregatorNotify();
918
968
  if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
919
969
  try {
920
970
  const res = await build.aggregator.connectAll();
@@ -49,9 +49,15 @@ const CLIENT_INFO = Object.freeze({ name: 'toolfunnel', version: '3.0.0-client'
49
49
  * and an explicit `.cmd` throws EINVAL (the CVE-2024-27980 mitigation). Most MCP servers launch via
50
50
  * exactly such shims (`npx`, `npm`, `uvx`, `pnpm`, …), so on Windows route anything that isn't already
51
51
  * a concrete `.exe`/`.com` through cmd.exe, which resolves PATHEXT shims. The args stay DISCRETE
52
- * (Node escapes each one under shell:false) so nothing is shell-concatenated — no injection surface on
53
- * this privileged path, and none of the `shell:true` DEP0190 hazard. close()'s taskkill /T still reaps
54
- * the whole cmd → shim → server process tree.
52
+ * (never shell-concatenated into one string) and there is none of the `shell:true` DEP0190 hazard.
53
+ * HONEST CAVEAT: because the spawned binary here is cmd.exe itself (an .exe), Node applies its
54
+ * STANDARD argv quoting — not cmd.exe ^-metacharacter escaping (that special-casing only fires when
55
+ * the command IS the .cmd/.bat). An arg containing cmd metacharacters (& | > < ") outside quotes can
56
+ * therefore still be reinterpreted by cmd.exe. These args come from the operator's own expose.json,
57
+ * so this is a correctness caveat on operator-authored config, not a privilege boundary; the fully
58
+ * escaped alternative (resolve the real interpreter à la auth/install.js's `node + npm-cli.js` and
59
+ * spawn it directly) is the upgrade path if it ever bites. close()'s taskkill /T still reaps the
60
+ * whole cmd → shim → server process tree.
55
61
  *
56
62
  * @param {string} command the configured upstream command (e.g. "npx", "node", an abs path)
57
63
  * @param {string[]} args the configured argv