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