toolfunnel 0.5.0 → 0.7.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 (72) hide show
  1. package/README.md +235 -167
  2. package/SECURITY.md +84 -0
  3. package/bin/toolfunnel.js +451 -147
  4. package/docs/KNOWN_BUGS.md +80 -33
  5. package/docs/MANUAL.pdf +0 -0
  6. package/docs/design.md +84 -0
  7. package/docs/packaging.md +115 -115
  8. package/hooks/hooks.manifest.json +27 -27
  9. package/hooks/scripts/example-audit.js +21 -21
  10. package/hooks/scripts/example-deny-dangerous.js +39 -31
  11. package/mcp/expose.example.json +30 -30
  12. package/mcp/servers/mock-modern/server.js +147 -0
  13. package/mcp/servers/mock-upstream/server.js +222 -12
  14. package/package.json +67 -65
  15. package/src/auth/config.js +174 -174
  16. package/src/auth/install.js +181 -181
  17. package/src/auth/resource-server.js +353 -344
  18. package/src/core/child-reaper.js +61 -0
  19. package/src/core/config-home.js +134 -134
  20. package/src/core/events.js +105 -105
  21. package/src/core/hook-engine.js +329 -275
  22. package/src/core/hook-loader.js +824 -791
  23. package/src/core/hook-runner.js +566 -346
  24. package/src/core/logger.js +297 -171
  25. package/src/core/matcher.js +100 -51
  26. package/src/core/metrics.js +75 -75
  27. package/src/core/rename-migration.js +163 -0
  28. package/src/core/requires.js +138 -138
  29. package/src/core/server-config.js +98 -77
  30. package/src/extend/add-hook.md +152 -142
  31. package/src/extend/add-mcp.md +119 -121
  32. package/src/extend/configure.md +88 -0
  33. package/src/extend/create-tool.md +168 -152
  34. package/src/extend/howto.js +102 -98
  35. package/src/extend/package.md +190 -187
  36. package/src/extend/wrap.md +99 -0
  37. package/src/mcp/aggregator.js +1674 -807
  38. package/src/mcp/expose-store.js +577 -430
  39. package/src/mcp/gated-run.js +210 -194
  40. package/src/mcp/http-transport.js +1563 -1012
  41. package/src/mcp/mcp-client.js +1070 -530
  42. package/src/mcp/modern.js +406 -0
  43. package/src/mcp/protocol.js +468 -466
  44. package/src/mcp/server.js +3112 -1405
  45. package/src/tools/registry.js +661 -513
  46. package/src/tools/tool-state.js +170 -104
  47. package/src/ui/public/app.js +1187 -1055
  48. package/src/ui/public/index.html +286 -249
  49. package/src/ui/public/styles.css +535 -535
  50. package/src/ui/server.js +1616 -1190
  51. package/tools/scripts/base64.js +139 -139
  52. package/tools/scripts/danger.js +61 -61
  53. package/tools/scripts/echo.js +39 -39
  54. package/tools/scripts/hash.js +101 -101
  55. package/tools/scripts/json.js +109 -109
  56. package/tools/scripts/text-stats.js +131 -131
  57. package/tools/scripts/tf-env.js +43 -43
  58. package/tools/scripts/tf-hook-add.js +151 -151
  59. package/tools/scripts/tf-hook-set.js +120 -112
  60. package/tools/scripts/tf-list.js +192 -192
  61. package/tools/scripts/tf-log.js +86 -86
  62. package/tools/scripts/tf-mcp-add.js +117 -117
  63. package/tools/scripts/tf-mcp-set.js +121 -102
  64. package/tools/scripts/tf-pack.js +237 -237
  65. package/tools/scripts/tf-tool-add.js +134 -129
  66. package/tools/scripts/tf-tool-set.js +142 -142
  67. package/tools/scripts/tf-wrap.js +103 -0
  68. package/tools/scripts/uuid.js +102 -102
  69. package/tools/tools.register.json +14 -3
  70. package/src/core/tool-registry.js +0 -180
  71. package/src/packages/packager.js +0 -715
  72. package/src/tools/drift.js +0 -138
@@ -1,1012 +1,1563 @@
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
- /**
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
- */
156
- function readBody(req) {
157
- return new Promise((resolve, reject) => {
158
- let size = 0;
159
- const chunks = [];
160
- let done = false;
161
- const finish = (fn, val) => {
162
- if (done) return;
163
- done = true;
164
- fn(val);
165
- };
166
- req.on('data', (chunk) => {
167
- if (done) return; // over-cap already signalled — discard, don't buffer
168
- size += chunk.length;
169
- if (size > MAX_BODY_BYTES) {
170
- // Stop buffering; the caller flush-then-closes with the same -32700 as the declared-CL path.
171
- finish(reject, new Error('request body too large'));
172
- return;
173
- }
174
- chunks.push(chunk);
175
- });
176
- req.on('end', () => finish(resolve, Buffer.concat(chunks).toString('utf8')));
177
- req.on('error', (err) => finish(reject, err));
178
- });
179
- }
180
-
181
- /** Write a JSON response with a status code. Defensive: a serialise failure degrades to 500 text. */
182
- function sendJson(res, status, obj) {
183
- let body;
184
- try {
185
- body = JSON.stringify(obj);
186
- } catch (_e) {
187
- body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
188
- }
189
- res.writeHead(status, {
190
- 'Content-Type': 'application/json',
191
- 'Content-Length': Buffer.byteLength(body, 'utf8'),
192
- 'Cache-Control': 'no-store',
193
- });
194
- res.end(body);
195
- }
196
-
197
- /**
198
- * Send a JSON response and then CLOSE the connection, draining the unread request body WITHOUT
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:
205
- * 1. announce `Connection: close` so the client/keep-alive agent expects the socket to end here,
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).
212
- * Defensive: like sendJson, a serialise failure degrades to a 500-shaped error body; the whole
213
- * function is wrapped so a write-after-headers race can never throw out of handleMcpPost.
214
- * @param {import('node:http').IncomingMessage} req
215
- * @param {import('node:http').ServerResponse} res
216
- * @param {number} status
217
- * @param {object} obj
218
- */
219
- function sendJsonAndClose(req, res, status, obj) {
220
- let body;
221
- try {
222
- body = JSON.stringify(obj);
223
- } catch (_e) {
224
- body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
225
- }
226
- const destroyReq = () => {
227
- try {
228
- req.destroy();
229
- } catch (_e) {
230
- /* the socket may already be gone; nothing more to do */
231
- }
232
- };
233
- try {
234
- res.writeHead(status, {
235
- 'Content-Type': 'application/json',
236
- 'Content-Length': Buffer.byteLength(body, 'utf8'),
237
- 'Cache-Control': 'no-store',
238
- // Signal end-of-connection so the client agent does not try to reuse the socket for a
239
- // pipelined follow-up (whose body would collide with the one we are about to discard).
240
- Connection: 'close',
241
- });
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
- });
256
- } catch (_e) {
257
- // writeHead/end can throw if headers were already sent by a racing path — fall back to a
258
- // straight destroy so the socket can never be left half-open.
259
- destroyReq();
260
- }
261
- }
262
-
263
- /**
264
- * createHttpMcpServer — construct (but do NOT start) the HTTP/SSE MCP host.
265
- *
266
- * @param {object} [opts]
267
- * @param {Function} [opts.buildFactory] () => { protocol, aggregator, engine, ctx }. Injectable so
268
- * tests pass a sandbox build. Default = buildProtocol().
269
- * @param {string} [opts.host] bind address (default 127.0.0.1 — loopback only).
270
- * @param {number} [opts.port] bind port (default 9998; pass 0 for an OS-assigned port).
271
- * @param {object} [opts.logger] an object with .error(msg); default writes to stderr.
272
- * @returns {{
273
- * start: () => Promise<{ port:number, url:string }>,
274
- * stop: () => Promise<void>,
275
- * reload: () => Promise<void>,
276
- * broadcastToolsListChanged: () => number,
277
- * health: () => object,
278
- * get port(): (number|null),
279
- * get url(): (string|null),
280
- * get sseClientCount(): number,
281
- * }}
282
- */
283
- function createHttpMcpServer(opts = {}) {
284
- const o = opts || {};
285
- const buildFactory = typeof o.buildFactory === 'function' ? o.buildFactory : defaultBuildFactory;
286
- const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
287
- // A port of 0 is VALID (OS-assigned) so we must distinguish "not provided" from 0.
288
- const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
289
- const log = makeLog(o.logger);
290
-
291
- // ── Mutable runtime state ─────────────────────────────────────────────────────────────────
292
- /** @type {object|null} the current Phase-2 build { protocol, aggregator, engine, ctx }. */
293
- let build = null;
294
- /** @type {import('node:http').Server|null} */
295
- let httpServer = null;
296
- /** @type {number|null} the actual bound port (resolved after listen — matters for port 0). */
297
- let boundPort = null;
298
- /** @type {Set<import('node:http').ServerResponse>} connected SSE client responses. */
299
- const sseClients = new Set();
300
- /** @type {boolean} latch so stop() is idempotent and start() can't double-bind. */
301
- let started = false;
302
- let stopping = false;
303
- /** @type {(() => void)|null} teardown for the config hot-reload watchers (armed in start()). */
304
- let configWatchersStop = null;
305
- /**
306
- * The result of the LAST connectAll() (from start() or reload()), surfaced by health() so a
307
- * silent upstream connect-failure becomes VISIBLE (otherwise it only reaches stderr via log()).
308
- * Shape mirrors Aggregator.connectAll's return: { connected: string[], failed: [{id,error}] }.
309
- * Defensive: only ever holds the well-formed arrays — a connectAll throw (which shouldn't happen)
310
- * or a malformed result leaves it EMPTY rather than half-populated. Reset to empty by stop().
311
- * @type {{ connected: string[], failed: Array<{id:string, error:string}> }}
312
- */
313
- let lastConnect = { connected: [], failed: [] };
314
-
315
- /**
316
- * Record a connectAll() result into lastConnect, defensively. A well-formed result has BOTH a
317
- * `connected` array and a `failed` array; anything else (a throw upstream, a malformed return)
318
- * leaves lastConnect EMPTY so health() never reports stale/partial connect state.
319
- * @param {*} res the value returned by Aggregator.connectAll()
320
- */
321
- function recordConnect(res) {
322
- if (res && Array.isArray(res.connected) && Array.isArray(res.failed)) {
323
- lastConnect = { connected: res.connected.slice(), failed: res.failed.slice() };
324
- } else {
325
- lastConnect = { connected: [], failed: [] };
326
- }
327
- }
328
-
329
- /** The advertised base URL once bound (null before start / after stop). */
330
- function currentUrl() {
331
- return boundPort == null ? null : `http://${host}:${boundPort}`;
332
- }
333
-
334
- // ── SSE plumbing ──────────────────────────────────────────────────────────────────────────
335
-
336
- /**
337
- * Open an SSE stream on a GET /mcp request (the Streamable-HTTP standard server→client stream) or
338
- * its GET /mcp/sse alias. Registers the response in the client set, writes the SSE preamble + a
339
- * ": connected" comment, and starts an unref()-ed keep-alive timer. On close (client disconnect,
340
- * server stop) the client is deregistered and its timer cleared.
341
- */
342
- function openSse(req, res) {
343
- res.writeHead(200, {
344
- 'Content-Type': 'text/event-stream',
345
- 'Cache-Control': 'no-store',
346
- Connection: 'keep-alive',
347
- // Disable proxy buffering so events flush immediately (harmless when no proxy is present).
348
- 'X-Accel-Buffering': 'no',
349
- });
350
- // Initial comment so the client knows the stream is live (comments start with ':').
351
- res.write(': connected\n\n');
352
-
353
- // Periodic keep-alive comment. unref() so a dangling stream NEVER blocks process exit, and
354
- // clear it on close/stop so we don't write to a dead socket.
355
- const keepAlive = setInterval(() => {
356
- try {
357
- res.write(': ping\n\n');
358
- } catch (_e) {
359
- /* a write to a half-closed socket is benign; close handler will clean up */
360
- }
361
- }, SSE_KEEPALIVE_MS);
362
- if (typeof keepAlive.unref === 'function') keepAlive.unref();
363
-
364
- // Track the timer ON the response object so stop()/close can clear it.
365
- res._keepAlive = keepAlive;
366
- sseClients.add(res);
367
-
368
- const cleanup = () => {
369
- clearInterval(keepAlive);
370
- sseClients.delete(res);
371
- };
372
- req.on('close', cleanup);
373
- req.on('error', cleanup);
374
- res.on('close', cleanup);
375
- res.on('error', cleanup);
376
- }
377
-
378
- /**
379
- * Push one JSON-RPC notification to every connected SSE client as a `data:` event. Each client
380
- * that errors mid-write is dropped (and cleaned up) so one dead socket can't sink the broadcast.
381
- * @param {object} notification a NO-ID JSON-RPC notification object
382
- * @returns {number} how many clients were written to
383
- */
384
- function pushToSse(notification) {
385
- let line;
386
- try {
387
- line = JSON.stringify(notification);
388
- } catch (_e) {
389
- return 0; // an unserialisable notification is a no-op rather than a throw
390
- }
391
- let count = 0;
392
- for (const res of Array.from(sseClients)) {
393
- try {
394
- res.write('data: ' + line + '\n\n');
395
- count += 1;
396
- } catch (_e) {
397
- // Drop a dead client so it never blocks future broadcasts or process exit.
398
- try {
399
- if (res._keepAlive) clearInterval(res._keepAlive);
400
- } catch (_e2) {
401
- /* ignore */
402
- }
403
- sseClients.delete(res);
404
- }
405
- }
406
- return count;
407
- }
408
-
409
- /**
410
- * broadcastToolsListChanged — push notifications/tools/list_changed (no id) to every SSE client.
411
- * A CLI honouring listChanged re-fetches tools/list (the design doc). Returns the
412
- * number of clients notified.
413
- * @returns {number}
414
- */
415
- function broadcastToolsListChanged() {
416
- return pushToSse({ jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' });
417
- }
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
-
429
- // ── OAuth 2.1 resource-server gate (OPTIONAL; default OFF) ──────────────────────────────────
430
- // The validator is memoised and rebuilt ONLY when the relevant auth config changes — so a live
431
- // enable/disable/edit takes effect on the next request WITHOUT discarding jose's JWKS cache in
432
- // steady state. The config is read FRESH per request (mirrors the logger + tool-state overlays),
433
- // so the on/off decision is always live; the cache key just avoids rebuilding the remote-JWKS
434
- // resolver when nothing relevant changed.
435
- let _validator = null;
436
- let _validatorSig = '';
437
-
438
- function validatorFor(authCfg) {
439
- const resourceMetadataUrl = boundPort == null ? '' : currentUrl() + WELL_KNOWN_PRM;
440
- const sig = JSON.stringify([
441
- authCfg.issuer, authCfg.audience, authCfg.jwksUri, authCfg.algorithms,
442
- authCfg.requiredScopes, authCfg.clockToleranceSec, resourceMetadataUrl,
443
- ]);
444
- if (_validator && sig === _validatorSig) return _validator;
445
- _validator = createValidator(authCfg, { resourceMetadataUrl, log });
446
- _validatorSig = sig;
447
- return _validator;
448
- }
449
-
450
- /**
451
- * Enforce the OAuth gate for a protected route. Returns true if the request may proceed, false if
452
- * it has ALREADY been answered (401/403/500). Fails CLOSED — any error rejects rather than allows.
453
- */
454
- async function passesAuth(req, res, authCfg) {
455
- let verdict;
456
- try {
457
- const validator = validatorFor(authCfg);
458
- verdict = await validator.validate(req.headers && req.headers.authorization);
459
- } catch (err) {
460
- // validate() is contracted never to throw; guard anyway and fail CLOSED (never allow on error).
461
- log('auth validate threw (failing closed):', (err && err.message) || String(err));
462
- verdict = { ok: false, status: 401, error: 'invalid_token', errorDescription: 'authentication error', wwwAuthenticate: 'Bearer' };
463
- }
464
- if (verdict && verdict.ok === true) return true;
465
- // Audit the DENIAL (self-gating; no-op unless logging is on). A rejected access attempt is the
466
- // security-relevant event — without this it was invisible (rejected at the gate, before the
467
- // protocol/logging layer), so a run of failed auth attempts left no trace in the activity log.
468
- try {
469
- const rawUrl = req.url || '/';
470
- const q = rawUrl.indexOf('?');
471
- logger.log({
472
- type: 'auth',
473
- event: 'deny',
474
- error: verdict && verdict.error,
475
- status: verdict && verdict.status,
476
- path: q === -1 ? rawUrl : rawUrl.slice(0, q),
477
- });
478
- } catch (_e) { /* never let audit logging break the gate */ }
479
- sendAuthChallenge(res, verdict);
480
- return false;
481
- }
482
-
483
- /** Send an OAuth-style challenge (401/403/500) with the WWW-Authenticate header. NEVER throws. */
484
- function sendAuthChallenge(res, verdict) {
485
- const status = (verdict && Number.isInteger(verdict.status)) ? verdict.status : 401;
486
- const payload = { error: (verdict && verdict.error) || 'invalid_token' };
487
- if (verdict && verdict.errorDescription) payload.error_description = verdict.errorDescription;
488
- let body;
489
- try { body = JSON.stringify(payload); } catch (_e) { body = '{"error":"invalid_token"}'; }
490
- const headers = {
491
- 'Content-Type': 'application/json',
492
- 'Content-Length': Buffer.byteLength(body, 'utf8'),
493
- 'Cache-Control': 'no-store',
494
- };
495
- if (verdict && verdict.wwwAuthenticate) headers['WWW-Authenticate'] = verdict.wwwAuthenticate;
496
- try {
497
- res.writeHead(status, headers);
498
- res.end(body);
499
- } catch (_e) {
500
- /* response may already be committed; nothing more to do */
501
- }
502
- }
503
-
504
- // ── HTTP request handling ─────────────────────────────────────────────────────────────────
505
-
506
- /** POST /mcp — one JSON-RPC request → handleMessage(build, msg) → HTTP response. NEVER throws. */
507
- async function handleMcpPost(req, res) {
508
- // ── Content-Length pre-check ──────────────────────────────────────────────────────────────
509
- // A client that ANNOUNCES an oversized body (Content-Length > MAX_BODY_BYTES) is rejected
510
- // BEFORE a single byte of that body is read, so a multi-MiB POST never touches memory. We reply
511
- // with the SAME clean -32700 parse-error contract as bad JSON / a streamed over-cap body, then
512
- // close the connection (draining the unread body without buffering — see sendJsonAndClose).
513
- // Chunked / Content-Length-less requests skip this branch and fall through to readBody's
514
- // streaming cap (which still bounds memory and keeps the server alive).
515
- const clHeader = req.headers && req.headers['content-length'];
516
- if (clHeader != null) {
517
- const declared = Number(clHeader);
518
- if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
519
- log('POST /mcp rejected by Content-Length pre-check:', declared, '>', MAX_BODY_BYTES);
520
- return sendJsonAndClose(
521
- req,
522
- res,
523
- 200,
524
- makeError(ERR.PARSE, 'Parse error: request body too large')
525
- );
526
- }
527
- }
528
-
529
- let raw;
530
- try {
531
- raw = await readBody(req);
532
- } catch (err) {
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).
536
- log('POST /mcp body read failed:', (err && err.message) || String(err));
537
- return sendJsonAndClose(req, res, 200, makeError(ERR.PARSE, 'Parse error: ' + ((err && err.message) || 'bad body')));
538
- }
539
-
540
- let msg;
541
- try {
542
- msg = JSON.parse(raw);
543
- } catch (_e) {
544
- // Bad JSON → JSON-RPC parse error at HTTP 200 (the body is the error object).
545
- return sendJson(res, 200, makeError(ERR.PARSE, 'Parse error: invalid JSON body'));
546
- }
547
-
548
- let response;
549
- try {
550
- response = await handleMessage(build, msg);
551
- } catch (err) {
552
- // handleMessage is contracted never to throw, but the transport must survive even if a
553
- // wiring bug breaks that contract — a single bad request can never crash the host.
554
- log('handleMessage threw (should not):', (err && err.stack) || String(err));
555
- return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err)));
556
- }
557
-
558
- // A notification (no reply) → 202 Accepted, no body. A request → 200 with the JSON-RPC result.
559
- if (response == null) {
560
- res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
561
- return res.end();
562
- }
563
- return sendJson(res, 200, response);
564
- }
565
-
566
- /**
567
- * The single node:http request listener. Dispatches by method + path. async (it may await the
568
- * OAuth gate). NEVER throws — start() wraps it so a rejection can't escape into the http server.
569
- *
570
- * Note on MCP-Protocol-Version: a client MAY send an `MCP-Protocol-Version` header on post-
571
- * initialize requests. We read it leniently (unknown headers are ignored) and do not 400 on a
572
- * mismatch — the version is negotiated in `initialize`. Strict per-request enforcement is a
573
- * roadmap item; rejecting working clients over a header is the worse default for a local gateway.
574
- */
575
- async function onRequest(req, res) {
576
- // Auth config is read FRESH per request (default OFF). When OFF the transport is loopback-only
577
- // and unauthenticated (the original behaviour). When ON, a valid bearer token is the boundary.
578
- const authCfg = authConfig.getConfig();
579
- const authEnabled = authCfg.enabled === true;
580
-
581
- // DNS-rebinding / non-loopback guard. With auth DISABLED the bind address is the ONLY boundary,
582
- // so we reject any non-loopback Host (defence-in-depth, unchanged). With auth ENABLED the token
583
- // is the boundary — remote clients legitimately present a non-loopback Host — so we skip this
584
- // check and let the OAuth gate below authenticate every protected route.
585
- if (!authEnabled && !isLoopbackHost(req.headers && req.headers.host)) {
586
- return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Host'));
587
- }
588
-
589
- // Parse the path only (ignore query) without pulling in node:url's WHATWG parser overhead.
590
- const rawUrl = req.url || '/';
591
- const qIndex = rawUrl.indexOf('?');
592
- const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
593
- const method = (req.method || 'GET').toUpperCase();
594
-
595
- try {
596
- // RFC 9728 discovery document — the ONE unauthenticated route, and only when auth is enabled.
597
- // A client hits this (or follows the `resource_metadata` hint from a 401) to learn which
598
- // authorization server issues tokens for this gateway.
599
- if (method === 'GET' && pathName === WELL_KNOWN_PRM) {
600
- if (!authEnabled) return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: auth is not enabled'));
601
- return sendJson(res, 200, protectedResourceMetadata(authCfg));
602
- }
603
-
604
- // OAuth gate: when auth is enabled, EVERY remaining route requires a valid bearer token —
605
- // /mcp (POST + SSE) and /health alike. The discovery document above is the sole exception.
606
- if (authEnabled) {
607
- const allowed = await passesAuth(req, res, authCfg);
608
- if (!allowed) return; // a 401/403/500 challenge has already been sent
609
- }
610
-
611
- if (method === 'POST' && pathName === '/mcp') {
612
- // handleMcpPost is async + self-contained (it owns its try/catch); a rejection here would
613
- // be unusual but we still guard so an unhandled rejection can't escape the listener.
614
- handleMcpPost(req, res).catch((err) => {
615
- log('handleMcpPost rejected (should not):', (err && err.stack) || String(err));
616
- try {
617
- sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error'));
618
- } catch (_e) {
619
- /* response may be partly written; nothing more to do */
620
- }
621
- });
622
- return;
623
- }
624
-
625
- // Streamable-HTTP standard: a GET to the SINGLE /mcp endpoint with Accept: text/event-stream
626
- // opens the server→client SSE stream. We serve that here, AND keep GET /mcp/sse as a working
627
- // alias (the older HTTP+SSE shape, deprecated but still supported). Both routes
628
- // hand off to the same openSse() — the loopback/auth guard above already gated the request.
629
- if (method === 'GET' && (pathName === '/mcp' || pathName === '/mcp/sse')) {
630
- return openSse(req, res);
631
- }
632
-
633
- if (method === 'GET' && pathName === '/health') {
634
- return sendJson(res, 200, health());
635
- }
636
-
637
- // Unknown route.
638
- return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: ' + method + ' ' + pathName));
639
- } catch (err) {
640
- // Last-resort guard: a synchronous throw in routing becomes a 500 JSON, never a crash.
641
- log('onRequest threw:', (err && err.stack) || String(err));
642
- try {
643
- sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error'));
644
- } catch (_e) {
645
- /* response may already be committed */
646
- }
647
- }
648
- }
649
-
650
- // ── Health snapshot ───────────────────────────────────────────────────────────────────────
651
-
652
- /**
653
- * health — a SYNCHRONOUS snapshot of the host. `upstreamsConnected` + `toolsExposed` are read
654
- * from the live aggregator (defensively — a degraded build has a null aggregator). NEVER throws.
655
- *
656
- * `connected` + `failed` mirror the LAST connectAll() (start/reload) so a per-upstream connect
657
- * FAILURE is visible to the renderer instead of only landing on stderr. Both are fresh copies so
658
- * a caller cannot mutate the internal snapshot. `connected` is the list of upstream ids that
659
- * connected; `failed` is [{ id, error }] for the ones that did not (isolation guard, spawn
660
- * failure, handshake/listTools failure, …). When the host is down both are empty (stop() resets).
661
- *
662
- * @returns {{ ok:boolean, server:object, protocolVersion:string, url:(string|null),
663
- * upstreamsConnected:number, upstreams:number, toolsExposed:number,
664
- * connected:string[], failed:Array<{id:string, error:string}> }}
665
- */
666
- function health() {
667
- let toolsExposed = 0;
668
- let upstreamsConnected = 0;
669
- const agg = build && build.aggregator;
670
- if (agg) {
671
- try {
672
- if (typeof agg.exposedToolDefinitions === 'function') {
673
- const defs = agg.exposedToolDefinitions();
674
- if (Array.isArray(defs)) toolsExposed = defs.length;
675
- }
676
- } catch (_e) {
677
- /* a failing aggregator reports 0, never throws out of health */
678
- }
679
- try {
680
- if (typeof agg.toolsByUpstream === 'function') {
681
- // toolsByUpstream() returns a clone map of upstreamId -> tools for CONNECTED upstreams.
682
- upstreamsConnected = Object.keys(agg.toolsByUpstream() || {}).length;
683
- }
684
- } catch (_e) {
685
- /* ignore — report 0 */
686
- }
687
- }
688
- // The advertised meta-tool count, DERIVED from the protocol so it never drifts from what
689
- // tools/list actually returns (all four meta-tools — list, instructions, run, howto — are
690
- // advertised; deriving rather than hardcoding keeps health honest through future changes).
691
- let metaCount = 0;
692
- try {
693
- const proto = build && build.protocol;
694
- const defs = proto && typeof proto.toolDefinitions === 'function' ? proto.toolDefinitions() : null;
695
- if (Array.isArray(defs)) metaCount = defs.length;
696
- } catch (_e) {
697
- /* degraded build → metaCount stays 0 */
698
- }
699
- return {
700
- ok: started === true && stopping === false,
701
- server: SERVER_INFO,
702
- protocolVersion: PROTOCOL_VERSION,
703
- // The MCP endpoint the CLI points at (includes the /mcp path) — matches the .mcp.json
704
- // target and what the status tooltip should show, not the bare origin.
705
- url: boundPort == null ? null : currentUrl() + '/mcp',
706
- upstreamsConnected,
707
- // `upstreams` is an alias of upstreamsConnected for the renderer status tooltip
708
- // (the renderer reads snap.upstreams / snap.url for its title text).
709
- upstreams: upstreamsConnected,
710
- toolsExposed,
711
- // `tools` = what the CLI sees in tools/list: the four advertised meta-tools
712
- // (toolfunnel_list_tools/tool_instructions/run_tool/howto) + the curated-direct surface.
713
- // DERIVED from protocol.toolDefinitions() (metaCount) so it never drifts from the real
714
- // advertised count. The register's long tail lives behind toolfunnel_list_tools, not as
715
- // separate tools/list entries (a fresh host's status cell reads "up.4").
716
- tools: metaCount + toolsExposed,
717
- // Per-upstream connect outcome from the last connectAll() — makes silent connect-failures
718
- // visible. Fresh copies so the caller can never mutate the internal snapshot.
719
- connected: lastConnect.connected.slice(),
720
- failed: lastConnect.failed.map((f) => ({ id: f.id, error: f.error })),
721
- // OAuth resource-server status (no secrets — issuer/audience live in the well-known doc):
722
- // whether auth is enabled, whether the optional jose dependency is installed, and whether the
723
- // current config is coherent enough to enforce (misconfigured: configError() reason present).
724
- auth: authSnapshot(),
725
- // Lightweight in-memory observability: process-lifetime tools/call counters (total + per-tool
726
- // + error counts). Resets on restart; complementary to the on-disk JSONL audit log.
727
- metrics: metricsSnapshot(),
728
- };
729
- }
730
-
731
- /** A defensive copy of the in-memory tool-call counters. NEVER throws. */
732
- function metricsSnapshot() {
733
- try {
734
- return metrics.snapshot();
735
- } catch (_e) {
736
- return { startedAt: '', calls: 0, errors: 0, byTool: {} };
737
- }
738
- }
739
-
740
- /** A no-secrets snapshot of the OAuth gate state for /health + the UI. NEVER throws. */
741
- function authSnapshot() {
742
- try {
743
- const cfg = authConfig.getConfig();
744
- return {
745
- enabled: cfg.enabled === true,
746
- joseInstalled: isJoseInstalled(),
747
- configError: cfg.enabled ? authConfig.configError(cfg) : null,
748
- };
749
- } catch (_e) {
750
- return { enabled: false, joseInstalled: false, configError: null };
751
- }
752
- }
753
-
754
- // ── Lifecycle ─────────────────────────────────────────────────────────────────────────────
755
-
756
- /**
757
- * start — build the protocol+aggregator, connect every enabled upstream, then bind HTTP. On
758
- * EADDRINUSE the returned promise REJECTS with a clear error (the caller decides what to do) —
759
- * it does NOT crash the process. Resolves to { port, url } once listening.
760
- *
761
- * Port 0 → an OS-assigned ephemeral port; the actual port is read back from server.address().
762
- * @returns {Promise<{ port:number, url:string }>}
763
- */
764
- async function start() {
765
- if (started) {
766
- // Idempotent-ish: a second start() on a running host just reports the live binding.
767
- return { port: boundPort, url: currentUrl() };
768
- }
769
-
770
- // ── Fail-fast safety guards (BEFORE building/binding) ───────────────────────────────────────
771
- // These throw a clear, typed error the caller surfaces — they NEVER start a misconfigured host.
772
- const authCfg = authConfig.getConfig();
773
- if (authCfg.enabled) {
774
- // OAuth on but the dependency missing → refuse (else every request would fail closed anyway,
775
- // but failing at start with an actionable message is far better than a dead host).
776
- if (!isJoseInstalled()) {
777
- throw new Error(
778
- 'OAuth is enabled but the "jose" dependency is not installed — run `toolfunnel install-oauth` ' +
779
- '(or click Install in the UI Auth panel), or disable auth in auth/auth.config.json.'
780
- );
781
- }
782
- const cfgErr = authConfig.configError(authCfg);
783
- if (cfgErr) throw new Error('OAuth is enabled but misconfigured: ' + cfgErr);
784
- } else if (!isLoopbackHost(host)) {
785
- // Binding off-loopback with NO auth would expose an UNAUTHENTICATED gateway to the network —
786
- // exactly the footgun the loopback default exists to prevent. Require auth before exposing it.
787
- throw new Error(
788
- `refusing to bind non-loopback host "${host}" without OAuth enabled — enable auth ` +
789
- '(and install jose) before exposing the gateway off localhost.'
790
- );
791
- }
792
-
793
- // Build + connect upstreams BEFORE binding so tools/list advertises the curated surface from
794
- // the very first request. connectAll() NEVER throws (failures land in failed[]); with the
795
- // default EMPTY expose.json it is an instant no-op.
796
- build = buildFactory();
797
- wireAggregatorNotify();
798
- if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
799
- try {
800
- const res = await build.aggregator.connectAll();
801
- recordConnect(res); // surface connect outcome (connected[]/failed[]) via health()
802
- if (res && Array.isArray(res.failed) && res.failed.length > 0) {
803
- log('connectAll: ' + res.failed.length + ' upstream(s) failed:',
804
- res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
805
- }
806
- } catch (err) {
807
- recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
808
- log('connectAll threw (ignored):', (err && err.message) || String(err));
809
- }
810
- } else {
811
- recordConnect(null); // no aggregator → no upstreams; report empty connect state
812
- }
813
-
814
- // onRequest is async (it may await the OAuth gate); wrap it so a rejection can NEVER escape into
815
- // the http server as an unhandled rejection — it degrades to a clean 500 instead.
816
- httpServer = http.createServer((req, res) => {
817
- onRequest(req, res).catch((err) => {
818
- log('onRequest rejected (should not):', (err && err.stack) || String(err));
819
- try { sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error')); } catch (_e) { /* committed */ }
820
- });
821
- });
822
-
823
- return new Promise((resolve, reject) => {
824
- // A bind failure (EADDRINUSE, EACCES) surfaces as an 'error' event before 'listening'. We
825
- // reject ONCE and detach the listener so a later runtime error can't double-settle.
826
- const onListenError = (err) => {
827
- httpServer.removeListener('listening', onListening);
828
- httpServer = null;
829
- // connectAll() ran BEFORE listen() and may have spawned upstream children; a bind failure
830
- // here would otherwise orphan them. Tear the aggregator down before rejecting. closeAll()
831
- // never throws; fire-and-forget (this is a sync 'error' handler) — it's cleanup only.
832
- const agg = build && build.aggregator;
833
- build = null; // nothing bound — drop the half-built state so a retry rebuilds cleanly
834
- if (agg && typeof agg.closeAll === 'function') {
835
- try { agg.closeAll(); } catch (_e) { /* never throw */ }
836
- }
837
- reject(err); // clear, typed error (err.code === 'EADDRINUSE' etc.) — caller decides
838
- };
839
- const onListening = () => {
840
- httpServer.removeListener('error', onListenError);
841
- const addr = httpServer.address();
842
- boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
843
- started = true;
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
- }
854
- // Swap the one-shot bind-error handler for a steady-state one so a later socket error
855
- // (a client RST, etc.) is logged, not thrown.
856
- httpServer.on('error', (e) => log('http server error:', (e && e.message) || String(e)));
857
- log('listening on', currentUrl(), '(pid', process.pid + ')');
858
- resolve({ port: boundPort, url: currentUrl() });
859
- };
860
- httpServer.once('error', onListenError);
861
- httpServer.once('listening', onListening);
862
- httpServer.listen(requestedPort, host);
863
- });
864
- }
865
-
866
- /**
867
- * stop — end every SSE client, close the HTTP server, then close the aggregator. Idempotent and
868
- * NEVER throws. Clears every keep-alive timer so the process can exit cleanly (no dangling
869
- * server, sockets, or timers).
870
- * @returns {Promise<void>}
871
- */
872
- async function stop() {
873
- if (!started && !httpServer) {
874
- // Never started (or already fully stopped) — still close any build the caller may have left.
875
- await closeAggregatorSafely();
876
- return;
877
- }
878
- stopping = true;
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
-
888
- // 1. Tear down SSE clients: clear their keep-alive timers and end the responses so their
889
- // sockets close (an open SSE socket would otherwise keep the server from closing).
890
- for (const res of Array.from(sseClients)) {
891
- try {
892
- if (res._keepAlive) clearInterval(res._keepAlive);
893
- } catch (_e) {
894
- /* ignore */
895
- }
896
- try {
897
- res.end();
898
- } catch (_e) {
899
- /* ignore */
900
- }
901
- }
902
- sseClients.clear();
903
-
904
- // 2. Close the HTTP server (stops accepting + waits for in-flight sockets to drain). We also
905
- // actively destroy any lingering sockets so close() can never hang the process exit.
906
- if (httpServer) {
907
- const srv = httpServer;
908
- httpServer = null;
909
- await new Promise((resolve) => {
910
- let settled = false;
911
- const done = () => {
912
- if (settled) return;
913
- settled = true;
914
- resolve();
915
- };
916
- try {
917
- srv.close(done);
918
- } catch (_e) {
919
- done();
920
- }
921
- // Destroy any keep-alive sockets still attached so close() resolves promptly.
922
- try {
923
- if (typeof srv.closeAllConnections === 'function') {
924
- srv.closeAllConnections();
925
- }
926
- } catch (_e) {
927
- /* closeAllConnections is Node ≥18.2; absent → close() still resolves on its own */
928
- }
929
- });
930
- }
931
-
932
- // 3. Close the aggregator (idempotent; never throws).
933
- await closeAggregatorSafely();
934
-
935
- boundPort = null;
936
- started = false;
937
- stopping = false;
938
- lastConnect = { connected: [], failed: [] }; // drop the connect snapshot — the host is down
939
- }
940
-
941
- /** Close the current build's aggregator if present. Idempotent + never throws. */
942
- async function closeAggregatorSafely() {
943
- const agg = build && build.aggregator;
944
- build = null;
945
- if (agg && typeof agg.closeAll === 'function') {
946
- try {
947
- await agg.closeAll();
948
- } catch (err) {
949
- log('aggregator.closeAll threw (ignored):', (err && err.message) || String(err));
950
- }
951
- }
952
- }
953
-
954
- /**
955
- * reload — apply an expose.json edit live: fully tear down the current aggregator, rebuild from
956
- * the (now-edited) config, reconnect every enabled upstream, then broadcast list_changed so a
957
- * connected CLI re-fetches tools/list. A clean full rebuild because expose edits are rare +
958
- * user-driven (the design doc). The HTTP server keeps running across the reload — only
959
- * the build is swapped. NEVER throws (connectAll never throws; closeAll never throws).
960
- * @returns {Promise<void>}
961
- */
962
- async function reload() {
963
- // 1. Close the old aggregator (this nulls `build`).
964
- await closeAggregatorSafely();
965
- // 2. Rebuild from the edited config + reconnect.
966
- build = buildFactory();
967
- wireAggregatorNotify();
968
- if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
969
- try {
970
- const res = await build.aggregator.connectAll();
971
- recordConnect(res); // refresh the health() connect snapshot for the new build
972
- if (res && Array.isArray(res.failed) && res.failed.length > 0) {
973
- log('reload connectAll: ' + res.failed.length + ' upstream(s) failed:',
974
- res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
975
- }
976
- } catch (err) {
977
- recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
978
- log('reload connectAll threw (ignored):', (err && err.message) || String(err));
979
- }
980
- } else {
981
- recordConnect(null); // no aggregator → no upstreams; report empty connect state
982
- }
983
- // 3. Tell connected clients the tool surface changed.
984
- broadcastToolsListChanged();
985
- }
986
-
987
- // ── Public handle ───────────────────────────────────────────────────────────────────────────
988
- return {
989
- start,
990
- stop,
991
- reload,
992
- broadcastToolsListChanged,
993
- health,
994
- get port() {
995
- return boundPort;
996
- },
997
- get url() {
998
- return currentUrl();
999
- },
1000
- get sseClientCount() {
1001
- return sseClients.size;
1002
- },
1003
- };
1004
- }
1005
-
1006
- module.exports = {
1007
- createHttpMcpServer,
1008
- // Exported for unit tests / reuse — the small pure seams.
1009
- isLoopbackHost,
1010
- PROTOCOL_VERSION,
1011
- SERVER_INFO,
1012
- };
1
+ 'use strict';
2
+
3
+ /**
4
+ * http-transport.js - the HTTP/SSE transport.
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. The point:
9
+ * 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; with auth off, non-loopback Host headers AND present
19
+ * non-loopback Origin headers are rejected defensively - see isLoopbackOrigin for the policy):
20
+ * - POST /mcp : one JSON-RPC request -> 200 application/json (a result) | 202 no body
21
+ * (a notification, handleMessage returned null) | a -32700 parse-error
22
+ * object at HTTP 200 for bad JSON. An oversized body is rejected with the
23
+ * SAME clean -32700 at HTTP 200: a Content-Length over the cap is refused
24
+ * up-front (before the body is read) with `Connection: close`; a chunked
25
+ * over-cap body is bounded by readBody's streaming cap. NEVER crashes.
26
+ * - GET /mcp : the server->client SSE stream (Accept: text/event-stream). This is the
27
+ * current Streamable-HTTP standard: a SINGLE /mcp endpoint serves POST
28
+ * (client->server messages) AND GET (the server->client SSE stream the host
29
+ * pushes notifications/tools/list_changed down). A CLI (type:"http")
30
+ * connects on this path.
31
+ * - GET /mcp/sse : a WORKING ALIAS for the same SSE stream (the older HTTP+SSE shape,
32
+ * deprecated but still supported). Identical behaviour to GET /mcp.
33
+ * - GET /health : 200 application/json with health() (a synchronous snapshot).
34
+ * - anything else : 404 JSON.
35
+ *
36
+ * SAFETY CONTRACT (mirrors the rest of src/mcp/):
37
+ * - start() rejects cleanly on EADDRINUSE (caller decides) - it does NOT crash the process.
38
+ * - stop() is idempotent and NEVER throws (ends SSE clients, closes the server, closes the
39
+ * aggregator). It also clears + unrefs the keep-alive timers so the process can exit.
40
+ * - A bad/oversized/garbage HTTP request becomes a clean response, never an unhandled throw.
41
+ * - All SSE keep-alive timers are unref()-ed so a live stream never blocks process exit.
42
+ *
43
+ * CommonJS only. Node BUILT-INS only (node:http) - no new npm dep, no MCP SDK.
44
+ */
45
+
46
+ const http = require('node:http');
47
+
48
+ const serverModule = require('./server');
49
+ const { handleMessage, handleBatch } = serverModule;
50
+ // The 2026-07-28 ("modern") era shapes - era detection, header validation, subscriptions/listen.
51
+ // This transport is DUAL-ERA: modern requests get strict header enforcement + the listen stream;
52
+ // legacy requests (initialize-handshake clients, the GET /mcp SSE stream) are byte-for-byte 0.5.0.
53
+ const modern = require('./modern');
54
+
55
+ // OPTIONAL OAuth 2.1 resource-server validation. These modules are zero-runtime-dep themselves;
56
+ // the actual jose import is lazy + inside resource-server.js, pulled in only when auth is enabled.
57
+ const authConfig = require('../auth/config');
58
+ const { createValidator, protectedResourceMetadata, isJoseInstalled } = require('../auth/resource-server');
59
+ const metrics = require('../core/metrics');
60
+ const logger = require('../core/logger');
61
+
62
+ const PROTOCOL_VERSION = serverModule.PROTOCOL_VERSION;
63
+ const SERVER_INFO = serverModule.SERVER_INFO;
64
+
65
+ // ── Tunables ──────────────────────────────────────────────────────────────────────────────────
66
+ const DEFAULT_HOST = '127.0.0.1';
67
+ const DEFAULT_PORT = 9998;
68
+ // SSE keep-alive comment interval. The browser/CLI EventSource treats a stalled stream as dead;
69
+ // a periodic ":" comment line keeps the pipe warm without emitting a real event. ~25s is well
70
+ // under typical 30-60s proxy idle timeouts. The timer is unref()-ed so it never blocks exit.
71
+ const SSE_KEEPALIVE_MS = 25000;
72
+ // Hard cap on a POSTed JSON-RPC body so a pathological/huge request can't exhaust memory. A
73
+ // JSON-RPC tools/call payload is tiny; 4 MiB is generous headroom. Over the cap -> -32700.
74
+ const MAX_BODY_BYTES = 4 * 1024 * 1024;
75
+ // Hard cap on JSON-RPC batch members (0.7.0). MAX_BODY_BYTES alone is no bound on WORK: a
76
+ // tools/call member is ~100 bytes, so 4 MiB buys tens of thousands of gated invocations - each
77
+ // spawning hook children and a tool child - executed serially while the socket is held. 64 is well
78
+ // past any real client's batching and far short of a denial of service.
79
+ const MAX_BATCH_MEMBERS = 64;
80
+ // RFC 9728 Protected Resource Metadata path - served UNAUTHENTICATED (it is the discovery document
81
+ // that tells a client which authorization server to use), and ONLY when auth is enabled.
82
+ const WELL_KNOWN_PRM = '/.well-known/oauth-protected-resource';
83
+
84
+ // Standard JSON-RPC error codes (subset used here - same values as server.js).
85
+ const JSONRPC = '2.0';
86
+ const ERR = Object.freeze({
87
+ PARSE: -32700,
88
+ INVALID_REQUEST: -32600,
89
+ INTERNAL: -32603,
90
+ });
91
+
92
+ /**
93
+ * Build a JSON-RPC error object with a null id (used for transport-level failures - a parse
94
+ * error has no determinable request id). Mirrors server.js makeError for the null-id case.
95
+ * @param {number} code
96
+ * @param {string} message
97
+ * @param {*} [data]
98
+ * @returns {{ jsonrpc: string, id: null, error: { code: number, message: string, data?: * } }}
99
+ */
100
+ function makeError(code, message, data) {
101
+ const error = { code, message };
102
+ if (data !== undefined) error.data = data;
103
+ return { jsonrpc: JSONRPC, id: null, error };
104
+ }
105
+
106
+ /** A JSON-RPC error carrying the REQUEST's id (modern header/validation failures know their id -
107
+ * a null id there would orphan the client's pending request). */
108
+ function makeErrorWithId(id, code, message, data) {
109
+ const error = { code, message };
110
+ if (data !== undefined) error.data = data;
111
+ return { jsonrpc: JSONRPC, id: id === undefined ? null : id, error };
112
+ }
113
+
114
+ /** Diagnostics -> logger (default: stderr). NEVER throws (a logging failure must not sink a call). */
115
+ function makeLog(logger) {
116
+ const sink =
117
+ logger && typeof logger.error === 'function'
118
+ ? (msg) => logger.error('[toolfunnel-http] ' + msg)
119
+ : (msg) => {
120
+ try {
121
+ process.stderr.write('[toolfunnel-http] ' + msg + '\n');
122
+ } catch (_e) {
123
+ /* never let logging throw */
124
+ }
125
+ };
126
+ return (...parts) => {
127
+ try {
128
+ sink(parts.join(' '));
129
+ } catch (_e) {
130
+ /* never throw out of the logger */
131
+ }
132
+ };
133
+ }
134
+
135
+ /** Default build factory - the real full build over the on-disk (EMPTY by default) expose.json. */
136
+ function defaultBuildFactory() {
137
+ return require('./server').buildProtocol();
138
+ }
139
+
140
+ /**
141
+ * Is the Host header loopback? We bind 127.0.0.1 only, but a forwarded/rebound request could still
142
+ * arrive with a non-loopback Host. Defence-in-depth: reject anything whose host part is not a
143
+ * recognised loopback name (DNS-rebinding guard). A MISSING Host header is allowed (HTTP/1.0 / a
144
+ * raw node:http client may omit it) - the bind address is already the hard boundary.
145
+ * @param {string|undefined} hostHeader the raw `Host` request header
146
+ * @returns {boolean}
147
+ */
148
+ function isLoopbackHost(hostHeader) {
149
+ if (hostHeader == null || hostHeader === '') return true; // no Host -> bind addr is the boundary
150
+ // Strip an optional :port. IPv6 hosts are bracketed: "[::1]:9998".
151
+ let host = String(hostHeader).trim();
152
+ if (host[0] === '[') {
153
+ const end = host.indexOf(']');
154
+ host = end === -1 ? host.slice(1) : host.slice(1, end);
155
+ } else {
156
+ // Strip an optional :port ONLY when unambiguous - exactly one colon means "host:port". A string
157
+ // with MULTIPLE colons is a bare IPv6 literal (e.g. "::1", "0:0:0:0:0:0:0:1"); compare it whole,
158
+ // else slicing at the first colon mangles it to "" and a valid IPv6 loopback bind is rejected.
159
+ const first = host.indexOf(':');
160
+ if (first !== -1 && first === host.lastIndexOf(':')) host = host.slice(0, first);
161
+ }
162
+ host = host.toLowerCase();
163
+ return host === '127.0.0.1' || host === 'localhost' || host === '::1' || host === '0:0:0:0:0:0:0:1';
164
+ }
165
+
166
+ /**
167
+ * Is the Origin header acceptable? OUR validation policy (an engineering call for a loopback
168
+ * gateway, not a spec quote), era-independent - it gates the HTTP request before any JSON-RPC
169
+ * body is read, so legacy and modern callers are treated identically:
170
+ *
171
+ * - ABSENT -> pass. Non-browser clients (stdio bridges, SDKs, curl) send no Origin; requiring
172
+ * one would break every legitimate client we serve.
173
+ * - PRESENT + loopback host part -> pass (a local web UI is a first-class client).
174
+ * - PRESENT + anything else -> reject. This is the DNS-rebinding / cross-origin-CSRF guard: a
175
+ * browser page on disallowed.example can script POSTs at 127.0.0.1 and the browser attaches its
176
+ * Origin - the one header the page cannot forge. Includes the opaque `Origin: null`
177
+ * (sandboxed iframes / file:// pages): present-and-not-loopback is the rejection rule.
178
+ *
179
+ * The caller applies this only when auth is DISABLED - with auth ON the bearer token is the
180
+ * boundary (same rationale as the Host guard) and remote browser clients legitimately present
181
+ * non-loopback Origins.
182
+ * @param {string|undefined} originHeader the raw `Origin` request header
183
+ * @returns {boolean}
184
+ */
185
+ function isLoopbackOrigin(originHeader) {
186
+ if (originHeader == null || originHeader === '') return true; // absent -> not a browser context
187
+ const raw = String(originHeader).trim();
188
+ // An Origin is scheme://host[:port] - no path. Anything without "://" (including the literal
189
+ // opaque token "null") is not a loopback web origin: reject.
190
+ const sep = raw.indexOf('://');
191
+ if (sep === -1) return false;
192
+ // An EMPTY host part ("http://") must reject HERE: isLoopbackHost passes '' because a request
193
+ // with no Host header at all is legitimate (HTTP/1.0), but this Origin IS present - a hostless
194
+ // origin is not a loopback web origin (origin.test.js 4b).
195
+ const hostPart = raw.slice(sep + 3);
196
+ if (hostPart === '') return false;
197
+ return isLoopbackHost(hostPart);
198
+ }
199
+
200
+ /**
201
+ * Read a request body up to MAX_BODY_BYTES. Resolves the buffered string, or rejects 'too large'.
202
+ * On over-cap it does NOT destroy the request - destroying here tears down the shared req/res
203
+ * socket before the caller's -32700 reply can flush (the client saw ECONNRESET instead of the clean
204
+ * parse error the declared-Content-Length path produces). Instead: stop BUFFERING (later chunks are
205
+ * discarded, so memory stays bounded) and reject; the caller replies via sendJsonAndClose, which
206
+ * flushes the error body first and destroys the request only in the res.end callback.
207
+ */
208
+ function readBody(req) {
209
+ return new Promise((resolve, reject) => {
210
+ let size = 0;
211
+ const chunks = [];
212
+ let done = false;
213
+ const finish = (fn, val) => {
214
+ if (done) return;
215
+ done = true;
216
+ fn(val);
217
+ };
218
+ req.on('data', (chunk) => {
219
+ if (done) return; // over-cap already signalled - discard, don't buffer
220
+ size += chunk.length;
221
+ if (size > MAX_BODY_BYTES) {
222
+ // Stop buffering; the caller flush-then-closes with the same -32700 as the declared-CL path.
223
+ finish(reject, new Error('request body too large'));
224
+ return;
225
+ }
226
+ chunks.push(chunk);
227
+ });
228
+ req.on('end', () => finish(resolve, Buffer.concat(chunks).toString('utf8')));
229
+ req.on('error', (err) => finish(reject, err));
230
+ });
231
+ }
232
+
233
+ /** Write a JSON response with a status code. Defensive: a serialise failure degrades to 500 text. */
234
+ function sendJson(res, status, obj) {
235
+ let body;
236
+ try {
237
+ body = JSON.stringify(obj);
238
+ } catch (_e) {
239
+ body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
240
+ }
241
+ res.writeHead(status, {
242
+ 'Content-Type': 'application/json',
243
+ 'Content-Length': Buffer.byteLength(body, 'utf8'),
244
+ 'Cache-Control': 'no-store',
245
+ });
246
+ res.end(body);
247
+ }
248
+
249
+ /**
250
+ * Send a JSON response and then CLOSE the connection, draining the unread request body WITHOUT
251
+ * buffering it. Used by both over-cap paths (the Content-Length pre-check and readBody's streaming
252
+ * cap): the client must receive the documented -32700 cleanly while the oversized payload never
253
+ * touches memory. The naive close - `req.destroy()`, even deferred to the res.end flush callback -
254
+ * is NOT safe: destroy() sends a TCP RST whenever request bytes are still unread/in-flight, and on
255
+ * RST Windows discards the peer's receive queue, eating the response we just flushed (the client
256
+ * saw ECONNRESET instead of the -32700; proven by test 5 in http.test.js). The robust sequence:
257
+ * 1. announce `Connection: close` so the client/keep-alive agent expects the socket to end here,
258
+ * 2. write the full response body, then in the res.end(...) flush callback:
259
+ * 3. resume() the request so any still-arriving body is DISCARDED (never buffered -> no DoS),
260
+ * 4. HALF-CLOSE the socket (socket.end()) - the FIN travels in-order AFTER the response bytes,
261
+ * so the client always reads the -32700 first, and
262
+ * 5. arm an unref'd hard-destroy timer as the backstop: a client that never stops sending and
263
+ * never closes gets its socket torn down after a grace window (bounded lifetime, no leak).
264
+ * Defensive: like sendJson, a serialise failure degrades to a 500-shaped error body; the whole
265
+ * function is wrapped so a write-after-headers race can never throw out of handleMcpPost.
266
+ * @param {import('node:http').IncomingMessage} req
267
+ * @param {import('node:http').ServerResponse} res
268
+ * @param {number} status
269
+ * @param {object} obj
270
+ */
271
+ function sendJsonAndClose(req, res, status, obj) {
272
+ let body;
273
+ try {
274
+ body = JSON.stringify(obj);
275
+ } catch (_e) {
276
+ body = JSON.stringify(makeError(ERR.INTERNAL, 'failed to serialise response'));
277
+ }
278
+ const destroyReq = () => {
279
+ try {
280
+ req.destroy();
281
+ } catch (_e) {
282
+ /* the socket may already be gone; nothing more to do */
283
+ }
284
+ };
285
+ try {
286
+ res.writeHead(status, {
287
+ 'Content-Type': 'application/json',
288
+ 'Content-Length': Buffer.byteLength(body, 'utf8'),
289
+ 'Cache-Control': 'no-store',
290
+ // Signal end-of-connection so the client agent does not try to reuse the socket for a
291
+ // pipelined follow-up (whose body would collide with the one we are about to discard).
292
+ Connection: 'close',
293
+ });
294
+ res.end(body, () => {
295
+ // The response has flushed to the kernel. Close down WITHOUT an RST (see the doc above):
296
+ try {
297
+ req.resume(); // discard (never buffer) whatever request body is still arriving
298
+ if (req.socket && !req.socket.destroyed) req.socket.end(); // FIN, in-order after the body
299
+ } catch (_e) {
300
+ return destroyReq(); // a torn socket at this point -> plain destroy is all that is left
301
+ }
302
+ // Backstop: bound the socket's lifetime if the client neither finishes nor closes. The
303
+ // timer is unref'd so it never keeps the process alive; a normal close clears it.
304
+ const t = setTimeout(destroyReq, 10000);
305
+ if (t && typeof t.unref === 'function') t.unref();
306
+ if (req.socket) req.socket.once('close', () => clearTimeout(t));
307
+ });
308
+ } catch (_e) {
309
+ // writeHead/end can throw if headers were already sent by a racing path - fall back to a
310
+ // straight destroy so the socket can never be left half-open.
311
+ destroyReq();
312
+ }
313
+ }
314
+
315
+ /**
316
+ * createHttpMcpServer - construct (but do NOT start) the HTTP/SSE MCP host.
317
+ *
318
+ * @param {object} [opts]
319
+ * @param {Function} [opts.buildFactory] () => { protocol, aggregator, engine, ctx }. Injectable so
320
+ * tests pass a sandbox build. Default = buildProtocol().
321
+ * @param {string} [opts.host] bind address (default 127.0.0.1 - loopback only).
322
+ * @param {number} [opts.port] bind port (default 9998; pass 0 for an OS-assigned port).
323
+ * @param {object} [opts.logger] an object with .error(msg); default writes to stderr.
324
+ * @returns {{
325
+ * start: () => Promise<{ port:number, url:string }>,
326
+ * stop: () => Promise<void>,
327
+ * reload: () => Promise<void>,
328
+ * broadcastToolsListChanged: () => number,
329
+ * health: () => object,
330
+ * get port(): (number|null),
331
+ * get url(): (string|null),
332
+ * get sseClientCount(): number,
333
+ * }}
334
+ */
335
+ function createHttpMcpServer(opts = {}) {
336
+ const o = opts || {};
337
+ const buildFactory = typeof o.buildFactory === 'function' ? o.buildFactory : defaultBuildFactory;
338
+ const host = typeof o.host === 'string' && o.host.length > 0 ? o.host : DEFAULT_HOST;
339
+ // A port of 0 is VALID (OS-assigned) so we must distinguish "not provided" from 0.
340
+ const requestedPort = Number.isInteger(o.port) && o.port >= 0 ? o.port : DEFAULT_PORT;
341
+ const log = makeLog(o.logger);
342
+
343
+ // ── Mutable runtime state ─────────────────────────────────────────────────────────────────
344
+ /** @type {object|null} the current full build { protocol, aggregator, engine, ctx }. */
345
+ let build = null;
346
+ /** @type {import('node:http').Server|null} */
347
+ let httpServer = null;
348
+ /** @type {number|null} the actual bound port (resolved after listen - matters for port 0). */
349
+ let boundPort = null;
350
+ /** @type {Set<import('node:http').ServerResponse>} connected SSE client responses. */
351
+ const sseClients = new Set();
352
+ /**
353
+ * Modern subscriptions/listen streams: { res, subId, agreed } per open listen POST. Kept
354
+ * SEPARATE from the legacy sseClients set - the two eras have different wire shapes (tagged +
355
+ * filtered vs raw) and different close semantics (a final result vs a plain end).
356
+ * @type {Set<{ res: import('node:http').ServerResponse, subId: *, agreed: object }>}
357
+ */
358
+ const modernListeners = new Set();
359
+ // Cap on concurrent modern listen streams - loopback bounds this in practice, but a looping
360
+ // client that re-listens without closing could still exhaust the process.
361
+ // The stdio transport needs no cap: one client per pipe, and re-listens replace-on-same-id.
362
+ const MODERN_LISTENERS_MAX = 64;
363
+ /** @type {boolean} latch so stop() is idempotent and start() can't double-bind. */
364
+ let started = false;
365
+ let stopping = false;
366
+ // 4.4: modern tools/call sockets closed before the response was written (per host lifetime).
367
+ let disconnectCancels = 0;
368
+ /** @type {(() => void)|null} teardown for the config hot-reload watchers (armed in start()). */
369
+ let configWatchersStop = null;
370
+ /**
371
+ * The result of the LAST connectAll() (from start() or reload()), surfaced by health() so a
372
+ * silent upstream connect-failure becomes VISIBLE (otherwise it only reaches stderr via log()).
373
+ * Shape mirrors Aggregator.connectAll's return: { connected: string[], failed: [{id,error}] }.
374
+ * Defensive: only ever holds the well-formed arrays - a connectAll throw (which shouldn't happen)
375
+ * or a malformed result leaves it EMPTY rather than half-populated. Reset to empty by stop().
376
+ * @type {{ connected: string[], failed: Array<{id:string, error:string}> }}
377
+ */
378
+ let lastConnect = { connected: [], failed: [] };
379
+
380
+ /**
381
+ * Record a connectAll() result into lastConnect, defensively. A well-formed result has BOTH a
382
+ * `connected` array and a `failed` array; anything else (a throw upstream, a malformed return)
383
+ * leaves lastConnect EMPTY so health() never reports stale/partial connect state.
384
+ * @param {*} res the value returned by Aggregator.connectAll()
385
+ */
386
+ function recordConnect(res) {
387
+ if (res && Array.isArray(res.connected) && Array.isArray(res.failed)) {
388
+ lastConnect = { connected: res.connected.slice(), failed: res.failed.slice() };
389
+ } else {
390
+ lastConnect = { connected: [], failed: [] };
391
+ }
392
+ }
393
+
394
+ /** The advertised base URL once bound (null before start / after stop). */
395
+ function currentUrl() {
396
+ return boundPort == null ? null : `http://${host}:${boundPort}`;
397
+ }
398
+
399
+ // ── SSE plumbing ──────────────────────────────────────────────────────────────────────────
400
+
401
+ /**
402
+ * Open an SSE stream on a GET /mcp request (the Streamable-HTTP standard server->client stream) or
403
+ * its GET /mcp/sse alias. Registers the response in the client set, writes the SSE preamble + a
404
+ * ": connected" comment, and starts an unref()-ed keep-alive timer. On close (client disconnect,
405
+ * server stop) the client is deregistered and its timer cleared.
406
+ */
407
+ function openSse(req, res) {
408
+ res.writeHead(200, {
409
+ 'Content-Type': 'text/event-stream',
410
+ 'Cache-Control': 'no-store',
411
+ Connection: 'keep-alive',
412
+ // Disable proxy buffering so events flush immediately (harmless when no proxy is present).
413
+ 'X-Accel-Buffering': 'no',
414
+ });
415
+ // Initial comment so the client knows the stream is live (comments start with ':').
416
+ res.write(': connected\n\n');
417
+
418
+ // Periodic keep-alive comment. unref() so a dangling stream NEVER blocks process exit, and
419
+ // clear it on close/stop so we don't write to a dead socket.
420
+ const keepAlive = setInterval(() => {
421
+ try {
422
+ res.write(': ping\n\n');
423
+ } catch (_e) {
424
+ /* a write to a half-closed socket is benign; close handler will clean up */
425
+ }
426
+ }, SSE_KEEPALIVE_MS);
427
+ if (typeof keepAlive.unref === 'function') keepAlive.unref();
428
+
429
+ // Track the timer ON the response object so stop()/close can clear it.
430
+ res._keepAlive = keepAlive;
431
+ sseClients.add(res);
432
+
433
+ const cleanup = () => {
434
+ clearInterval(keepAlive);
435
+ sseClients.delete(res);
436
+ };
437
+ req.on('close', cleanup);
438
+ req.on('error', cleanup);
439
+ res.on('close', cleanup);
440
+ res.on('error', cleanup);
441
+ }
442
+
443
+ /**
444
+ * Push one JSON-RPC notification to every connected SSE client as a `data:` event. Each client
445
+ * that errors mid-write is dropped (and cleaned up) so one dead socket can't sink the broadcast.
446
+ * @param {object} notification a NO-ID JSON-RPC notification object
447
+ * @returns {number} how many clients were written to
448
+ */
449
+ function pushToSse(notification) {
450
+ let line;
451
+ try {
452
+ line = JSON.stringify(notification);
453
+ } catch (_e) {
454
+ return 0; // an unserialisable notification is a no-op rather than a throw
455
+ }
456
+ let count = 0;
457
+ for (const res of Array.from(sseClients)) {
458
+ try {
459
+ res.write('data: ' + line + '\n\n');
460
+ count += 1;
461
+ } catch (_e) {
462
+ // Drop a dead client so it never blocks future broadcasts or process exit.
463
+ try {
464
+ if (res._keepAlive) clearInterval(res._keepAlive);
465
+ } catch (_e2) {
466
+ /* ignore */
467
+ }
468
+ sseClients.delete(res);
469
+ }
470
+ }
471
+ return count;
472
+ }
473
+
474
+ /**
475
+ * Open a modern subscriptions/listen stream: the POST's response becomes a long-lived SSE
476
+ * stream. Per spec the FIRST message is the acknowledgment notification (carrying the agreed
477
+ * filter subset + the subscription id = the listen request's JSON-RPC id); every later
478
+ * notification is tagged with that id; a graceful server-side close sends a final RESULT to
479
+ * the listen id then ends the stream. Keep-alive comments mirror the legacy openSse.
480
+ */
481
+ function openListenStream(req, res, msg) {
482
+ const subId = msg.id;
483
+ // A listen-FIRST modern client bypasses handleMessage - arm the wrap-chatter filter here too.
484
+ if (typeof serverModule.armWrapChatter === 'function') serverModule.armWrapChatter(build);
485
+ const { agreed } = modern.normaliseListenFilter(msg.params);
486
+
487
+ // At capacity -> refuse the NEW stream with a clear error BEFORE any SSE bytes are written;
488
+ // existing streams are never evicted (they may carry subscriptions the client relies on).
489
+ //
490
+ // ORDER MATTERS (0.7.0): this check used to sit AFTER subscribeResources below, and the 503
491
+ // return path registers no cleanup - so a refused listen took refcounts it never released.
492
+ // Each leaked ref pinned its URI above zero (no resources/unsubscribe ever sent) and kept it
493
+ // in the replay set, re-subscribed on every future upstream reconnect, unboundedly.
494
+ if (modernListeners.size >= MODERN_LISTENERS_MAX) {
495
+ return sendJson(res, 503, makeErrorWithId(subId, ERR.INTERNAL,
496
+ 'listen stream limit reached (' + MODERN_LISTENERS_MAX + ') - close an existing subscriptions/listen stream first'));
497
+ }
498
+
499
+ // resourceSubscriptions is only agreed when some upstream can DELIVER per-URI updates (modern
500
+ // era emits spontaneously; a subscribe-capable legacy upstream gets the subscribes forwarded).
501
+ // An ack must never promise a dead channel.
502
+ if (agreed.resourceSubscriptions) {
503
+ const agg = build && build.aggregator;
504
+ if (agg && typeof agg.canHonourResourceSubscriptions === 'function' && agg.canHonourResourceSubscriptions()) {
505
+ agg.subscribeResources(agreed.resourceSubscriptions);
506
+ } else {
507
+ delete agreed.resourceSubscriptions;
508
+ }
509
+ }
510
+
511
+ res.writeHead(200, {
512
+ 'Content-Type': 'text/event-stream',
513
+ 'Cache-Control': 'no-store',
514
+ Connection: 'keep-alive',
515
+ 'X-Accel-Buffering': 'no',
516
+ });
517
+
518
+ // Spec: the acknowledgment MUST be the first message on the stream.
519
+ try {
520
+ res.write('data: ' + JSON.stringify(modern.listenAck(subId, agreed)) + '\n\n');
521
+ } catch (_e) {
522
+ /* a client that died mid-open is cleaned up by the close handlers below */
523
+ }
524
+
525
+ const keepAlive = setInterval(() => {
526
+ try {
527
+ res.write(': ping\n\n');
528
+ } catch (_e) {
529
+ /* benign - close handler cleans up */
530
+ }
531
+ }, SSE_KEEPALIVE_MS);
532
+ if (typeof keepAlive.unref === 'function') keepAlive.unref();
533
+ res._keepAlive = keepAlive;
534
+
535
+ const entry = { res, subId, agreed };
536
+ modernListeners.add(entry);
537
+
538
+ const cleanup = () => {
539
+ clearInterval(keepAlive);
540
+ modernListeners.delete(entry);
541
+ // 5.4: the stream WAS the subscription - closing it releases the listen-owned resource
542
+ // URIs (this cleanup previously released nothing: leak site #2). _released guards the
543
+ // up-to-four close/error events that all route here.
544
+ if (entry._released !== true && entry.agreed && Array.isArray(entry.agreed.resourceSubscriptions)) {
545
+ entry._released = true;
546
+ const agg = build && build.aggregator;
547
+ if (agg && typeof agg.releaseResources === 'function') agg.releaseResources(entry.agreed.resourceSubscriptions);
548
+ }
549
+ };
550
+ req.on('close', cleanup);
551
+ req.on('error', cleanup);
552
+ res.on('close', cleanup);
553
+ res.on('error', cleanup);
554
+ }
555
+
556
+ /**
557
+ * Push one notification to every modern listener whose agreed filter includes it (only
558
+ * toolsListChanged exists on this tools-only server), TAGGED with each stream's subscription
559
+ * id. A dead client is dropped, mirroring pushToSse. Returns how many were written to.
560
+ */
561
+ function pushToListeners(notification) {
562
+ let count = 0;
563
+ for (const entry of Array.from(modernListeners)) {
564
+ // Deliver only to streams whose AGREED filter includes this notification's channel
565
+ // (tools/prompts/resources list_changed, or resources/updated for a subscribed uri).
566
+ if (!modern.notificationMatchesFilter(notification, entry.agreed)) continue;
567
+ let line;
568
+ try {
569
+ line = JSON.stringify(modern.tagNotification(notification, entry.subId));
570
+ } catch (_e) {
571
+ continue;
572
+ }
573
+ try {
574
+ entry.res.write('data: ' + line + '\n\n');
575
+ count += 1;
576
+ } catch (_e) {
577
+ try {
578
+ if (entry.res._keepAlive) clearInterval(entry.res._keepAlive);
579
+ } catch (_e2) {
580
+ /* ignore */
581
+ }
582
+ modernListeners.delete(entry);
583
+ }
584
+ }
585
+ return count;
586
+ }
587
+
588
+ /**
589
+ * broadcastToolsListChanged - push notifications/tools/list_changed (no id) to every legacy SSE
590
+ * client AND (tagged + filtered) to every modern listen stream. A client honouring listChanged
591
+ * re-fetches tools/list (the design doc). Returns the number of clients notified.
592
+ * @returns {number}
593
+ */
594
+ function broadcastToolsListChanged() {
595
+ const note = { jsonrpc: JSONRPC, method: 'notifications/tools/list_changed' };
596
+ return pushToSse(note) + pushToListeners(note);
597
+ }
598
+
599
+ /**
600
+ * Wire the CURRENT build's aggregator "tools changed" signal (a background reconnect finally
601
+ * winning an upstream) to the SSE broadcast, so an HTTP client honouring listChanged re-fetches.
602
+ * Re-called after every build (re)creation because a fresh aggregator has no listener - the stdio
603
+ * path does the equivalent in main(). NEVER throws.
604
+ */
605
+ function wireAggregatorNotify() {
606
+ if (build && build.aggregator) {
607
+ // Arm the cross-upstream wrap-chatter filter from wiring time - bridged notifications can arrive
608
+ // before the first client message computes the wrap state.
609
+ if (typeof serverModule.armWrapChatter === 'function') serverModule.armWrapChatter(build);
610
+ build.aggregator.onToolsChanged = () => broadcastToolsListChanged();
611
+ // Bridge an upstream's server-initiated change-notifications (resources/prompts/tools
612
+ // list_changed, resources/updated) to legacy SSE clients (raw) AND modern listen streams
613
+ // (tagged + filtered per subscription). NEVER throws.
614
+ build.aggregator.onUpstreamNotification = (uid, n) => {
615
+ // Under a wrap, ONLY the wrapped upstream's notifications reach clients - another attached
616
+ // upstream's events are a transparency tell + cross-server leak.
617
+ const wrapOnly = build.aggregator && build.aggregator.wrapChatterUpstream;
618
+ if (wrapOnly && uid !== wrapOnly) return;
619
+ // Request-scoped chatter (ONE client's in-flight progress/log lines) must not BROADCAST on
620
+ // the multi-client HTTP transport - client B would receive client A's progress tokens and
621
+ // log output. stdio (one client per process) keeps the relay;
622
+ // chat-scoped change notifications (list_changed, resources/updated) still broadcast here.
623
+ const m = n && n.method;
624
+ if (m === 'notifications/progress' || m === 'notifications/message') return;
625
+ try { pushToSse(n); } catch (_e) { /* legacy SSE best-effort */ }
626
+ try { pushToListeners(n); } catch (_e) { /* modern listeners best-effort */ }
627
+ };
628
+ // Elicitation bridge: elicitations from the wrapped upstream bind to the one in-flight wrapped call
629
+ // (or decline). The handler reads `build` live via this closure, same as the bridge above.
630
+ if (typeof serverModule.handleUpstreamServerRequest === 'function') {
631
+ build.aggregator.onUpstreamServerRequest = (uid, m, c) =>
632
+ serverModule.handleUpstreamServerRequest(build, uid, m, c);
633
+ }
634
+ }
635
+ }
636
+
637
+ // ── OAuth 2.1 resource-server gate (OPTIONAL; default OFF) ──────────────────────────────────
638
+ // The validator is memoised and rebuilt ONLY when the relevant auth config changes - so a live
639
+ // enable/disable/edit takes effect on the next request WITHOUT discarding jose's JWKS cache in
640
+ // steady state. The config is read FRESH per request (mirrors the logger + tool-state overlays),
641
+ // so the on/off decision is always live; the cache key just avoids rebuilding the remote-JWKS
642
+ // resolver when nothing relevant changed.
643
+ let _validator = null;
644
+ let _validatorSig = '';
645
+
646
+ function validatorFor(authCfg) {
647
+ const resourceMetadataUrl = boundPort == null ? '' : currentUrl() + WELL_KNOWN_PRM;
648
+ const sig = JSON.stringify([
649
+ authCfg.issuer, authCfg.audience, authCfg.jwksUri, authCfg.algorithms,
650
+ authCfg.requiredScopes, authCfg.clockToleranceSec, resourceMetadataUrl,
651
+ ]);
652
+ if (_validator && sig === _validatorSig) return _validator;
653
+ _validator = createValidator(authCfg, { resourceMetadataUrl, log });
654
+ _validatorSig = sig;
655
+ return _validator;
656
+ }
657
+
658
+ /**
659
+ * Enforce the OAuth gate for a protected route. Returns true if the request may proceed, false if
660
+ * it has ALREADY been answered (401/403/500). Fails CLOSED - any error rejects rather than allows.
661
+ */
662
+ async function passesAuth(req, res, authCfg) {
663
+ let verdict;
664
+ try {
665
+ const validator = validatorFor(authCfg);
666
+ verdict = await validator.validate(req.headers && req.headers.authorization);
667
+ } catch (err) {
668
+ // validate() is contracted never to throw; guard anyway and fail CLOSED (never allow on error).
669
+ log('auth validate threw (failing closed):', (err && err.message) || String(err));
670
+ verdict = { ok: false, status: 401, error: 'invalid_token', errorDescription: 'authentication error', wwwAuthenticate: 'Bearer' };
671
+ }
672
+ if (verdict && verdict.ok === true) return true;
673
+ // Audit the DENIAL (self-gating; no-op unless logging is on). A rejected access attempt is the
674
+ // security-relevant event - without this it was invisible (rejected at the gate, before the
675
+ // protocol/logging layer), so a run of failed auth attempts left no trace in the activity log.
676
+ try {
677
+ const rawUrl = req.url || '/';
678
+ const q = rawUrl.indexOf('?');
679
+ logger.log({
680
+ type: 'auth',
681
+ event: 'deny',
682
+ error: verdict && verdict.error,
683
+ status: verdict && verdict.status,
684
+ path: q === -1 ? rawUrl : rawUrl.slice(0, q),
685
+ });
686
+ } catch (_e) { /* never let audit logging break the gate */ }
687
+ sendAuthChallenge(res, verdict);
688
+ return false;
689
+ }
690
+
691
+ /** Send an OAuth-style challenge (401/403/500) with the WWW-Authenticate header. NEVER throws. */
692
+ function sendAuthChallenge(res, verdict) {
693
+ const status = (verdict && Number.isInteger(verdict.status)) ? verdict.status : 401;
694
+ const payload = { error: (verdict && verdict.error) || 'invalid_token' };
695
+ if (verdict && verdict.errorDescription) payload.error_description = verdict.errorDescription;
696
+ let body;
697
+ try { body = JSON.stringify(payload); } catch (_e) { body = '{"error":"invalid_token"}'; }
698
+ const headers = {
699
+ 'Content-Type': 'application/json',
700
+ 'Content-Length': Buffer.byteLength(body, 'utf8'),
701
+ 'Cache-Control': 'no-store',
702
+ };
703
+ if (verdict && verdict.wwwAuthenticate) headers['WWW-Authenticate'] = verdict.wwwAuthenticate;
704
+ try {
705
+ res.writeHead(status, headers);
706
+ res.end(body);
707
+ } catch (_e) {
708
+ /* response may already be committed; nothing more to do */
709
+ }
710
+ }
711
+
712
+ // ── HTTP request handling ─────────────────────────────────────────────────────────────────
713
+
714
+ /** POST /mcp - one JSON-RPC request -> handleMessage(build, msg) -> HTTP response. NEVER throws. */
715
+ async function handleMcpPost(req, res) {
716
+ // ── Content-Length pre-check ──────────────────────────────────────────────────────────────
717
+ // A client that ANNOUNCES an oversized body (Content-Length > MAX_BODY_BYTES) is rejected
718
+ // BEFORE a single byte of that body is read, so a multi-MiB POST never touches memory. We reply
719
+ // with the SAME clean -32700 parse-error contract as bad JSON / a streamed over-cap body, then
720
+ // close the connection (draining the unread body without buffering - see sendJsonAndClose).
721
+ // Chunked / Content-Length-less requests skip this branch and fall through to readBody's
722
+ // streaming cap (which still bounds memory and keeps the server alive).
723
+ const clHeader = req.headers && req.headers['content-length'];
724
+ if (clHeader != null) {
725
+ const declared = Number(clHeader);
726
+ if (Number.isFinite(declared) && declared > MAX_BODY_BYTES) {
727
+ log('POST /mcp rejected by Content-Length pre-check:', declared, '>', MAX_BODY_BYTES);
728
+ return sendJsonAndClose(
729
+ req,
730
+ res,
731
+ 200,
732
+ makeError(ERR.PARSE, 'Parse error: request body too large')
733
+ );
734
+ }
735
+ }
736
+
737
+ let raw;
738
+ try {
739
+ raw = await readBody(req);
740
+ } catch (err) {
741
+ // Oversized / aborted body -> the SAME flush-then-close -32700 as the Content-Length pre-check
742
+ // (readBody no longer destroys the request, so the reply can actually reach the client; a
743
+ // genuinely-dead socket is fine too - sendJsonAndClose's writes are defensively wrapped).
744
+ log('POST /mcp body read failed:', (err && err.message) || String(err));
745
+ return sendJsonAndClose(req, res, 200, makeError(ERR.PARSE, 'Parse error: ' + ((err && err.message) || 'bad body')));
746
+ }
747
+
748
+ let msg;
749
+ try {
750
+ msg = JSON.parse(raw);
751
+ } catch (_e) {
752
+ // Bad JSON -> JSON-RPC parse error at HTTP 200 (the body is the error object).
753
+ return sendJson(res, 200, makeError(ERR.PARSE, 'Parse error: invalid JSON body'));
754
+ }
755
+
756
+ // ── JSON-RPC batch ─────────────────────────────────────────────────────────────
757
+ // HTTP is sessionless, so the header is the only era evidence. ABSENT passes - 2025-03-26
758
+ // (the one revision that defines batching) PREDATES the MCP-Protocol-Version header, which
759
+ // 2025-06-18 introduced; an explicit 2025-03-26 passes; any other value lets the array fall
760
+ // through to handleMessage's frozen -32600. Mechanics per the 2025-03-26 Transports spec:
761
+ // solely-notifications -> 202 no body; any requests -> one application/json array; an empty
762
+ // batch -> 400 + the id-less -32600 ("server cannot accept the input").
763
+ if (Array.isArray(msg)) {
764
+ const hv = req.headers && req.headers['mcp-protocol-version'];
765
+ if (hv === undefined || hv === '2025-03-26') {
766
+ // A MODERN-shaped member cannot legally travel in a batch: batching exists ONLY in
767
+ // 2025-03-26, which predates the modern era entirely. Refusing here closes an era-mixing
768
+ // hole (0.7.0): this branch runs BEFORE bodyIsModern below, so a modern body wrapped in a
769
+ // one-element array used to skip validateModernHeaders, the Mcp-Param cross-check and the
770
+ // disconnect-cancel accounting, and still be SERVED with full modern semantics because
771
+ // handleMessage re-detects the era from the body.
772
+ if (msg.some((m) => modern.isModernRequest(m))) {
773
+ return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
774
+ 'modern-era messages cannot be batched - JSON-RPC batching is 2025-03-26 only; send each modern request as its own POST'));
775
+ }
776
+ // A batch is executed member-by-member with a gated tool call possible in every slot, so an
777
+ // uncapped array is one POST buying unbounded sequential execution (0.7.0).
778
+ if (msg.length > MAX_BATCH_MEMBERS) {
779
+ return sendJson(res, 400, makeError(ERR.INVALID_REQUEST,
780
+ `batch too large (${msg.length} members, max ${MAX_BATCH_MEMBERS}) - split it`));
781
+ }
782
+ let batched;
783
+ try {
784
+ batched = await handleBatch(build, msg);
785
+ } catch (err) {
786
+ log('handleBatch threw (should not):', (err && err.stack) || String(err));
787
+ return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error'));
788
+ }
789
+ if (batched == null) {
790
+ res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
791
+ return res.end();
792
+ }
793
+ if (!Array.isArray(batched)) return sendJson(res, 400, batched); // empty batch
794
+ return sendJson(res, 200, batched);
795
+ }
796
+ }
797
+
798
+ // ── Modern era (2026-07-28): strict header enforcement + the listen stream ────────────────
799
+ // Era detection is by BODY (per-request _meta - the spec's dual-era rule); initialize always
800
+ // selects legacy. Legacy requests skip ALL of this and flow byte-for-byte as 0.5.0 (their
801
+ // optional MCP-Protocol-Version header stays leniently ignored - pre-modern values carry no
802
+ // enforcement contract for a dual-era server).
803
+ const bodyIsModern = msg && msg.method !== 'initialize' && modern.isModernRequest(msg);
804
+ const headerVersion = req.headers && req.headers['mcp-protocol-version'];
805
+ if (bodyIsModern) {
806
+ // Headers are REQUIRED for compliance on modern requests: MCP-Protocol-Version, Mcp-Method,
807
+ // and (tools/call | resources/read | prompts/get) Mcp-Name must all mirror the body.
808
+ // A mismatch is 400 + HeaderMismatch (-32020) per spec.
809
+ const hErr = modern.validateModernHeaders(req.headers, msg);
810
+ if (hErr) {
811
+ logger.log({ type: 'client', event: 'modern-header-reject', method: msg.method });
812
+ return sendJson(res, hErr.httpStatus, makeErrorWithId(msg.id, hErr.code, hErr.message));
813
+ }
814
+ const vErr = modern.validateModernRequest(msg);
815
+ if (vErr) {
816
+ return sendJson(res, vErr.httpStatus, makeErrorWithId(msg.id, vErr.code, vErr.message, vErr.data));
817
+ }
818
+ // 4.3: x-mcp-header parameter mirroring - the 2026-07-28 Streamable HTTP spec's Server
819
+ // Validation MUST: a PRESENT Mcp-Param-{name} header must equal the body argument at the
820
+ // advertised annotation's path (split-source-of-truth guard: a proxy routing on the
821
+ // header while we execute on the body). Values ride the 4.2 sentinel. Absent headers
822
+ // pass (mirroring is the CLIENT's obligation); unknown Mcp-Param names are ignored.
823
+ // The numeric 42.0==42 note is a SHOULD - scoped out; primitives compare canonically.
824
+ if (msg.method === 'tools/call' && msg.params && typeof msg.params.name === 'string') {
825
+ const pErr = validateMcpParamHeaders(build, req.headers, msg.params);
826
+ if (pErr) return sendJson(res, 400, makeErrorWithId(msg.id, modern.ERR_MODERN.HEADER_MISMATCH, pErr));
827
+ }
828
+ // subscriptions/listen is transport-owned: the POST's response IS the SSE stream.
829
+ if (msg.method === 'subscriptions/listen') {
830
+ if (msg.id === undefined || msg.id === null) {
831
+ // A listen with no id can never be acknowledged or closed - reject it clearly.
832
+ return sendJson(res, 400, makeError(ERR.INVALID_REQUEST, 'subscriptions/listen requires an id'));
833
+ }
834
+ return openListenStream(req, res, msg);
835
+ }
836
+ } else if (headerVersion === modern.MODERN_PROTOCOL_VERSION) {
837
+ // The header announces the modern era but the request is not a modern one. Two cases, so the
838
+ // message is TRUTHFUL rather than always blaming a missing _meta:
839
+ // - `initialize` carrying a modern header: initialize ALWAYS selects the legacy era, so the
840
+ // modern header is contradictory - tell them to drop it on the fallback handshake.
841
+ // - any other body with no modern _meta: a genuine header/_meta mismatch.
842
+ const isInit = msg && msg.method === 'initialize';
843
+ const message = isInit
844
+ ? 'Header mismatch: `initialize` always selects the legacy era - drop the MCP-Protocol-Version: ' +
845
+ modern.MODERN_PROTOCOL_VERSION + ' header on the legacy handshake (or send a modern request instead)'
846
+ : 'Header mismatch: MCP-Protocol-Version announces ' + modern.MODERN_PROTOCOL_VERSION +
847
+ ' but the body carries no matching _meta protocolVersion';
848
+ return sendJson(res, 400, makeErrorWithId(msg && msg.id, modern.ERR_MODERN.HEADER_MISMATCH, message));
849
+ } else if (headerVersion !== undefined && !modern.supportedVersions().includes(headerVersion)) {
850
+ // The 2025-06-18 MUST: "a request with an invalid or unsupported
851
+ // MCP-Protocol-Version" gets 400. Only PRESENT-and-unknown values land here: absent stays
852
+ // the frozen lenient pass (the spec's own default-assumption case + every pre-2025-06-18
853
+ // client), supported legacy values pass, and the modern value is owned by the
854
+ // HeaderMismatch arm above (it demands matching modern _meta). Composition with 2.3:
855
+ // an array with a SUPPORTED non-batching header keeps the frozen -32600 (the batch gate
856
+ // declined, this arm passes); an array with an UNKNOWN header lands here and 400s - it is
857
+ // still a request with an invalid header, exactly what the MUST covers.
858
+ // -32022 (UnsupportedProtocolVersion), NOT the generic -32600. Same condition, same code,
859
+ // wherever it is detected: the _meta path already answers -32022 with supported/requested,
860
+ // and a client should not have to know WHERE the server noticed to parse the reply. The
861
+ // data payload is the useful part - it names the versions to retry with, so a client can
862
+ // renegotiate without a human reading the message string. Unlike the _meta path (whose one
863
+ // lawful value is the modern version) the HEADER may carry any era we speak, so the full
864
+ // supported set is advertised here.
865
+ return sendJson(res, 400, makeErrorWithId(msg && msg.id, modern.ERR_MODERN.UNSUPPORTED_PROTOCOL_VERSION,
866
+ `Unsupported protocol version: MCP-Protocol-Version header '${headerVersion}' (supported: ` +
867
+ modern.supportedVersions().join(', ') + ')',
868
+ { supported: modern.supportedVersions(), requested: headerVersion }));
869
+ }
870
+
871
+ // 4.4 - disconnect-as-cancellation (DECIDED; the trade STATED): the modern spec's
872
+ // stream-close-MUST is written for per-request SSE streams; we answer plain JSON, so the
873
+ // only close signal is the bare POST socket - ambiguous (proxy timeout, keep-alive reset,
874
+ // real client abort). Decided: treat a premature close as cancellation anyway, gated on
875
+ // modern tools/call. ACCEPTED CONSEQUENCE, documented: a proxy-killed socket spuriously
876
+ // cancels the RESPONSE of a live gated call (the upstream work stops only where a cancel
877
+ // path exists - sessionless HTTP has no id translation to relay one). Riders honoured:
878
+ // the cancellation is audit-logged and counted on /health for a client that comes back.
879
+ let modernDisconnected = false;
880
+ if (bodyIsModern && msg.method === 'tools/call') {
881
+ res.once('close', () => {
882
+ if (res.writableEnded) return; // normal completion - not a cancellation
883
+ modernDisconnected = true;
884
+ disconnectCancels += 1;
885
+ logger.log({
886
+ type: 'client', event: 'disconnect-cancel', method: msg.method,
887
+ tool: msg.params && msg.params.name, id: msg.id,
888
+ });
889
+ });
890
+ }
891
+
892
+ let response;
893
+ try {
894
+ response = await handleMessage(build, msg);
895
+ } catch (err) {
896
+ // handleMessage is contracted never to throw, but the transport must survive even if a
897
+ // wiring bug breaks that contract - a single bad request can never crash the host.
898
+ log('handleMessage threw (should not):', (err && err.stack) || String(err));
899
+ return sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error', (err && err.message) || String(err)));
900
+ }
901
+
902
+ // 4.4: a cancelled request gets NO further messages (MUST) - the socket is gone anyway;
903
+ // returning here keeps the contract explicit and the counter honest.
904
+ if (modernDisconnected) return;
905
+
906
+ // A notification (no reply) -> 202 Accepted, no body. A request -> 200 with the JSON-RPC result.
907
+ if (response == null) {
908
+ res.writeHead(202, { 'Content-Length': 0, 'Cache-Control': 'no-store' });
909
+ return res.end();
910
+ }
911
+ // Modern-era unknown method pairs -32601 with HTTP 404 (spec: the status IS the era-detection
912
+ // signal distinguishing a modern server from a legacy one). Legacy bodies keep the
913
+ // JSON-RPC-over-HTTP convention of 200-with-error.
914
+ if (modern.isModernRequest(msg) && response && response.error && response.error.code === -32601) {
915
+ return sendJson(res, 404, response);
916
+ }
917
+ // 4.1 - the modern-error status map: in-handler mints inherit their spec-mandated status by
918
+ // CODE. -32602 is a MUST-400 (2026-07-28 base protocol, Error Handling); the three modern codes
919
+ // (-32020/-32021/-32022) are 400 wherever they arise (the seam validators already 400 their
920
+ // own). Legacy bodies keep the JSON-RPC-over-HTTP 200-with-error convention untouched.
921
+ if (modern.isModernRequest(msg) && response && response.error &&
922
+ (response.error.code === -32602 || response.error.code === -32020 ||
923
+ response.error.code === -32021 || response.error.code === -32022)) {
924
+ return sendJson(res, 400, response);
925
+ }
926
+ // NARROW remap for the modern-only refusal ONLY: a legacy body refused under
927
+ // serveLegacy:false carries the mint's data.policy marker - give it the same 404 a real
928
+ // modern-only server presents a probing legacy client. Gated on the exact marker so every
929
+ // ORDINARY legacy -32601 keeps its frozen 200-with-error (the golden diff enforces that).
930
+ if (response && response.error && response.error.code === -32601 &&
931
+ response.error.data && response.error.data.policy === 'modern-only') {
932
+ return sendJson(res, 404, response);
933
+ }
934
+ return sendJson(res, 200, response);
935
+ }
936
+
937
+ /**
938
+ * validateMcpParamHeaders - the 4.3 Server-Validation check. Resolves the ADVERTISED def for
939
+ * params.name off the live surface (handleToolsList - the same assembly the client saw, so
940
+ * the annotation map can never disagree with the advertisement; the ingest hygiene in the
941
+ * aggregator has already stripped invalid annotations). Walks the pure-`properties` chains
942
+ * collecting x-mcp-header names, then compares each PRESENT `mcp-param-<name>` header
943
+ * (sentinel-decoded) against the body argument at that path. Returns a mismatch message, or
944
+ * null when valid. NEVER throws - a lookup failure validates nothing (fail open here: the
945
+ * body is still the single execution source of truth downstream).
946
+ */
947
+ function validateMcpParamHeaders(build, headers, params) {
948
+ try {
949
+ const list = serverModule.handleToolsList(build.protocol, build.aggregator,
950
+ { registry: build.registry, toolStatePath: build.toolStatePath });
951
+ const def = ((list && list.tools) || []).find((t) => t && t.name === params.name);
952
+ const schema = def && def.inputSchema;
953
+ if (!schema || typeof schema !== 'object') return null;
954
+ const annotations = []; // { header: lowercased header name, path: [prop, ...] }
955
+ (function walk(node, pathSoFar) {
956
+ if (!node || typeof node !== 'object' || Array.isArray(node)) return;
957
+ const props = node.properties;
958
+ if (!props || typeof props !== 'object' || Array.isArray(props)) return;
959
+ for (const [key, sub] of Object.entries(props)) {
960
+ if (!sub || typeof sub !== 'object' || Array.isArray(sub)) continue;
961
+ const p = pathSoFar.concat(key);
962
+ if (typeof sub['x-mcp-header'] === 'string') {
963
+ annotations.push({ header: 'mcp-param-' + sub['x-mcp-header'].toLowerCase(), path: p });
964
+ }
965
+ walk(sub, p); // nested objects stay on the chain via their own `properties`
966
+ }
967
+ })(schema, []);
968
+ const args = params.arguments && typeof params.arguments === 'object' ? params.arguments : {};
969
+ for (const a of annotations) {
970
+ const raw = headers && headers[a.header];
971
+ if (raw === undefined) continue; // absent -> the client mirrored nothing; nothing to cross-check
972
+ const headerValue = modern.decodeHeaderSentinel(String(raw));
973
+ let bodyValue = args;
974
+ for (const k of a.path) {
975
+ bodyValue = bodyValue && typeof bodyValue === 'object' ? bodyValue[k] : undefined;
976
+ }
977
+ if (bodyValue === undefined || String(bodyValue) !== headerValue) {
978
+ return `Header mismatch: ${a.header} value '${String(raw)}' does not match the body argument at '${a.path.join('.')}'`;
979
+ }
980
+ }
981
+ return null;
982
+ } catch (_e) {
983
+ return null; // never let validation break the request path
984
+ }
985
+ }
986
+
987
+ /**
988
+ * The single node:http request listener. Dispatches by method + path. async (it may await the
989
+ * OAuth gate). NEVER throws - start() wraps it so a rejection can't escape into the http server.
990
+ *
991
+ * Note on MCP-Protocol-Version: a client MAY send an `MCP-Protocol-Version` header on post-
992
+ * initialize requests. We read it leniently (unknown headers are ignored) and do not 400 on a
993
+ * mismatch - the version is negotiated in `initialize`. Strict per-request enforcement is a
994
+ * roadmap item; rejecting working clients over a header is the worse default for a local gateway.
995
+ */
996
+ async function onRequest(req, res) {
997
+ // Auth config is read FRESH per request (default OFF). When OFF the transport is loopback-only
998
+ // and unauthenticated (the original behaviour). When ON, a valid bearer token is the boundary.
999
+ const authCfg = authConfig.getConfig();
1000
+ const authEnabled = authCfg.enabled === true;
1001
+
1002
+ // DNS-rebinding / non-loopback guard. With auth DISABLED the bind address is the ONLY boundary,
1003
+ // so we reject any non-loopback Host (defence-in-depth, unchanged). With auth ENABLED the token
1004
+ // is the boundary - remote clients legitimately present a non-loopback Host - so we skip this
1005
+ // check and let the OAuth gate below authenticate every protected route.
1006
+ if (!authEnabled && !isLoopbackHost(req.headers && req.headers.host)) {
1007
+ return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Host'));
1008
+ }
1009
+
1010
+ // Origin guard: with auth OFF, a PRESENT non-loopback Origin is refused - the
1011
+ // browser-attached Origin is the one header a third-party page cannot forge, so this closes the
1012
+ // DNS-rebinding / cross-origin-CSRF hole the bind address alone cannot (a browser on ANY site
1013
+ // can reach 127.0.0.1). Absent Origin passes (non-browser clients). Policy + parsing rules
1014
+ // live on isLoopbackOrigin(). With auth ON the token is the boundary, exactly like the Host
1015
+ // guard above.
1016
+ if (!authEnabled && !isLoopbackOrigin(req.headers && req.headers.origin)) {
1017
+ return sendJson(res, 403, makeError(ERR.INVALID_REQUEST, 'forbidden: non-loopback Origin'));
1018
+ }
1019
+
1020
+ // Parse the path only (ignore query) without pulling in node:url's WHATWG parser overhead.
1021
+ const rawUrl = req.url || '/';
1022
+ const qIndex = rawUrl.indexOf('?');
1023
+ const pathName = qIndex === -1 ? rawUrl : rawUrl.slice(0, qIndex);
1024
+ const method = (req.method || 'GET').toUpperCase();
1025
+
1026
+ try {
1027
+ // RFC 9728 discovery document - the ONE unauthenticated route, and only when auth is enabled.
1028
+ // A client hits this (or follows the `resource_metadata` hint from a 401) to learn which
1029
+ // authorization server issues tokens for this gateway.
1030
+ if (method === 'GET' && pathName === WELL_KNOWN_PRM) {
1031
+ if (!authEnabled) return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: auth is not enabled'));
1032
+ return sendJson(res, 200, protectedResourceMetadata(authCfg));
1033
+ }
1034
+
1035
+ // OAuth gate: when auth is enabled, EVERY remaining route requires a valid bearer token -
1036
+ // /mcp (POST + SSE) and /health alike. The discovery document above is the sole exception.
1037
+ if (authEnabled) {
1038
+ const allowed = await passesAuth(req, res, authCfg);
1039
+ if (!allowed) return; // a 401/403/500 challenge has already been sent
1040
+ }
1041
+
1042
+ if (method === 'POST' && pathName === '/mcp') {
1043
+ // handleMcpPost is async + self-contained (it owns its try/catch); a rejection here would
1044
+ // be unusual but we still guard so an unhandled rejection can't escape the listener.
1045
+ handleMcpPost(req, res).catch((err) => {
1046
+ log('handleMcpPost rejected (should not):', (err && err.stack) || String(err));
1047
+ try {
1048
+ sendJson(res, 200, makeError(ERR.INTERNAL, 'Internal error'));
1049
+ } catch (_e) {
1050
+ /* response may be partly written; nothing more to do */
1051
+ }
1052
+ });
1053
+ return;
1054
+ }
1055
+
1056
+ // Streamable-HTTP standard: a GET to the SINGLE /mcp endpoint with Accept: text/event-stream
1057
+ // opens the server->client SSE stream. We serve that here, AND keep GET /mcp/sse as a working
1058
+ // alias (the older HTTP+SSE shape, deprecated but still supported). Both routes
1059
+ // hand off to the same openSse() - the loopback/auth guard above already gated the request.
1060
+ if (method === 'GET' && (pathName === '/mcp' || pathName === '/mcp/sse')) {
1061
+ // MODERN-ONLY POLICY (0.7.0): the GET-SSE stream is a LEGACY-era channel (modern uses
1062
+ // subscriptions/listen on POST) - answer the spec's own compatibility shape, 405 Method
1063
+ // Not Allowed + Allow: POST (the Streamable HTTP spec's rule; a MUST across three revisions).
1064
+ // Replaces the old 400/-32020 - HeaderMismatch was an allocation-policy misuse here too.
1065
+ if (build && build.serveLegacy === false) {
1066
+ res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1067
+ return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
1068
+ 'modern-only gateway (serveLegacy:false): the legacy SSE channel is disabled - POST subscriptions/listen instead')));
1069
+ }
1070
+ return openSse(req, res);
1071
+ }
1072
+
1073
+ // 4.5: modern-only mode answers DELETE on the MCP endpoint with 405 too (older-transport
1074
+ // session teardown - the spec's compatibility table). Dual-era keeps the frozen 404.
1075
+ if (method === 'DELETE' && (pathName === '/mcp' || pathName === '/mcp/sse') &&
1076
+ build && build.serveLegacy === false) {
1077
+ res.writeHead(405, { Allow: 'POST', 'Content-Type': 'application/json', 'Cache-Control': 'no-store' });
1078
+ return res.end(JSON.stringify(makeError(ERR.INVALID_REQUEST,
1079
+ 'modern-only gateway (serveLegacy:false): sessions are not a modern mechanism - POST requests instead')));
1080
+ }
1081
+
1082
+ if (method === 'GET' && pathName === '/health') {
1083
+ return sendJson(res, 200, health());
1084
+ }
1085
+
1086
+ // Unknown route.
1087
+ return sendJson(res, 404, makeError(ERR.INVALID_REQUEST, 'not found: ' + method + ' ' + pathName));
1088
+ } catch (err) {
1089
+ // Last-resort guard: a synchronous throw in routing becomes a 500 JSON, never a crash.
1090
+ log('onRequest threw:', (err && err.stack) || String(err));
1091
+ try {
1092
+ sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error'));
1093
+ } catch (_e) {
1094
+ /* response may already be committed */
1095
+ }
1096
+ }
1097
+ }
1098
+
1099
+ // ── Health snapshot ───────────────────────────────────────────────────────────────────────
1100
+
1101
+ /**
1102
+ * health - a SYNCHRONOUS snapshot of the host. `upstreamsConnected` + `toolsExposed` are read
1103
+ * from the live aggregator (defensively - a degraded build has a null aggregator). NEVER throws.
1104
+ *
1105
+ * `connected` + `failed` mirror the LAST connectAll() (start/reload) so a per-upstream connect
1106
+ * FAILURE is visible to the renderer instead of only landing on stderr. Both are fresh copies so
1107
+ * a caller cannot mutate the internal snapshot. `connected` is the list of upstream ids that
1108
+ * connected; `failed` is [{ id, error }] for the ones that did not (isolation guard, spawn
1109
+ * failure, handshake/listTools failure, ...). When the host is down both are empty (stop() resets).
1110
+ *
1111
+ * @returns {{ ok:boolean, server:object, protocolVersion:string, url:(string|null),
1112
+ * upstreamsConnected:number, upstreams:number, toolsExposed:number,
1113
+ * connected:string[], failed:Array<{id:string, error:string}> }}
1114
+ */
1115
+ function health() {
1116
+ let toolsExposed = 0;
1117
+ let upstreamsConnected = 0;
1118
+ const agg = build && build.aggregator;
1119
+ if (agg) {
1120
+ try {
1121
+ if (typeof agg.exposedToolDefinitions === 'function') {
1122
+ const defs = agg.exposedToolDefinitions();
1123
+ if (Array.isArray(defs)) toolsExposed = defs.length;
1124
+ }
1125
+ } catch (_e) {
1126
+ /* a failing aggregator reports 0, never throws out of health */
1127
+ }
1128
+ try {
1129
+ if (typeof agg.toolsByUpstream === 'function') {
1130
+ // toolsByUpstream() returns a clone map of upstreamId -> tools for CONNECTED upstreams.
1131
+ upstreamsConnected = Object.keys(agg.toolsByUpstream() || {}).length;
1132
+ }
1133
+ } catch (_e) {
1134
+ /* ignore - report 0 */
1135
+ }
1136
+ }
1137
+ // The advertised meta-tool count, DERIVED from the protocol so it never drifts from what
1138
+ // tools/list actually returns (all four meta-tools - list, instructions, run, howto - are
1139
+ // advertised; deriving rather than hardcoding keeps health honest through future changes).
1140
+ // ACTIVE WRAP -> liveness only. The /mcp surface on this very port impersonates the wrapped
1141
+ // server; the full body (server:toolfunnel, versions, upstream ids, connect-failure strings)
1142
+ // would hand any unauthenticated loopback GET the funnel's existence wholesale - undoing the
1143
+ // error-text neutralisation the wrap paths pay for.
1144
+ try {
1145
+ if (build && typeof build.wrapActive === 'function' && build.wrapActive()) {
1146
+ return { ok: started === true && stopping === false };
1147
+ }
1148
+ } catch (_e) { /* degraded flag -> serve the full body */ }
1149
+ let metaCount = 0;
1150
+ try {
1151
+ const proto = build && build.protocol;
1152
+ const defs = proto && typeof proto.toolDefinitions === 'function' ? proto.toolDefinitions() : null;
1153
+ if (Array.isArray(defs)) metaCount = defs.length;
1154
+ } catch (_e) {
1155
+ /* degraded build -> metaCount stays 0 */
1156
+ }
1157
+ return {
1158
+ ok: started === true && stopping === false,
1159
+ server: SERVER_INFO,
1160
+ protocolVersion: PROTOCOL_VERSION,
1161
+ // Dual-era: every revision this host speaks (modern first). `protocolVersion` above stays
1162
+ // the legacy string for backward compatibility with 0.5.0 health consumers (the UI).
1163
+ protocolVersions: modern.supportedVersions(),
1164
+ // Open modern subscriptions/listen streams (legacy SSE clients are sseClientCount).
1165
+ listenStreams: modernListeners.size,
1166
+ // 4.4 rider: modern POST tools/call sockets that closed before the response was written -
1167
+ // treated as cancellation (audit-logged); a returning client discovers the count here.
1168
+ disconnectCancels,
1169
+ // The MCP endpoint the CLI points at (includes the /mcp path) - matches the .mcp.json
1170
+ // target and what the status tooltip should show, not the bare origin.
1171
+ url: boundPort == null ? null : currentUrl() + '/mcp',
1172
+ upstreamsConnected,
1173
+ // `upstreams` is an alias of upstreamsConnected for the renderer status tooltip
1174
+ // (the renderer reads snap.upstreams / snap.url for its title text).
1175
+ upstreams: upstreamsConnected,
1176
+ toolsExposed,
1177
+ // `tools` = what the CLI sees in tools/list: the four advertised meta-tools
1178
+ // (toolfunnel_list_tools/tool_instructions/run_tool/howto) + the curated-direct surface.
1179
+ // DERIVED from protocol.toolDefinitions() (metaCount) so it never drifts from the real
1180
+ // advertised count. The register's long tail lives behind toolfunnel_list_tools, not as
1181
+ // separate tools/list entries (a fresh host's status cell reads "up.4").
1182
+ tools: metaCount + toolsExposed,
1183
+ // Per-upstream connect outcome from the last connectAll() - makes silent connect-failures
1184
+ // visible. Fresh copies so the caller can never mutate the internal snapshot.
1185
+ connected: lastConnect.connected.slice(),
1186
+ failed: lastConnect.failed.map((f) => ({ id: f.id, error: f.error })),
1187
+ // OAuth resource-server status (no secrets - issuer/audience live in the well-known doc):
1188
+ // whether auth is enabled, whether the optional jose dependency is installed, and whether the
1189
+ // current config is coherent enough to enforce (misconfigured: configError() reason present).
1190
+ auth: authSnapshot(),
1191
+ // Lightweight in-memory observability: process-lifetime tools/call counters (total + per-tool
1192
+ // + error counts). Resets on restart; complementary to the on-disk JSONL audit log.
1193
+ metrics: metricsSnapshot(),
1194
+ };
1195
+ }
1196
+
1197
+ /** A defensive copy of the in-memory tool-call counters. NEVER throws. */
1198
+ function metricsSnapshot() {
1199
+ try {
1200
+ return metrics.snapshot();
1201
+ } catch (_e) {
1202
+ return { startedAt: '', calls: 0, errors: 0, byTool: {} };
1203
+ }
1204
+ }
1205
+
1206
+ /** A no-secrets snapshot of the OAuth gate state for /health + the UI. NEVER throws. */
1207
+ function authSnapshot() {
1208
+ try {
1209
+ const cfg = authConfig.getConfig();
1210
+ return {
1211
+ enabled: cfg.enabled === true,
1212
+ joseInstalled: isJoseInstalled(),
1213
+ configError: cfg.enabled ? authConfig.configError(cfg) : null,
1214
+ };
1215
+ } catch (_e) {
1216
+ return { enabled: false, joseInstalled: false, configError: null };
1217
+ }
1218
+ }
1219
+
1220
+ // ── Lifecycle ─────────────────────────────────────────────────────────────────────────────
1221
+
1222
+ /**
1223
+ * start - build the protocol+aggregator, connect every enabled upstream, then bind HTTP. On
1224
+ * EADDRINUSE the returned promise REJECTS with a clear error (the caller decides what to do) -
1225
+ * it does NOT crash the process. Resolves to { port, url } once listening.
1226
+ *
1227
+ * Port 0 -> an OS-assigned ephemeral port; the actual port is read back from server.address().
1228
+ * @returns {Promise<{ port:number, url:string }>}
1229
+ */
1230
+ async function start() {
1231
+ if (started) {
1232
+ // Idempotent-ish: a second start() on a running host just reports the live binding.
1233
+ return { port: boundPort, url: currentUrl() };
1234
+ }
1235
+
1236
+ // ── Fail-fast safety guards (BEFORE building/binding) ───────────────────────────────────────
1237
+ // These throw a clear, typed error the caller surfaces - they NEVER start a misconfigured host.
1238
+ const authCfg = authConfig.getConfig();
1239
+ if (authCfg.enabled) {
1240
+ // OAuth on but the dependency missing -> refuse (else every request would fail closed anyway,
1241
+ // but failing at start with an actionable message is far better than a dead host).
1242
+ if (!isJoseInstalled()) {
1243
+ throw new Error(
1244
+ 'OAuth is enabled but the "jose" dependency is not installed - run `toolfunnel install-oauth` ' +
1245
+ '(or click Install in the UI Auth panel), or disable auth in auth/auth.config.json.'
1246
+ );
1247
+ }
1248
+ const cfgErr = authConfig.configError(authCfg);
1249
+ if (cfgErr) throw new Error('OAuth is enabled but misconfigured: ' + cfgErr);
1250
+ } else if (!isLoopbackHost(host)) {
1251
+ // Binding off-loopback with NO auth would expose an UNAUTHENTICATED gateway to the network -
1252
+ // exactly the footgun the loopback default exists to prevent. Require auth before exposing it.
1253
+ throw new Error(
1254
+ `refusing to bind non-loopback host "${host}" without OAuth enabled - enable auth ` +
1255
+ '(and install jose) before exposing the gateway off localhost.'
1256
+ );
1257
+ }
1258
+
1259
+ // Build + connect upstreams BEFORE binding so tools/list advertises the curated surface from
1260
+ // the very first request. connectAll() NEVER throws (failures land in failed[]); with the
1261
+ // default EMPTY expose.json it is an instant no-op.
1262
+ build = buildFactory();
1263
+ wireAggregatorNotify();
1264
+ if (build && build.aggregator && typeof build.aggregator.connectAll === 'function') {
1265
+ try {
1266
+ const res = await build.aggregator.connectAll();
1267
+ recordConnect(res); // surface connect outcome (connected[]/failed[]) via health()
1268
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
1269
+ log('connectAll: ' + res.failed.length + ' upstream(s) failed:',
1270
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
1271
+ }
1272
+ } catch (err) {
1273
+ recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
1274
+ log('connectAll threw (ignored):', (err && err.message) || String(err));
1275
+ }
1276
+ } else {
1277
+ recordConnect(null); // no aggregator -> no upstreams; report empty connect state
1278
+ }
1279
+
1280
+ // onRequest is async (it may await the OAuth gate); wrap it so a rejection can NEVER escape into
1281
+ // the http server as an unhandled rejection - it degrades to a clean 500 instead.
1282
+ httpServer = http.createServer((req, res) => {
1283
+ onRequest(req, res).catch((err) => {
1284
+ log('onRequest rejected (should not):', (err && err.stack) || String(err));
1285
+ try { sendJson(res, 500, makeError(ERR.INTERNAL, 'Internal error')); } catch (_e) { /* committed */ }
1286
+ });
1287
+ });
1288
+
1289
+ return new Promise((resolve, reject) => {
1290
+ // A bind failure (EADDRINUSE, EACCES) surfaces as an 'error' event before 'listening'. We
1291
+ // reject ONCE and detach the listener so a later runtime error can't double-settle.
1292
+ const onListenError = (err) => {
1293
+ httpServer.removeListener('listening', onListening);
1294
+ httpServer = null;
1295
+ // connectAll() ran BEFORE listen() and may have spawned upstream children; a bind failure
1296
+ // here would otherwise orphan them. Tear the aggregator down before rejecting. closeAll()
1297
+ // never throws; fire-and-forget (this is a sync 'error' handler) - it's cleanup only.
1298
+ const agg = build && build.aggregator;
1299
+ build = null; // nothing bound - drop the half-built state so a retry rebuilds cleanly
1300
+ if (agg && typeof agg.closeAll === 'function') {
1301
+ try { agg.closeAll(); } catch (_e) { /* never throw */ }
1302
+ }
1303
+ reject(err); // clear, typed error (err.code === 'EADDRINUSE' etc.) - caller decides
1304
+ };
1305
+ const onListening = () => {
1306
+ httpServer.removeListener('error', onListenError);
1307
+ const addr = httpServer.address();
1308
+ boundPort = addr && typeof addr === 'object' ? addr.port : requestedPort;
1309
+ started = true;
1310
+ stopping = false;
1311
+ // Arm config hot-reload now the build is live + bound. Pass a GETTER so the reloaders
1312
+ // always target the CURRENT build (reload() rebuilds it); send = pushToSse so a
1313
+ // hook/register/expose change broadcasts notifications/tools/list_changed to SSE clients.
1314
+ // This is the wiring the HTTP host was missing - stdio's main() has always done it.
1315
+ try {
1316
+ configWatchersStop = require('./server').startConfigWatchers(() => build, (n) => {
1317
+ // Era-aware fanout: legacy SSE clients get the raw notification; modern listen
1318
+ // streams get tagged + filtered copies (tools/list_changed is the only channel).
1319
+ pushToSse(n);
1320
+ if (n && n.method === 'notifications/tools/list_changed') pushToListeners(n);
1321
+ });
1322
+ } catch (e) {
1323
+ log('config watchers failed to arm (host runs, without hot-reload):', (e && e.message) || String(e));
1324
+ }
1325
+ // Swap the one-shot bind-error handler for a steady-state one so a later socket error
1326
+ // (a client RST, etc.) is logged, not thrown.
1327
+ httpServer.on('error', (e) => log('http server error:', (e && e.message) || String(e)));
1328
+ log('listening on', currentUrl(), '(pid', process.pid + ')');
1329
+ resolve({ port: boundPort, url: currentUrl() });
1330
+ };
1331
+ httpServer.once('error', onListenError);
1332
+ httpServer.once('listening', onListening);
1333
+ httpServer.listen(requestedPort, host);
1334
+ });
1335
+ }
1336
+
1337
+ /**
1338
+ * stop - end every SSE client, close the HTTP server, then close the aggregator. Idempotent and
1339
+ * NEVER throws. Clears every keep-alive timer so the process can exit cleanly (no dangling
1340
+ * server, sockets, or timers).
1341
+ * @returns {Promise<void>}
1342
+ */
1343
+ async function stop() {
1344
+ if (!started && !httpServer) {
1345
+ // Never started (or already fully stopped) - still close any build the caller may have left.
1346
+ await closeAggregatorSafely();
1347
+ return;
1348
+ }
1349
+ stopping = true;
1350
+
1351
+ // 0. Disarm the config watchers FIRST, so no debounced reload can fire against the build we're
1352
+ // about to tear down (a reload mutating a half-closed aggregator would be a benign no-op, but
1353
+ // disarming first is the clean order).
1354
+ if (configWatchersStop) {
1355
+ try { configWatchersStop(); } catch (_e) { /* never throw */ }
1356
+ configWatchersStop = null;
1357
+ }
1358
+
1359
+ // 1. Tear down SSE clients: clear their keep-alive timers and end the responses so their
1360
+ // sockets close (an open SSE socket would otherwise keep the server from closing).
1361
+ for (const res of Array.from(sseClients)) {
1362
+ try {
1363
+ if (res._keepAlive) clearInterval(res._keepAlive);
1364
+ } catch (_e) {
1365
+ /* ignore */
1366
+ }
1367
+ try {
1368
+ res.end();
1369
+ } catch (_e) {
1370
+ /* ignore */
1371
+ }
1372
+ }
1373
+ sseClients.clear();
1374
+
1375
+ // 1b. Close modern listen streams GRACEFULLY per spec: a final RESULT to the listen id
1376
+ // (resultType complete, tagged), then end the stream - and WAIT (bounded) for the ends to
1377
+ // flush before step 2's closeAllConnections destroys the sockets, else the close result is
1378
+ // written but torn out of the kernel buffer before it ever reaches the client (the same
1379
+ // flush-vs-destroy race sendJsonAndClose documents). A dead socket just gets ended.
1380
+ const listenFlushes = [];
1381
+ for (const entry of Array.from(modernListeners)) {
1382
+ try {
1383
+ if (entry.res._keepAlive) clearInterval(entry.res._keepAlive);
1384
+ } catch (_e) {
1385
+ /* ignore */
1386
+ }
1387
+ try {
1388
+ entry.res.write('data: ' + JSON.stringify(modern.listenClose(entry.subId)) + '\n\n');
1389
+ } catch (_e) {
1390
+ /* half-closed socket - ending below is all that is left */
1391
+ }
1392
+ listenFlushes.push(
1393
+ new Promise((resolve) => {
1394
+ let settled = false;
1395
+ const done = () => {
1396
+ if (settled) return;
1397
+ settled = true;
1398
+ resolve();
1399
+ };
1400
+ try {
1401
+ entry.res.once('finish', done);
1402
+ entry.res.once('close', done);
1403
+ entry.res.once('error', done);
1404
+ entry.res.end();
1405
+ } catch (_e) {
1406
+ done();
1407
+ }
1408
+ })
1409
+ );
1410
+ }
1411
+ modernListeners.clear();
1412
+ if (listenFlushes.length > 0) {
1413
+ // Bounded: a client that never drains cannot wedge stop() - 500ms then move on regardless.
1414
+ await Promise.race([
1415
+ Promise.all(listenFlushes),
1416
+ new Promise((resolve) => {
1417
+ const t = setTimeout(resolve, 500);
1418
+ if (t && typeof t.unref === 'function') t.unref();
1419
+ }),
1420
+ ]);
1421
+ }
1422
+
1423
+ // 2. Close the HTTP server (stops accepting + waits for in-flight sockets to drain). We also
1424
+ // actively destroy any lingering sockets so close() can never hang the process exit.
1425
+ if (httpServer) {
1426
+ const srv = httpServer;
1427
+ httpServer = null;
1428
+ await new Promise((resolve) => {
1429
+ let settled = false;
1430
+ const done = () => {
1431
+ if (settled) return;
1432
+ settled = true;
1433
+ resolve();
1434
+ };
1435
+ try {
1436
+ srv.close(done);
1437
+ } catch (_e) {
1438
+ done();
1439
+ }
1440
+ // Destroy any keep-alive sockets still attached so close() resolves promptly.
1441
+ try {
1442
+ if (typeof srv.closeAllConnections === 'function') {
1443
+ srv.closeAllConnections();
1444
+ }
1445
+ } catch (_e) {
1446
+ /* closeAllConnections is Node ≥18.2; absent -> close() still resolves on its own */
1447
+ }
1448
+ });
1449
+ }
1450
+
1451
+ // 3. Close the aggregator (idempotent; never throws).
1452
+ await closeAggregatorSafely();
1453
+
1454
+ boundPort = null;
1455
+ started = false;
1456
+ stopping = false;
1457
+ lastConnect = { connected: [], failed: [] }; // drop the connect snapshot - the host is down
1458
+ }
1459
+
1460
+ /** Close the current build's aggregator if present. Idempotent + never throws. */
1461
+ async function closeAggregatorSafely() {
1462
+ const agg = build && build.aggregator;
1463
+ build = null;
1464
+ if (agg && typeof agg.closeAll === 'function') {
1465
+ try {
1466
+ await agg.closeAll();
1467
+ } catch (err) {
1468
+ log('aggregator.closeAll threw (ignored):', (err && err.message) || String(err));
1469
+ }
1470
+ }
1471
+ }
1472
+
1473
+ /**
1474
+ * reload - apply an expose.json edit live: fully tear down the current aggregator, rebuild from
1475
+ * the (now-edited) config, reconnect every enabled upstream, then broadcast list_changed so a
1476
+ * connected CLI re-fetches tools/list. A clean full rebuild because expose edits are rare +
1477
+ * user-driven (the design doc). The HTTP server keeps running across the reload - only
1478
+ * the build is swapped. NEVER throws (connectAll never throws; closeAll never throws).
1479
+ * @returns {Promise<void>}
1480
+ */
1481
+ async function reload() {
1482
+ // 1. Build + connect the NEW build FIRST - the live build keeps serving throughout. The old
1483
+ // null-then-rebuild order left a `build == null` window (upstream spawn + handshake long)
1484
+ // during which a wrapped host answered initialize/server/discover as ToolFunnel - an
1485
+ // identity leak on the exported API. Swap-then-close, like reloadExpose.
1486
+ const next = buildFactory();
1487
+ // Arm the wrap scope on the NEW build BEFORE connectAll - the subscription replay in
1488
+ // _connectOne is scope-checked, and an unarmed aggregator replayed onto every upstream
1489
+ // Arm first, THEN carry the set: the arming setter clears on target change.
1490
+ if (next && next.aggregator && typeof serverModule.armWrapChatter === 'function') {
1491
+ serverModule.armWrapChatter(next);
1492
+ }
1493
+ // Subscription carryover - must land BEFORE connectAll so the new aggregator's
1494
+ // _connectOne replays the agreed URIs onto the fresh upstream processes.
1495
+ const prevAggForSubs = build && build.aggregator;
1496
+ if (next && next.aggregator && prevAggForSubs &&
1497
+ prevAggForSubs._subscribedUris instanceof Set && prevAggForSubs._subscribedUris.size) {
1498
+ next.aggregator._subscribedUris = new Set(prevAggForSubs._subscribedUris);
1499
+ // Carry the REFCOUNTS too, exactly as reloadExpose does (server.js). Without them the new
1500
+ // aggregator held the URIs with an empty refs Map, and releaseResources treats refcount 0 as
1501
+ // "last holder" - so the first listener to close unsubscribed upstream and killed the
1502
+ // channel for every other listener still holding that URI (0.7.0).
1503
+ if (prevAggForSubs._subscribedRefs instanceof Map) {
1504
+ next.aggregator._subscribedRefs = new Map(prevAggForSubs._subscribedRefs);
1505
+ }
1506
+ }
1507
+ if (next && next.aggregator && typeof next.aggregator.connectAll === 'function') {
1508
+ try {
1509
+ const res = await next.aggregator.connectAll();
1510
+ recordConnect(res); // refresh the health() connect snapshot for the new build
1511
+ if (res && Array.isArray(res.failed) && res.failed.length > 0) {
1512
+ log('reload connectAll: ' + res.failed.length + ' upstream(s) failed:',
1513
+ res.failed.map((f) => `${f.id}(${f.error})`).join('; '));
1514
+ }
1515
+ } catch (err) {
1516
+ recordConnect(null); // a throw leaves connect state EMPTY rather than stale/partial
1517
+ log('reload connectAll threw (ignored):', (err && err.message) || String(err));
1518
+ }
1519
+ } else {
1520
+ recordConnect(null); // no aggregator -> no upstreams; report empty connect state
1521
+ }
1522
+ // 2. Swap, re-wire the notification bridges onto the new build, then close the OLD aggregator.
1523
+ const prev = build && build.aggregator;
1524
+ build = next;
1525
+ wireAggregatorNotify();
1526
+ if (prev && typeof prev.closeAll === 'function') {
1527
+ try {
1528
+ await prev.closeAll();
1529
+ } catch (err) {
1530
+ log('aggregator.closeAll threw (ignored):', (err && err.message) || String(err));
1531
+ }
1532
+ }
1533
+ // 3. Tell connected clients the tool surface changed.
1534
+ broadcastToolsListChanged();
1535
+ }
1536
+
1537
+ // ── Public handle ───────────────────────────────────────────────────────────────────────────
1538
+ return {
1539
+ start,
1540
+ stop,
1541
+ reload,
1542
+ broadcastToolsListChanged,
1543
+ health,
1544
+ get port() {
1545
+ return boundPort;
1546
+ },
1547
+ get url() {
1548
+ return currentUrl();
1549
+ },
1550
+ get sseClientCount() {
1551
+ return sseClients.size;
1552
+ },
1553
+ };
1554
+ }
1555
+
1556
+ module.exports = {
1557
+ createHttpMcpServer,
1558
+ // Exported for unit tests / reuse - the small pure seams.
1559
+ isLoopbackHost,
1560
+ isLoopbackOrigin,
1561
+ PROTOCOL_VERSION,
1562
+ SERVER_INFO,
1563
+ };