toolfunnel 0.3.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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -0
  3. package/bin/toolfunnel.js +112 -0
  4. package/docs/admin-ui.md +404 -0
  5. package/docs/configuration.md +402 -0
  6. package/docs/hooks-and-gating.md +428 -0
  7. package/docs/logging.md +223 -0
  8. package/docs/management.md +429 -0
  9. package/docs/oauth.md +103 -0
  10. package/docs/setup.md +352 -0
  11. package/docs/tools-and-upstreams.md +469 -0
  12. package/hooks/hooks.manifest.json +27 -0
  13. package/hooks/scripts/.gitkeep +3 -0
  14. package/hooks/scripts/example-audit.js +21 -0
  15. package/hooks/scripts/example-deny-dangerous.js +31 -0
  16. package/mcp/expose.example.json +30 -0
  17. package/mcp/servers/mock-upstream/server.js +170 -0
  18. package/package.json +63 -0
  19. package/src/auth/config.js +172 -0
  20. package/src/auth/install.js +181 -0
  21. package/src/auth/resource-server.js +319 -0
  22. package/src/core/events.js +105 -0
  23. package/src/core/hook-engine.js +275 -0
  24. package/src/core/hook-loader.js +791 -0
  25. package/src/core/hook-runner.js +346 -0
  26. package/src/core/logger.js +169 -0
  27. package/src/core/matcher.js +51 -0
  28. package/src/core/metrics.js +75 -0
  29. package/src/core/tool-registry.js +180 -0
  30. package/src/extend/add-hook.md +142 -0
  31. package/src/extend/add-mcp.md +121 -0
  32. package/src/extend/create-tool.md +132 -0
  33. package/src/extend/howto.js +98 -0
  34. package/src/extend/package.md +154 -0
  35. package/src/mcp/aggregator.js +802 -0
  36. package/src/mcp/expose-store.js +430 -0
  37. package/src/mcp/gated-run.js +194 -0
  38. package/src/mcp/http-transport.js +962 -0
  39. package/src/mcp/mcp-client.js +524 -0
  40. package/src/mcp/protocol.js +466 -0
  41. package/src/mcp/server.js +1240 -0
  42. package/src/packages/packager.js +713 -0
  43. package/src/tools/drift.js +138 -0
  44. package/src/tools/registry.js +468 -0
  45. package/src/tools/tool-state.js +104 -0
  46. package/src/ui/public/app.js +1055 -0
  47. package/src/ui/public/index.html +249 -0
  48. package/src/ui/public/logo.jpg +0 -0
  49. package/src/ui/public/styles.css +535 -0
  50. package/src/ui/server.js +1144 -0
  51. package/tools/scripts/base64.js +139 -0
  52. package/tools/scripts/danger.js +61 -0
  53. package/tools/scripts/echo.js +39 -0
  54. package/tools/scripts/hash.js +101 -0
  55. package/tools/scripts/json.js +109 -0
  56. package/tools/scripts/text-stats.js +131 -0
  57. package/tools/scripts/tf-hook-add.js +152 -0
  58. package/tools/scripts/tf-hook-set.js +113 -0
  59. package/tools/scripts/tf-list.js +195 -0
  60. package/tools/scripts/tf-log.js +83 -0
  61. package/tools/scripts/tf-mcp-add.js +119 -0
  62. package/tools/scripts/tf-mcp-set.js +104 -0
  63. package/tools/scripts/tf-tool-add.js +129 -0
  64. package/tools/scripts/tf-tool-set.js +148 -0
  65. package/tools/scripts/uuid.js +102 -0
  66. package/tools/tools.register.json +171 -0
@@ -0,0 +1,962 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * http-transport.js — the HTTP/SSE transport (the design doc §7).
5
+ *
6
+ * The SAME hand-rolled MCP protocol as the stdio `server.js`, served over HTTP/SSE so a
7
+ * LONG-LIVED host process can HOST the server and any CLI client connects to it over
8
+ * `localhost:<port>` — instead of the CLI spawning a short-lived stdio child. This is the
9
+ * north-star rung: the host owns the server, not a console.
10
+ *
11
+ * It is THIN. It reuses `server.js` for ALL JSON-RPC routing:
12
+ * - the 4 lean meta-tools (toolfunnel_list_tools / toolfunnel_tool_instructions / toolfunnel_run_tool / toolfunnel_howto),
13
+ * - the curated-direct call path THROUGH the PreToolUse gate (the safety invariant).
14
+ * This file owns ONLY the wire: parse an HTTP request → `handleMessage(build, msg)` →
15
+ * shape the HTTP response. The protocol logic is identical to stdio precisely because
16
+ * protocol.js + the server's handleMessage are transport-free.
17
+ *
18
+ * Endpoints (bind 127.0.0.1 only; non-loopback Host headers are rejected defensively):
19
+ * - POST /mcp : one JSON-RPC request → 200 application/json (a result) | 202 no body
20
+ * (a notification, handleMessage returned null) | a -32700 parse-error
21
+ * object at HTTP 200 for bad JSON. An oversized body is rejected with the
22
+ * SAME clean -32700 at HTTP 200: a Content-Length over the cap is refused
23
+ * up-front (before the body is read) with `Connection: close`; a chunked
24
+ * over-cap body is bounded by readBody's streaming cap. NEVER crashes.
25
+ * - GET /mcp : the server→client SSE stream (Accept: text/event-stream). This is the
26
+ * current Streamable-HTTP standard: a SINGLE /mcp endpoint serves POST
27
+ * (client→server messages) AND GET (the server→client SSE stream the host
28
+ * pushes notifications/tools/list_changed down). A CLI (type:"http")
29
+ * connects on this path.
30
+ * - GET /mcp/sse : a WORKING ALIAS for the same SSE stream (the older HTTP+SSE shape,
31
+ * deprecated but still supported). Identical behaviour to GET /mcp.
32
+ * - GET /health : 200 application/json with health() (a synchronous snapshot).
33
+ * - anything else : 404 JSON.
34
+ *
35
+ * SAFETY CONTRACT (mirrors the rest of src/mcp/):
36
+ * - start() rejects cleanly on EADDRINUSE (caller decides) — it does NOT crash the process.
37
+ * - stop() is idempotent and NEVER throws (ends SSE clients, closes the server, closes the
38
+ * aggregator). It also clears + unrefs the keep-alive timers so the process can exit.
39
+ * - A bad/oversized/garbage HTTP request becomes a clean response, never an unhandled throw.
40
+ * - All SSE keep-alive timers are unref()-ed so a live stream never blocks process exit.
41
+ *
42
+ * CommonJS only. Node BUILT-INS only (node:http) — no new npm dep, no MCP SDK.
43
+ */
44
+
45
+ const http = require('node:http');
46
+
47
+ const serverModule = require('./server');
48
+ const { handleMessage } = serverModule;
49
+
50
+ // OPTIONAL OAuth 2.1 resource-server validation. These modules are zero-runtime-dep themselves;
51
+ // the actual jose import is lazy + inside resource-server.js, pulled in only when auth is enabled.
52
+ const authConfig = require('../auth/config');
53
+ const { createValidator, protectedResourceMetadata, isJoseInstalled } = require('../auth/resource-server');
54
+ const metrics = require('../core/metrics');
55
+ const logger = require('../core/logger');
56
+
57
+ const PROTOCOL_VERSION = serverModule.PROTOCOL_VERSION;
58
+ const SERVER_INFO = serverModule.SERVER_INFO;
59
+
60
+ // ── Tunables ──────────────────────────────────────────────────────────────────────────────────
61
+ const DEFAULT_HOST = '127.0.0.1';
62
+ const DEFAULT_PORT = 9998;
63
+ // SSE keep-alive comment interval. The browser/CLI EventSource treats a stalled stream as dead;
64
+ // a periodic ":" comment line keeps the pipe warm without emitting a real event. ~25s is well
65
+ // under typical 30–60s proxy idle timeouts. The timer is unref()-ed so it never blocks exit.
66
+ const SSE_KEEPALIVE_MS = 25000;
67
+ // Hard cap on a POSTed JSON-RPC body so a pathological/huge request can't exhaust memory. A
68
+ // JSON-RPC tools/call payload is tiny; 4 MiB is generous headroom. Over the cap → -32700.
69
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
70
+ // RFC 9728 Protected Resource Metadata path — served UNAUTHENTICATED (it is the discovery document
71
+ // that tells a client which authorization server to use), and ONLY when auth is enabled.
72
+ const WELL_KNOWN_PRM = '/.well-known/oauth-protected-resource';
73
+
74
+ // Standard JSON-RPC error codes (subset used here — same values as server.js).
75
+ const JSONRPC = '2.0';
76
+ const ERR = Object.freeze({
77
+ PARSE: -32700,
78
+ INVALID_REQUEST: -32600,
79
+ INTERNAL: -32603,
80
+ });
81
+
82
+ /**
83
+ * Build a JSON-RPC error object with a null id (used for transport-level failures — a parse
84
+ * error has no determinable request id). Mirrors server.js makeError for the null-id case.
85
+ * @param {number} code
86
+ * @param {string} message
87
+ * @param {*} [data]
88
+ * @returns {{ jsonrpc: string, id: null, error: { code: number, message: string, data?: * } }}
89
+ */
90
+ function makeError(code, message, data) {
91
+ const error = { code, message };
92
+ if (data !== undefined) error.data = data;
93
+ return { jsonrpc: JSONRPC, id: null, error };
94
+ }
95
+
96
+ /** Diagnostics → logger (default: stderr). NEVER throws (a logging failure must not sink a call). */
97
+ function makeLog(logger) {
98
+ const sink =
99
+ logger && typeof logger.error === 'function'
100
+ ? (msg) => logger.error('[toolfunnel-http] ' + msg)
101
+ : (msg) => {
102
+ try {
103
+ process.stderr.write('[toolfunnel-http] ' + msg + '\n');
104
+ } catch (_e) {
105
+ /* never let logging throw */
106
+ }
107
+ };
108
+ return (...parts) => {
109
+ try {
110
+ sink(parts.join(' '));
111
+ } catch (_e) {
112
+ /* never throw out of the logger */
113
+ }
114
+ };
115
+ }
116
+
117
+ /** Default build factory — the real Phase-2 build over the on-disk (EMPTY by default) expose.json. */
118
+ function defaultBuildFactory() {
119
+ return require('./server').buildProtocol();
120
+ }
121
+
122
+ /**
123
+ * Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound request could still
124
+ * arrive with a non-loopback Host. Defence-in-depth: reject anything whose host part is not a
125
+ * recognised loopback name (DNS-rebinding guard). A MISSING Host header is allowed (HTTP/1.0 / a
126
+ * raw node:http client may omit it) — the bind address is already the hard boundary.
127
+ * @param {string|undefined} hostHeader the raw `Host` request header
128
+ * @returns {boolean}
129
+ */
130
+ function isLoopbackHost(hostHeader) {
131
+ if (hostHeader == null || hostHeader === '') return true; // no Host → bind addr is the boundary
132
+ // Strip an optional :port. IPv6 hosts are bracketed: "[::1]:9998".
133
+ let host = String(hostHeader).trim();
134
+ if (host[0] === '[') {
135
+ const end = host.indexOf(']');
136
+ host = end === -1 ? host.slice(1) : host.slice(1, end);
137
+ } else {
138
+ // Strip an optional :port ONLY when unambiguous — exactly one colon means "host:port". A string
139
+ // with MULTIPLE colons is a bare IPv6 literal (e.g. "::1", "0:0:0:0:0:0:0:1"); compare it whole,
140
+ // else slicing at the first colon mangles it to "" and a valid IPv6 loopback bind is rejected.
141
+ const first = host.indexOf(':');
142
+ if (first !== -1 && first === host.lastIndexOf(':')) host = host.slice(0, first);
143
+ }
144
+ host = host.toLowerCase();
145
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
146
+ }
147
+
148
+ /** Read a request body up to MAX_BODY_BYTES. Resolves the buffered string, or rejects 'too large'. */
149
+ function readBody(req) {
150
+ return new Promise((resolve, reject) => {
151
+ let size = 0;
152
+ const chunks = [];
153
+ let done = false;
154
+ const finish = (fn, val) => {
155
+ if (done) return;
156
+ done = true;
157
+ fn(val);
158
+ };
159
+ req.on('data', (chunk) => {
160
+ size += chunk.length;
161
+ if (size > MAX_BODY_BYTES) {
162
+ // Stop reading; the caller turns this into a parse-error response.
163
+ finish(reject, new Error('request body too large'));
164
+ try {
165
+ req.destroy();
166
+ } catch (_e) {
167
+ /* ignore */
168
+ }
169
+ return;
170
+ }
171
+ chunks.push(chunk);
172
+ });
173
+ req.on('end', () => finish(resolve, Buffer.concat(chunks).toString('utf8')));
174
+ req.on('error', (err) => finish(reject, err));
175
+ });
176
+ }
177
+
178
+ /** Write a JSON response with a status code. Defensive: a serialise failure degrades to 500 text. */
179
+ function sendJson(res, status, obj) {
180
+ let body;
181
+ try {
182
+ body = JSON.stringify(obj);
183
+ } catch (_e) {
184
+ body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
185
+ }
186
+ res.writeHead(status, {
187
+ 'Content-Type': 'application/json',
188
+ 'Content-Length': Buffer.byteLength(body, 'utf8'),
189
+ 'Cache-Control': 'no-store',
190
+ });
191
+ res.end(body);
192
+ }
193
+
194
+ /**
195
+ * 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:
201
+ * 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
+ * Defensive: like sendJson, a serialise failure degrades to a 500-shaped error body; the whole
207
+ * function is wrapped so a write-after-headers race can never throw out of handleMcpPost.
208
+ * @param {import('node:http').IncomingMessage} req
209
+ * @param {import('node:http').ServerResponse} res
210
+ * @param {number} status
211
+ * @param {object} obj
212
+ */
213
+ function sendJsonAndClose(req, res, status, obj) {
214
+ let body;
215
+ try {
216
+ body = JSON.stringify(obj);
217
+ } catch (_e) {
218
+ body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
219
+ }
220
+ const destroyReq = () => {
221
+ try {
222
+ req.destroy();
223
+ } catch (_e) {
224
+ /* the socket may already be gone; nothing more to do */
225
+ }
226
+ };
227
+ try {
228
+ res.writeHead(status, {
229
+ 'Content-Type': 'application/json',
230
+ 'Content-Length': Buffer.byteLength(body, 'utf8'),
231
+ 'Cache-Control': 'no-store',
232
+ // Signal end-of-connection so the client agent does not try to reuse the socket for a
233
+ // pipelined follow-up (whose body would collide with the one we are about to discard).
234
+ Connection: 'close',
235
+ });
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);
239
+ } catch (_e) {
240
+ // writeHead/end can throw if headers were already sent by a racing path — fall back to a
241
+ // straight destroy so the socket can never be left half-open.
242
+ destroyReq();
243
+ }
244
+ }
245
+
246
+ /**
247
+ * createHttpMcpServer — construct (but do NOT start) the HTTP/SSE MCP host.
248
+ *
249
+ * @param {object} [opts]
250
+ * @param {Function} [opts.buildFactory] () => { protocol, aggregator, engine, ctx }. Injectable so
251
+ * tests pass a sandbox build. Default = buildProtocol().
252
+ * @param {string} [opts.host] bind address (default 127.0.0.1 — loopback only).
253
+ * @param {number} [opts.port] bind port (default 9998; pass 0 for an OS-assigned port).
254
+ * @param {object} [opts.logger] an object with .error(msg); default writes to stderr.
255
+ * @returns {{
256
+ * start: () => Promise<{ port:number, url:string }>,
257
+ * stop: () => Promise<void>,
258
+ * reload: () => Promise<void>,
259
+ * broadcastToolsListChanged: () => number,
260
+ * health: () => object,
261
+ * get port(): (number|null),
262
+ * get url(): (string|null),
263
+ * get sseClientCount(): number,
264
+ * }}
265
+ */
266
+ function createHttpMcpServer(opts = {}) {
267
+ const o = opts || {};
268
+ const buildFactory = typeof o.buildFactory === 'function' ? o.buildFactory : defaultBuildFactory;
269
+ const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
270
+ // A port of 0 is VALID (OS-assigned) so we must distinguish "not provided" from 0.
271
+ const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
272
+ const log = makeLog(o.logger);
273
+
274
+ // ── Mutable runtime state ─────────────────────────────────────────────────────────────────
275
+ /** @type {object|null} the current Phase-2 build { protocol, aggregator, engine, ctx }. */
276
+ let build = null;
277
+ /** @type {import('node:http').Server|null} */
278
+ let httpServer = null;
279
+ /** @type {number|null} the actual bound port (resolved after listen — matters for port 0). */
280
+ let boundPort = null;
281
+ /** @type {Set<import('node:http').ServerResponse>} connected SSE client responses. */
282
+ const sseClients = new Set();
283
+ /** @type {boolean} latch so stop() is idempotent and start() can't double-bind. */
284
+ let started = false;
285
+ let stopping = false;
286
+ /**
287
+ * The result of the LAST connectAll() (from start() or reload()), surfaced by health() so a
288
+ * silent upstream connect-failure becomes VISIBLE (otherwise it only reaches stderr via log()).
289
+ * Shape mirrors Aggregator.connectAll's return: { connected: string[], failed: [{id,error}] }.
290
+ * Defensive: only ever holds the well-formed arrays — a connectAll throw (which shouldn't happen)
291
+ * or a malformed result leaves it EMPTY rather than half-populated. Reset to empty by stop().
292
+ * @type {{ connected: string[], failed: Array<{id:string, error:string}> }}
293
+ */
294
+ let lastConnect = { connected: [], failed: [] };
295
+
296
+ /**
297
+ * Record a connectAll() result into lastConnect, defensively. A well-formed result has BOTH a
298
+ * `connected` array and a `failed` array; anything else (a throw upstream, a malformed return)
299
+ * leaves lastConnect EMPTY so health() never reports stale/partial connect state.
300
+ * @param {*} res the value returned by Aggregator.connectAll()
301
+ */
302
+ function recordConnect(res) {
303
+ if (res && Array.isArray(res.connected) && Array.isArray(res.failed)) {
304
+ lastConnect = { connected: res.connected.slice(), failed: res.failed.slice() };
305
+ } else {
306
+ lastConnect = { connected: [], failed: [] };
307
+ }
308
+ }
309
+
310
+ /** The advertised base URL once bound (null before start / after stop). */
311
+ function currentUrl() {
312
+ return boundPort == null ? null : `http://${host}:${boundPort}`;
313
+ }
314
+
315
+ // ── SSE plumbing ──────────────────────────────────────────────────────────────────────────
316
+
317
+ /**
318
+ * Open an SSE stream on a GET /mcp request (the Streamable-HTTP standard server→client stream) or
319
+ * its GET /mcp/sse alias. Registers the response in the client set, writes the SSE preamble + a
320
+ * ": connected" comment, and starts an unref()-ed keep-alive timer. On close (client disconnect,
321
+ * server stop) the client is deregistered and its timer cleared.
322
+ */
323
+ function openSse(req, res) {
324
+ res.writeHead(200, {
325
+ 'Content-Type': 'text/event-stream',
326
+ 'Cache-Control': 'no-store',
327
+ Connection: 'keep-alive',
328
+ // Disable proxy buffering so events flush immediately (harmless when no proxy is present).
329
+ 'X-Accel-Buffering': 'no',
330
+ });
331
+ // Initial comment so the client knows the stream is live (comments start with ':').
332
+ res.write(': connected\n\n');
333
+
334
+ // Periodic keep-alive comment. unref() so a dangling stream NEVER blocks process exit, and
335
+ // clear it on close/stop so we don't write to a dead socket.
336
+ const keepAlive = setInterval(() => {
337
+ try {
338
+ res.write(': ping\n\n');
339
+ } catch (_e) {
340
+ /* a write to a half-closed socket is benign; close handler will clean up */
341
+ }
342
+ }, SSE_KEEPALIVE_MS);
343
+ if (typeof keepAlive.unref === 'function') keepAlive.unref();
344
+
345
+ // Track the timer ON the response object so stop()/close can clear it.
346
+ res._keepAlive = keepAlive;
347
+ sseClients.add(res);
348
+
349
+ const cleanup = () => {
350
+ clearInterval(keepAlive);
351
+ sseClients.delete(res);
352
+ };
353
+ req.on('close', cleanup);
354
+ req.on('error', cleanup);
355
+ res.on('close', cleanup);
356
+ res.on('error', cleanup);
357
+ }
358
+
359
+ /**
360
+ * Push one JSON-RPC notification to every connected SSE client as a `data:` event. Each client
361
+ * that errors mid-write is dropped (and cleaned up) so one dead socket can't sink the broadcast.
362
+ * @param {object} notification a NO-ID JSON-RPC notification object
363
+ * @returns {number} how many clients were written to
364
+ */
365
+ function pushToSse(notification) {
366
+ let line;
367
+ try {
368
+ line = JSON.stringify(notification);
369
+ } catch (_e) {
370
+ return 0; // an unserialisable notification is a no-op rather than a throw
371
+ }
372
+ let count = 0;
373
+ for (const res of Array.from(sseClients)) {
374
+ try {
375
+ res.write('data: ' + line + '\n\n');
376
+ count += 1;
377
+ } catch (_e) {
378
+ // Drop a dead client so it never blocks future broadcasts or process exit.
379
+ try {
380
+ if (res._keepAlive) clearInterval(res._keepAlive);
381
+ } catch (_e2) {
382
+ /* ignore */
383
+ }
384
+ sseClients.delete(res);
385
+ }
386
+ }
387
+ return count;
388
+ }
389
+
390
+ /**
391
+ * broadcastToolsListChanged — push notifications/tools/list_changed (no id) to every SSE client.
392
+ * A CLI honouring listChanged re-fetches tools/list (the design doc). Returns the
393
+ * number of clients notified.
394
+ * @returns {number}
395
+ */
396
+ function broadcastToolsListChanged() {
397
+ return pushToSse({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
398
+ }
399
+
400
+ // ── OAuth 2.1 resource-server gate (OPTIONAL; default OFF) ──────────────────────────────────
401
+ // The validator is memoised and rebuilt ONLY when the relevant auth config changes — so a live
402
+ // enable/disable/edit takes effect on the next request WITHOUT discarding jose's JWKS cache in
403
+ // steady state. The config is read FRESH per request (mirrors the logger + tool-state overlays),
404
+ // so the on/off decision is always live; the cache key just avoids rebuilding the remote-JWKS
405
+ // resolver when nothing relevant changed.
406
+ let _validator = null;
407
+ let _validatorSig = '';
408
+
409
+ function validatorFor(authCfg) {
410
+ const resourceMetadataUrl = boundPort == null ? '' : currentUrl() + WELL_KNOWN_PRM;
411
+ const sig = JSON.stringify([
412
+ authCfg.issuer, authCfg.audience, authCfg.jwksUri, authCfg.algorithms,
413
+ authCfg.requiredScopes, authCfg.clockToleranceSec, resourceMetadataUrl,
414
+ ]);
415
+ if (_validator && sig === _validatorSig) return _validator;
416
+ _validator = createValidator(authCfg, { resourceMetadataUrl, log });
417
+ _validatorSig = sig;
418
+ return _validator;
419
+ }
420
+
421
+ /**
422
+ * Enforce the OAuth gate for a protected route. Returns true if the request may proceed, false if
423
+ * it has ALREADY been answered (401/403/500). Fails CLOSED — any error rejects rather than allows.
424
+ */
425
+ async function passesAuth(req, res, authCfg) {
426
+ let verdict;
427
+ try {
428
+ const validator = validatorFor(authCfg);
429
+ verdict = await validator.validate(req.headers && req.headers.authorization);
430
+ } catch (err) {
431
+ // validate() is contracted never to throw; guard anyway and fail CLOSED (never allow on error).
432
+ log('auth validate threw (failing closed):', (err && err.message) || String(err));
433
+ verdict = { ok: false, status: 401, error: 'invalid_token', errorDescription: 'authentication error', wwwAuthenticate: 'Bearer' };
434
+ }
435
+ if (verdict && verdict.ok === true) return true;
436
+ // Audit the DENIAL (self-gating; no-op unless logging is on). A rejected access attempt is the
437
+ // security-relevant event — without this it was invisible (rejected at the gate, before the
438
+ // protocol/logging layer), so a run of failed auth attempts left no trace in the activity log.
439
+ try {
440
+ const rawUrl = req.url || '/';
441
+ const q = rawUrl.indexOf('?');
442
+ logger.log({
443
+ type: 'auth',
444
+ event: 'deny',
445
+ error: verdict && verdict.error,
446
+ status: verdict && verdict.status,
447
+ path: q === -1 ? rawUrl : rawUrl.slice(0, q),
448
+ });
449
+ } catch (_e) { /* never let audit logging break the gate */ }
450
+ sendAuthChallenge(res, verdict);
451
+ return false;
452
+ }
453
+
454
+ /** Send an OAuth-style challenge (401/403/500) with the WWW-Authenticate header. NEVER throws. */
455
+ function sendAuthChallenge(res, verdict) {
456
+ const status = (verdict && Number.isInteger(verdict.status)) ? verdict.status : 401;
457
+ const payload = { error: (verdict && verdict.error) || 'invalid_token' };
458
+ if (verdict && verdict.errorDescription) payload.error_description = verdict.errorDescription;
459
+ let body;
460
+ try { body = JSON.stringify(payload); } catch (_e) { body = '{"error":"invalid_token"}'; }
461
+ const headers = {
462
+ 'Content-Type': 'application/json',
463
+ 'Content-Length': Buffer.byteLength(body, 'utf8'),
464
+ 'Cache-Control': 'no-store',
465
+ };
466
+ if (verdict && verdict.wwwAuthenticate) headers['WWW-Authenticate'] = verdict.wwwAuthenticate;
467
+ try {
468
+ res.writeHead(status, headers);
469
+ res.end(body);
470
+ } catch (_e) {
471
+ /* response may already be committed; nothing more to do */
472
+ }
473
+ }
474
+
475
+ // ── HTTP request handling ─────────────────────────────────────────────────────────────────
476
+
477
+ /** POST /mcp — one JSON-RPC request → handleMessage(build, msg) → HTTP response. NEVER throws. */
478
+ async function handleMcpPost(req, res) {
479
+ // ── Content-Length pre-check ──────────────────────────────────────────────────────────────
480
+ // A client that ANNOUNCES an oversized body (Content-Length > MAX_BODY_BYTES) is rejected
481
+ // BEFORE a single byte of that body is read, so a multi-MiB POST never touches memory. We reply
482
+ // with the SAME clean -32700 parse-error contract as bad JSON / a streamed over-cap body, then
483
+ // close the connection (draining the unread body without buffering — see sendJsonAndClose).
484
+ // Chunked / Content-Length-less requests skip this branch and fall through to readBody's
485
+ // streaming cap (which still bounds memory and keeps the server alive).
486
+ const clHeader = req.headers && req.headers['content-length'];
487
+ if (clHeader != null) {
488
+ const declared = Number(clHeader);
489
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
490
+ log('POST /mcp rejected by Content-Length pre-check:', declared, '>', MAX_BODY_BYTES);
491
+ return sendJsonAndClose(
492
+ req,
493
+ res,
494
+ 200,
495
+ makeError(ERR.PARSE, 'Parse error: request body too large')
496
+ );
497
+ }
498
+ }
499
+
500
+ let raw;
501
+ try {
502
+ raw = await readBody(req);
503
+ } catch (err) {
504
+ // Oversized / aborted body → a clean parse error, server stays alive.
505
+ 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')));
507
+ }
508
+
509
+ let msg;
510
+ try {
511
+ msg = JSON.parse(raw);
512
+ } catch (_e) {
513
+ // Bad JSON → JSON-RPC parse error at HTTP 200 (the body is the error object).
514
+ return sendJson(res, 200, makeError(ERR.PARSE, 'Parse error: invalid JSON body'));
515
+ }
516
+
517
+ let response;
518
+ try {
519
+ response = await handleMessage(build, msg);
520
+ } catch (err) {
521
+ // handleMessage is contracted never to throw, but the transport must survive even if a
522
+ // wiring bug breaks that contract — a single bad request can never crash the host.
523
+ log('handleMessage threw (should not):', (err && err.stack) || String(err));
524
+ return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err)));
525
+ }
526
+
527
+ // A notification (no reply) → 202 Accepted, no body. A request → 200 with the JSON-RPC result.
528
+ if (response == null) {
529
+ res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
530
+ return res.end();
531
+ }
532
+ return sendJson(res, 200, response);
533
+ }
534
+
535
+ /**
536
+ * The single node:http request listener. Dispatches by method + path. async (it may await the
537
+ * OAuth gate). NEVER throws — start() wraps it so a rejection can't escape into the http server.
538
+ *
539
+ * Note on MCP-Protocol-Version: a client MAY send an `MCP-Protocol-Version` header on post-
540
+ * initialize requests. We read it leniently (unknown headers are ignored) and do not 400 on a
541
+ * mismatch — the version is negotiated in `initialize`. Strict per-request enforcement is a
542
+ * roadmap item; rejecting working clients over a header is the worse default for a local gateway.
543
+ */
544
+ async function onRequest(req, res) {
545
+ // Auth config is read FRESH per request (default OFF). When OFF the transport is loopback-only
546
+ // and unauthenticated (the original behaviour). When ON, a valid bearer token is the boundary.
547
+ const authCfg = authConfig.getConfig();
548
+ const authEnabled = authCfg.enabled === true;
549
+
550
+ // DNS-rebinding / non-loopback guard. With auth DISABLED the bind address is the ONLY boundary,
551
+ // so we reject any non-loopback Host (defence-in-depth, unchanged). With auth ENABLED the token
552
+ // is the boundary — remote clients legitimately present a non-loopback Host — so we skip this
553
+ // check and let the OAuth gate below authenticate every protected route.
554
+ if (!authEnabled && !isLoopbackHost(req.headers && req.headers.host)) {
555
+ return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Host'));
556
+ }
557
+
558
+ // Parse the path only (ignore query) without pulling in node:url's WHATWG parser overhead.
559
+ const rawUrl = req.url || '/';
560
+ const qIndex = rawUrl.indexOf('?');
561
+ const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
562
+ const method = (req.method || 'GET').toUpperCase();
563
+
564
+ try {
565
+ // RFC 9728 discovery document — the ONE unauthenticated route, and only when auth is enabled.
566
+ // A client hits this (or follows the `resource_metadata` hint from a 401) to learn which
567
+ // authorization server issues tokens for this gateway.
568
+ if (method === 'GET' && pathName === WELL_KNOWN_PRM) {
569
+ if (!authEnabled) return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: auth is not enabled'));
570
+ return sendJson(res, 200, protectedResourceMetadata(authCfg));
571
+ }
572
+
573
+ // OAuth gate: when auth is enabled, EVERY remaining route requires a valid bearer token —
574
+ // /mcp (POST + SSE) and /health alike. The discovery document above is the sole exception.
575
+ if (authEnabled) {
576
+ const allowed = await passesAuth(req, res, authCfg);
577
+ if (!allowed) return; // a 401/403/500 challenge has already been sent
578
+ }
579
+
580
+ if (method === 'POST' && pathName === '/mcp') {
581
+ // handleMcpPost is async + self-contained (it owns its try/catch); a rejection here would
582
+ // be unusual but we still guard so an unhandled rejection can't escape the listener.
583
+ handleMcpPost(req, res).catch((err) => {
584
+ log('handleMcpPost rejected (should not):', (err && err.stack) || String(err));
585
+ try {
586
+ sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error'));
587
+ } catch (_e) {
588
+ /* response may be partly written; nothing more to do */
589
+ }
590
+ });
591
+ return;
592
+ }
593
+
594
+ // Streamable-HTTP standard: a GET to the SINGLE /mcp endpoint with Accept: text/event-stream
595
+ // opens the server→client SSE stream. We serve that here, AND keep GET /mcp/sse as a working
596
+ // alias (the older HTTP+SSE shape, deprecated but still supported). Both routes
597
+ // hand off to the same openSse() — the loopback/auth guard above already gated the request.
598
+ if (method === 'GET' && (pathName === '/mcp' || pathName === '/mcp/sse')) {
599
+ return openSse(req, res);
600
+ }
601
+
602
+ if (method === 'GET' && pathName === '/health') {
603
+ return sendJson(res, 200, health());
604
+ }
605
+
606
+ // Unknown route.
607
+ return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: ' + method + ' ' + pathName));
608
+ } catch (err) {
609
+ // Last-resort guard: a synchronous throw in routing becomes a 500 JSON, never a crash.
610
+ log('onRequest threw:', (err && err.stack) || String(err));
611
+ try {
612
+ sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error'));
613
+ } catch (_e) {
614
+ /* response may already be committed */
615
+ }
616
+ }
617
+ }
618
+
619
+ // ── Health snapshot ───────────────────────────────────────────────────────────────────────
620
+
621
+ /**
622
+ * health — a SYNCHRONOUS snapshot of the host. `upstreamsConnected` + `toolsExposed` are read
623
+ * from the live aggregator (defensively — a degraded build has a null aggregator). NEVER throws.
624
+ *
625
+ * `connected` + `failed` mirror the LAST connectAll() (start/reload) so a per-upstream connect
626
+ * FAILURE is visible to the renderer instead of only landing on stderr. Both are fresh copies so
627
+ * a caller cannot mutate the internal snapshot. `connected` is the list of upstream ids that
628
+ * connected; `failed` is [{ id, error }] for the ones that did not (isolation guard, spawn
629
+ * failure, handshake/listTools failure, …). When the host is down both are empty (stop() resets).
630
+ *
631
+ * @returns {{ ok:boolean, server:object, protocolVersion:string, url:(string|null),
632
+ * upstreamsConnected:number, upstreams:number, toolsExposed:number,
633
+ * connected:string[], failed:Array<{id:string, error:string}> }}
634
+ */
635
+ function health() {
636
+ let toolsExposed = 0;
637
+ let upstreamsConnected = 0;
638
+ const agg = build && build.aggregator;
639
+ if (agg) {
640
+ try {
641
+ if (typeof agg.exposedToolDefinitions === 'function') {
642
+ const defs = agg.exposedToolDefinitions();
643
+ if (Array.isArray(defs)) toolsExposed = defs.length;
644
+ }
645
+ } catch (_e) {
646
+ /* a failing aggregator reports 0, never throws out of health */
647
+ }
648
+ try {
649
+ if (typeof agg.toolsByUpstream === 'function') {
650
+ // toolsByUpstream() returns a clone map of upstreamId -> tools for CONNECTED upstreams.
651
+ upstreamsConnected = Object.keys(agg.toolsByUpstream() || {}).length;
652
+ }
653
+ } catch (_e) {
654
+ /* ignore — report 0 */
655
+ }
656
+ }
657
+ // The advertised meta-tool count, DERIVED from the protocol so it never drifts from what
658
+ // tools/list actually returns (all four meta-tools — list, instructions, run, howto — are
659
+ // advertised; deriving rather than hardcoding keeps health honest through future changes).
660
+ let metaCount = 0;
661
+ try {
662
+ const proto = build && build.protocol;
663
+ const defs = proto && typeof proto.toolDefinitions === 'function' ? proto.toolDefinitions() : null;
664
+ if (Array.isArray(defs)) metaCount = defs.length;
665
+ } catch (_e) {
666
+ /* degraded build → metaCount stays 0 */
667
+ }
668
+ return {
669
+ ok: started === true && stopping === false,
670
+ server: SERVER_INFO,
671
+ protocolVersion: PROTOCOL_VERSION,
672
+ // The MCP endpoint the CLI points at (includes the /mcp path) — matches the .mcp.json
673
+ // target and what the status tooltip should show, not the bare origin.
674
+ url: boundPort == null ? null : currentUrl() + '/mcp',
675
+ upstreamsConnected,
676
+ // `upstreams` is an alias of upstreamsConnected for the renderer status tooltip
677
+ // (the renderer reads snap.upstreams / snap.url for its title text).
678
+ upstreams: upstreamsConnected,
679
+ toolsExposed,
680
+ // `tools` = what the CLI sees in tools/list: the four advertised meta-tools
681
+ // (toolfunnel_list_tools/tool_instructions/run_tool/howto) + the curated-direct surface.
682
+ // DERIVED from protocol.toolDefinitions() (metaCount) so it never drifts from the real
683
+ // advertised count. The register's long tail lives behind toolfunnel_list_tools, not as
684
+ // separate tools/list entries (a fresh host's status cell reads "up.4").
685
+ tools: metaCount + toolsExposed,
686
+ // Per-upstream connect outcome from the last connectAll() — makes silent connect-failures
687
+ // visible. Fresh copies so the caller can never mutate the internal snapshot.
688
+ connected: lastConnect.connected.slice(),
689
+ failed: lastConnect.failed.map((f) => ({ id: f.id, error: f.error })),
690
+ // OAuth resource-server status (no secrets — issuer/audience live in the well-known doc):
691
+ // whether auth is enabled, whether the optional jose dependency is installed, and whether the
692
+ // current config is coherent enough to enforce (misconfigured: configError() reason present).
693
+ auth: authSnapshot(),
694
+ // Lightweight in-memory observability: process-lifetime tools/call counters (total + per-tool
695
+ // + error counts). Resets on restart; complementary to the on-disk JSONL audit log.
696
+ metrics: metricsSnapshot(),
697
+ };
698
+ }
699
+
700
+ /** A defensive copy of the in-memory tool-call counters. NEVER throws. */
701
+ function metricsSnapshot() {
702
+ try {
703
+ return metrics.snapshot();
704
+ } catch (_e) {
705
+ return { startedAt: '', calls: 0, errors: 0, byTool: {} };
706
+ }
707
+ }
708
+
709
+ /** A no-secrets snapshot of the OAuth gate state for /health + the UI. NEVER throws. */
710
+ function authSnapshot() {
711
+ try {
712
+ const cfg = authConfig.getConfig();
713
+ return {
714
+ enabled: cfg.enabled === true,
715
+ joseInstalled: isJoseInstalled(),
716
+ configError: cfg.enabled ? authConfig.configError(cfg) : null,
717
+ };
718
+ } catch (_e) {
719
+ return { enabled: false, joseInstalled: false, configError: null };
720
+ }
721
+ }
722
+
723
+ // ── Lifecycle ─────────────────────────────────────────────────────────────────────────────
724
+
725
+ /**
726
+ * start — build the protocol+aggregator, connect every enabled upstream, then bind HTTP. On
727
+ * EADDRINUSE the returned promise REJECTS with a clear error (the caller decides what to do) —
728
+ * it does NOT crash the process. Resolves to { port, url } once listening.
729
+ *
730
+ * Port 0 → an OS-assigned ephemeral port; the actual port is read back from server.address().
731
+ * @returns {Promise<{ port:number, url:string }>}
732
+ */
733
+ async function start() {
734
+ if (started) {
735
+ // Idempotent-ish: a second start() on a running host just reports the live binding.
736
+ return { port: boundPort, url: currentUrl() };
737
+ }
738
+
739
+ // ── Fail-fast safety guards (BEFORE building/binding) ───────────────────────────────────────
740
+ // These throw a clear, typed error the caller surfaces — they NEVER start a misconfigured host.
741
+ const authCfg = authConfig.getConfig();
742
+ if (authCfg.enabled) {
743
+ // OAuth on but the dependency missing → refuse (else every request would fail closed anyway,
744
+ // but failing at start with an actionable message is far better than a dead host).
745
+ if (!isJoseInstalled()) {
746
+ throw new Error(
747
+ 'OAuth is enabled but the "jose" dependency is not installed — run `toolfunnel install-oauth` ' +
748
+ '(or click Install in the UI Auth panel), or disable auth in auth/auth.config.json.'
749
+ );
750
+ }
751
+ const cfgErr = authConfig.configError(authCfg);
752
+ if (cfgErr) throw new Error('OAuth is enabled but misconfigured: ' + cfgErr);
753
+ } else if (!isLoopbackHost(host)) {
754
+ // Binding off-loopback with NO auth would expose an UNAUTHENTICATED gateway to the network —
755
+ // exactly the footgun the loopback default exists to prevent. Require auth before exposing it.
756
+ throw new Error(
757
+ `refusing to bind non-loopback host "${host}" without OAuth enabled — enable auth ` +
758
+ '(and install jose) before exposing the gateway off localhost.'
759
+ );
760
+ }
761
+
762
+ // Build + connect upstreams BEFORE binding so tools/list advertises the curated surface from
763
+ // the very first request. connectAll() NEVER throws (failures land in failed[]); with the
764
+ // default EMPTY expose.json it is an instant no-op.
765
+ build = buildFactory();
766
+ if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
767
+ try {
768
+ const res = await build.aggregator.connectAll();
769
+ recordConnect(res); // surface connect outcome (connected[]/failed[]) via health()
770
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
771
+ log('connectAll: ' + res.failed.length + ' upstream(s) failed:',
772
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
773
+ }
774
+ } catch (err) {
775
+ recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
776
+ log('connectAll threw (ignored):', (err && err.message) || String(err));
777
+ }
778
+ } else {
779
+ recordConnect(null); // no aggregator → no upstreams; report empty connect state
780
+ }
781
+
782
+ // onRequest is async (it may await the OAuth gate); wrap it so a rejection can NEVER escape into
783
+ // the http server as an unhandled rejection — it degrades to a clean 500 instead.
784
+ httpServer = http.createServer((req, res) => {
785
+ onRequest(req, res).catch((err) => {
786
+ log('onRequest rejected (should not):', (err && err.stack) || String(err));
787
+ try { sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error')); } catch (_e) { /* committed */ }
788
+ });
789
+ });
790
+
791
+ return new Promise((resolve, reject) => {
792
+ // A bind failure (EADDRINUSE, EACCES) surfaces as an 'error' event before 'listening'. We
793
+ // reject ONCE and detach the listener so a later runtime error can't double-settle.
794
+ const onListenError = (err) => {
795
+ httpServer.removeListener('listening', onListening);
796
+ httpServer = null;
797
+ // connectAll() ran BEFORE listen() and may have spawned upstream children; a bind failure
798
+ // here would otherwise orphan them. Tear the aggregator down before rejecting. closeAll()
799
+ // never throws; fire-and-forget (this is a sync 'error' handler) — it's cleanup only.
800
+ const agg = build && build.aggregator;
801
+ build = null; // nothing bound — drop the half-built state so a retry rebuilds cleanly
802
+ if (agg && typeof agg.closeAll === 'function') {
803
+ try { agg.closeAll(); } catch (_e) { /* never throw */ }
804
+ }
805
+ reject(err); // clear, typed error (err.code === 'EADDRINUSE' etc.) — caller decides
806
+ };
807
+ const onListening = () => {
808
+ httpServer.removeListener('error', onListenError);
809
+ const addr = httpServer.address();
810
+ boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
811
+ started = true;
812
+ stopping = false;
813
+ // Swap the one-shot bind-error handler for a steady-state one so a later socket error
814
+ // (a client RST, etc.) is logged, not thrown.
815
+ httpServer.on('error', (e) => log('http server error:', (e && e.message) || String(e)));
816
+ log('listening on', currentUrl(), '(pid', process.pid + ')');
817
+ resolve({ port: boundPort, url: currentUrl() });
818
+ };
819
+ httpServer.once('error', onListenError);
820
+ httpServer.once('listening', onListening);
821
+ httpServer.listen(requestedPort, host);
822
+ });
823
+ }
824
+
825
+ /**
826
+ * stop — end every SSE client, close the HTTP server, then close the aggregator. Idempotent and
827
+ * NEVER throws. Clears every keep-alive timer so the process can exit cleanly (no dangling
828
+ * server, sockets, or timers).
829
+ * @returns {Promise<void>}
830
+ */
831
+ async function stop() {
832
+ if (!started && !httpServer) {
833
+ // Never started (or already fully stopped) — still close any build the caller may have left.
834
+ await closeAggregatorSafely();
835
+ return;
836
+ }
837
+ stopping = true;
838
+
839
+ // 1. Tear down SSE clients: clear their keep-alive timers and end the responses so their
840
+ // sockets close (an open SSE socket would otherwise keep the server from closing).
841
+ for (const res of Array.from(sseClients)) {
842
+ try {
843
+ if (res._keepAlive) clearInterval(res._keepAlive);
844
+ } catch (_e) {
845
+ /* ignore */
846
+ }
847
+ try {
848
+ res.end();
849
+ } catch (_e) {
850
+ /* ignore */
851
+ }
852
+ }
853
+ sseClients.clear();
854
+
855
+ // 2. Close the HTTP server (stops accepting + waits for in-flight sockets to drain). We also
856
+ // actively destroy any lingering sockets so close() can never hang the process exit.
857
+ if (httpServer) {
858
+ const srv = httpServer;
859
+ httpServer = null;
860
+ await new Promise((resolve) => {
861
+ let settled = false;
862
+ const done = () => {
863
+ if (settled) return;
864
+ settled = true;
865
+ resolve();
866
+ };
867
+ try {
868
+ srv.close(done);
869
+ } catch (_e) {
870
+ done();
871
+ }
872
+ // Destroy any keep-alive sockets still attached so close() resolves promptly.
873
+ try {
874
+ if (typeof srv.closeAllConnections === 'function') {
875
+ srv.closeAllConnections();
876
+ }
877
+ } catch (_e) {
878
+ /* closeAllConnections is Node ≥18.2; absent → close() still resolves on its own */
879
+ }
880
+ });
881
+ }
882
+
883
+ // 3. Close the aggregator (idempotent; never throws).
884
+ await closeAggregatorSafely();
885
+
886
+ boundPort = null;
887
+ started = false;
888
+ stopping = false;
889
+ lastConnect = { connected: [], failed: [] }; // drop the connect snapshot — the host is down
890
+ }
891
+
892
+ /** Close the current build's aggregator if present. Idempotent + never throws. */
893
+ async function closeAggregatorSafely() {
894
+ const agg = build && build.aggregator;
895
+ build = null;
896
+ if (agg && typeof agg.closeAll === 'function') {
897
+ try {
898
+ await agg.closeAll();
899
+ } catch (err) {
900
+ log('aggregator.closeAll threw (ignored):', (err && err.message) || String(err));
901
+ }
902
+ }
903
+ }
904
+
905
+ /**
906
+ * reload — apply an expose.json edit live: fully tear down the current aggregator, rebuild from
907
+ * the (now-edited) config, reconnect every enabled upstream, then broadcast list_changed so a
908
+ * connected CLI re-fetches tools/list. A clean full rebuild because expose edits are rare +
909
+ * user-driven (the design doc). The HTTP server keeps running across the reload — only
910
+ * the build is swapped. NEVER throws (connectAll never throws; closeAll never throws).
911
+ * @returns {Promise<void>}
912
+ */
913
+ async function reload() {
914
+ // 1. Close the old aggregator (this nulls `build`).
915
+ await closeAggregatorSafely();
916
+ // 2. Rebuild from the edited config + reconnect.
917
+ build = buildFactory();
918
+ if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
919
+ try {
920
+ const res = await build.aggregator.connectAll();
921
+ recordConnect(res); // refresh the health() connect snapshot for the new build
922
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
923
+ log('reload connectAll: ' + res.failed.length + ' upstream(s) failed:',
924
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
925
+ }
926
+ } catch (err) {
927
+ recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
928
+ log('reload connectAll threw (ignored):', (err && err.message) || String(err));
929
+ }
930
+ } else {
931
+ recordConnect(null); // no aggregator → no upstreams; report empty connect state
932
+ }
933
+ // 3. Tell connected clients the tool surface changed.
934
+ broadcastToolsListChanged();
935
+ }
936
+
937
+ // ── Public handle ───────────────────────────────────────────────────────────────────────────
938
+ return {
939
+ start,
940
+ stop,
941
+ reload,
942
+ broadcastToolsListChanged,
943
+ health,
944
+ get port() {
945
+ return boundPort;
946
+ },
947
+ get url() {
948
+ return currentUrl();
949
+ },
950
+ get sseClientCount() {
951
+ return sseClients.size;
952
+ },
953
+ };
954
+ }
955
+
956
+ module.exports = {
957
+ createHttpMcpServer,
958
+ // Exported for unit tests / reuse — the small pure seams.
959
+ isLoopbackHost,
960
+ PROTOCOL_VERSION,
961
+ SERVER_INFO,
962
+ };