mcp-pacemaker 2.0.1

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 (47) hide show
  1. package/CHANGELOG.md +452 -0
  2. package/CONTRIBUTING.md +222 -0
  3. package/LICENSE +21 -0
  4. package/README.md +621 -0
  5. package/SECURITY.md +120 -0
  6. package/THIRD_PARTY_NOTICES.txt +366 -0
  7. package/autostart/windows/launcher.vbs +11 -0
  8. package/autostart/windows/register-task.ps1 +63 -0
  9. package/bin/cli.mjs +880 -0
  10. package/bin/config-checks.mjs +62 -0
  11. package/bin/logs.mjs +465 -0
  12. package/bin/mcp-bridge.mjs +2162 -0
  13. package/bin/pooling-batch-scheduler.mjs +129 -0
  14. package/bin/pooling-batches.mjs +270 -0
  15. package/bin/pooling-config.mjs +577 -0
  16. package/bin/pooling-editor.mjs +128 -0
  17. package/bin/pooling-errors.mjs +23 -0
  18. package/bin/pooling-execution.mjs +104 -0
  19. package/bin/pooling-files.mjs +541 -0
  20. package/bin/pooling-trace.mjs +218 -0
  21. package/bin/pooling-writer.mjs +242 -0
  22. package/bin/prewarm.mjs +233 -0
  23. package/bin/server-metrics.mjs +69 -0
  24. package/bin/service-control.mjs +124 -0
  25. package/bin/shared-sessions.mjs +1083 -0
  26. package/bin/top.mjs +115 -0
  27. package/bin/update-check.mjs +153 -0
  28. package/bin/windows/PoolingSecurityHelper.build.json +42 -0
  29. package/bin/windows/PoolingSecurityHelper.exe +0 -0
  30. package/bin/windows/src/AssemblyInfo.cs +11 -0
  31. package/bin/windows/src/PoolingNativeFiles.cs +678 -0
  32. package/bin/windows/src/PoolingSecurityHelper.cs +178 -0
  33. package/bin/windows/src/PoolingSecurityReader.cs +50 -0
  34. package/docs/shared-sessions.md +181 -0
  35. package/examples/servers.example.json +31 -0
  36. package/package.json +77 -0
  37. package/supervisor/bridge-child.mjs +14 -0
  38. package/supervisor/supervise.mjs +172 -0
  39. package/supervisor/supervise.ps1 +23 -0
  40. package/supervisor/supervise.sh +11 -0
  41. package/tools/windows-security-helper/README.md +238 -0
  42. package/tools/windows-security-helper/build.ps1 +262 -0
  43. package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
  44. package/ui/dist/assets/index-CaLPg0c-.css +1 -0
  45. package/ui/dist/assets/index-CgPruWYi.js +9 -0
  46. package/ui/dist/index.html +14 -0
  47. package/ui/dist/third-party-manifest.json +597 -0
@@ -0,0 +1,2162 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-bridge.mjs — zero-dependency stdio/HTTP MCP bridge with pluggable auth.
4
+ * Part of mcp-pacemaker. MIT.
5
+ *
6
+ * WHY
7
+ * Editor clients (VS Code, Cursor, Claude Desktop) spawn stdio MCP servers as children
8
+ * of their extension host. When the host restarts or crashes, every stdio server dies at
9
+ * once. This bridge runs the servers as children of a long-lived process and exposes each
10
+ * over the MCP HTTP+SSE transport, so the client simply RECONNECTS after a host restart —
11
+ * the servers keep running.
12
+ *
13
+ * ZERO DEPENDENCIES
14
+ * Node built-ins only. Nothing is pulled from a package registry at runtime.
15
+ *
16
+ * AUTH (per http server)
17
+ * "auth": { "type": "command", "command": "<cli that prints a token>", "refreshMinutes": 50 }
18
+ * "auth": { "type": "env", "var": "MY_TOKEN" }
19
+ * "auth": { "type": "static", "header": "Authorization", "value": "Bearer ..." }
20
+ * "auth": { "type": "none" }
21
+ * Back-compat: a bare "audience": "<res>" is treated as an `az account get-access-token` command.
22
+ *
23
+ * USAGE
24
+ * node mcp-bridge.mjs [--port 8791] [--host 127.0.0.1] [--config servers.json] [--cwd <dir>]
25
+ */
26
+ import http from 'node:http';
27
+ import https from 'node:https';
28
+ import { spawn, exec, execFile, execFileSync } from 'node:child_process';
29
+ import { createHash, randomUUID } from 'node:crypto';
30
+ import { readFileSync, existsSync, writeFileSync, appendFileSync, statSync, renameSync, watch } from 'node:fs';
31
+ import { fileURLToPath } from 'node:url';
32
+ import { dirname, resolve, extname } from 'node:path';
33
+ import { homedir } from 'node:os';
34
+ import { createInterface } from 'node:readline';
35
+ import { checkServerPaths, checkReservedName } from './config-checks.mjs';
36
+ import { ServerMetrics } from './server-metrics.mjs';
37
+ import { MAX_MIN_WARM, hasPoolingTransaction, recoverPoolingConfig } from './pooling-config.mjs';
38
+ import { PoolingBatches } from './pooling-batches.mjs';
39
+ import { POOLING_BUDGET_MS } from './pooling-execution.mjs';
40
+ import { poolingTrace } from './pooling-trace.mjs';
41
+ import { SharedSessionManager, SHARED_DEFAULTS } from './shared-sessions.mjs';
42
+
43
+ const __dirname = dirname(fileURLToPath(import.meta.url));
44
+ const argv = process.argv.slice(2);
45
+ const getArg = (name, def) => {
46
+ const i = argv.indexOf(name);
47
+ return i >= 0 && argv[i + 1] ? argv[i + 1] : def;
48
+ };
49
+
50
+ const HOME = resolve(homedir(), '.mcp-pacemaker');
51
+ const localConfig = resolve(process.cwd(), 'servers.json');
52
+ const defaultConfig = existsSync(localConfig) || hasPoolingTransaction(localConfig)
53
+ ? resolve(process.cwd(), 'servers.json')
54
+ : resolve(HOME, 'servers.json');
55
+ const CONFIG = resolve(process.cwd(), getArg('--config', defaultConfig));
56
+ const PORT = parseInt(getArg('--port', '8791'), 10);
57
+ const HOST = getArg('--host', '127.0.0.1');
58
+ const VERSION = (() => { try { return JSON.parse(readFileSync(resolve(__dirname, '..', 'package.json'), 'utf8')).version; } catch { return '0'; } })();
59
+ // Base working directory for relative server commands/args. Defaults to the config's folder.
60
+ const BASE_CWD = resolve(getArg('--cwd', dirname(CONFIG)));
61
+
62
+ /** @type {Record<string, any>} */
63
+ if (hasPoolingTransaction(CONFIG)) recoverPoolingConfig(CONFIG);
64
+ const servers = JSON.parse(readFileSync(CONFIG, 'utf8'));
65
+ const poolingConfig = new PoolingBatches({
66
+ configPath: CONFIG,
67
+ reload: reloadConfig,
68
+ revision: () => createHash('sha256').update(lastConfigText ?? '').digest('hex'),
69
+ notify: publishSnapshot,
70
+ captureSnapshot: richSnapshot,
71
+ onError: (error) => log(`pooling reload failed (${error.code || 'IO_ERROR'}); reread batch status`),
72
+ });
73
+
74
+ /* ----------------------------- dashboard backbone --------------------------- */
75
+ const startedAt = Date.now();
76
+ const instanceId = randomUUID();
77
+ let snapshotVersion = 0;
78
+ const serverMetrics = new ServerMetrics();
79
+ const ADMIN_NONCE = randomUUID();
80
+ const NONCE_FILE = resolve(dirname(CONFIG), 'admin.nonce');
81
+ const logBuffer = [];
82
+ const logClients = new Set();
83
+ const snapClients = new Set();
84
+ const stats = new Map(); // name -> see stat()
85
+ const stat = (name) => {
86
+ let s = stats.get(name);
87
+ if (!s) { s = { requests: 0, lastError: null, lastErrorAt: 0, lastActivity: 0, lastSuccess: 0, consecutiveFailures: 0, lastProbe: 0 }; stats.set(name, s); }
88
+ return s;
89
+ };
90
+
91
+ /* ------------------------------- server health ------------------------------ */
92
+ // `lastError` alone is not a health signal: it is sticky, so a server that failed once last week
93
+ // looks identical to one that is failing right now, and one that recovered still shows the old
94
+ // error. A server 401ing on every call sat that way for hours here while the dashboard showed
95
+ // nothing wrong. Health is therefore a running verdict — did the last attempt work, and how many
96
+ // in a row have not — rather than a string that only ever accumulates.
97
+ function noteSuccess(name) {
98
+ const s = stat(name);
99
+ if (s.consecutiveFailures > 0) log(`[${name}] recovered after ${s.consecutiveFailures} consecutive failure(s)`);
100
+ s.consecutiveFailures = 0;
101
+ s.lastSuccess = Date.now();
102
+ s.lastError = null;
103
+ }
104
+
105
+ function noteFailure(name, detail) {
106
+ const s = stat(name);
107
+ if (s.consecutiveFailures === 0) log(`[${name}] now failing: ${detail}`);
108
+ s.consecutiveFailures++;
109
+ s.lastError = detail;
110
+ s.lastErrorAt = Date.now();
111
+ }
112
+
113
+ // 'unknown' is deliberately distinct from 'ok'. Reporting a server nobody has called as healthy
114
+ // is the exact lie this feature exists to stop.
115
+ function healthOf(name) {
116
+ const s = stat(name);
117
+ const state = s.consecutiveFailures > 0 ? 'failing' : (s.lastSuccess ? 'ok' : 'unknown');
118
+ return {
119
+ state,
120
+ consecutiveFailures: s.consecutiveFailures,
121
+ lastSuccessSec: s.lastSuccess ? Math.round((Date.now() - s.lastSuccess) / 1000) : null,
122
+ lastErrorSec: s.lastErrorAt ? Math.round((Date.now() - s.lastErrorAt) / 1000) : null,
123
+ };
124
+ }
125
+
126
+ // A durable log next to the config. The in-memory buffer is all the dashboard and `/api/logs`
127
+ // have, and a single chatty client fills it in minutes — on a real setup a client polling every
128
+ // 30s left roughly two minutes of history, which is useless for diagnosing something that
129
+ // happened overnight. stderr alone does not help either: the supervisor starts the bridge
130
+ // without redirecting it, and on Windows that output goes nowhere. So write here too, and roll
131
+ // the file at a fixed size so it cannot grow without bound.
132
+ const LOG_FILE = resolve(dirname(CONFIG), 'bridge.log');
133
+ const LOG_MAX_BYTES = parseInt(process.env.MCP_LOG_MAX_BYTES || String(5 * 1024 * 1024), 10);
134
+ let logBytes = -1; // -1 until the existing file has been measured once
135
+ function appendLog(line) {
136
+ if (LOG_MAX_BYTES <= 0) return;
137
+ try {
138
+ if (logBytes < 0) logBytes = existsSync(LOG_FILE) ? statSync(LOG_FILE).size : 0;
139
+ if (logBytes >= LOG_MAX_BYTES) {
140
+ renameSync(LOG_FILE, LOG_FILE + '.1'); // keep one previous file, overwriting any older one
141
+ logBytes = 0;
142
+ }
143
+ const buf = line + '\n';
144
+ appendFileSync(LOG_FILE, buf);
145
+ logBytes += Buffer.byteLength(buf);
146
+ } catch { /* logging must never take the bridge down */ }
147
+ }
148
+
149
+ const log = (m) => {
150
+ const line = `[mcp-bridge] ${new Date().toISOString()} ${m}`;
151
+ process.stderr.write(line + '\n');
152
+ appendLog(line);
153
+ logBuffer.push(line); if (logBuffer.length > 500) logBuffer.shift();
154
+ for (const c of logClients) { try { c.write(`data: ${JSON.stringify(line)}\n\n`); } catch { /* gone */ } }
155
+ };
156
+
157
+ // Token expiry (seconds) for an http server's cached command/az token, if any — for the dashboard.
158
+ //
159
+ // A bare `audience` is shorthand for an Azure CLI token command. It is expanded in exactly one
160
+ // place because the expanded string is also the token cache key: two copies that drift would
161
+ // mint under one key and look it up under another, quietly disabling the cache.
162
+ const azTokenCommand = (audience) =>
163
+ `az account get-access-token --resource ${audience} --query accessToken -o tsv`;
164
+ function normalizedAuth(def) {
165
+ if (def.auth) return def.auth;
166
+ if (def.audience) return { type: 'command', command: azTokenCommand(def.audience), refreshMinutes: 50 };
167
+ return null;
168
+ }
169
+ function authCacheKey(def) {
170
+ const auth = normalizedAuth(def);
171
+ return auth && auth.type === 'command' ? auth.command : null;
172
+ }
173
+ function tokenExpiryFor(def) {
174
+ const key = authCacheKey(def);
175
+ if (!key) return null;
176
+ const c = tokenCache.get(key);
177
+ return c ? Math.max(0, Math.round((c.exp - Date.now()) / 1000)) : null;
178
+ }
179
+
180
+ // Rich per-server snapshot for the dashboard (/api/status, /api/events).
181
+ function richSnapshot() {
182
+ const byName = {};
183
+ for (const name of Object.keys(servers)) {
184
+ const def = servers[name];
185
+ const st = stat(name);
186
+ byName[name] = { name, type: def.type === 'http' ? 'http' : 'stdio', sessions: 0, cappedSessions: 0, pids: [], clients: [], sharing: def.sharing || 'isolated', warm: (warmPool.get(name) || []).length, minWarm: poolTarget(name), recycleMinutes: recycleMinutesFor(name) || null, maxSessions: (def.maxSessions ?? MAX_SESSIONS_PER_SERVER) || null, requests: st.requests, lastError: st.lastError, lastActivitySec: st.lastActivity ? Math.round((Date.now() - st.lastActivity) / 1000) : null, health: healthOf(name), spawn: spawnStats(name), peakConcurrency: peakConcurrency(name), advice: poolAdvice(name) };
187
+ byName[name].prewarming = prewarmingState(name);
188
+ byName[name].shared = sharedManager.inspect(name);
189
+ if (byName[name].shared?.pid) byName[name].pids.push(byName[name].shared.pid);
190
+ byName[name].startingSessions = startingSessions.get(name) || 0;
191
+ byName[name].cappedSessions += byName[name].startingSessions;
192
+ if (def.type === 'http') { byName[name].url = def.url; byName[name].tokenExpiresIn = tokenExpiryFor(def); }
193
+ }
194
+ // `sessions` sums both transports for display, but `maxSessions` only governs the streamable
195
+ // pool — so a classic SSE session made the card read "33/32" as though the cap had been
196
+ // breached. Report the number the cap actually counts alongside it.
197
+ for (const s of sessions.values()) { const b = byName[s.name]; if (b) { b.sessions++; if (s.child?.pid) b.pids.push(s.child.pid); } }
198
+ for (const s of httpSessions.values()) { const b = byName[s.name]; if (b) { b.sessions++; b.cappedSessions++; if (s.child?.pid && !b.pids.includes(s.child.pid)) b.pids.push(s.child.pid); if (s.clientInfo && s.clientInfo.name && !b.clients.includes(s.clientInfo.name)) b.clients.push(s.clientInfo.name); } }
199
+ const prewarm = {
200
+ revision: createHash('sha256').update(lastConfigText ?? '').digest('hex'),
201
+ maxWarm: MAX_MIN_WARM, ...poolingConfig.snapshot(),
202
+ };
203
+ snapshotVersion++;
204
+ return { ok: true, service: 'mcp-pacemaker', version: VERSION, port: PORT, instanceId, snapshotVersion, startedAt: new Date(startedAt).toISOString(), prewarm, uptimeSec: Math.round((Date.now() - startedAt) / 1000), sessions: sessions.size + httpSessions.size, restart: restartStatus(), servers: Object.values(byName) };
205
+ }
206
+
207
+ function publishSnapshot() {
208
+ if (!snapClients.size) return;
209
+ const frame = `data: ${JSON.stringify(richSnapshot())}\n\n`;
210
+ for (const client of snapClients) {
211
+ try { client.write(frame); } catch { /* disconnected */ }
212
+ }
213
+ }
214
+
215
+ function prewarmingState(name) {
216
+ const def = servers[name];
217
+ let reason;
218
+ if (def.type === 'http' ||
219
+ !def.command ||
220
+ def.url) {
221
+ reason = 'HTTP proxies have no local child to pre-warm.';
222
+ } else if (def.sharing === 'shared') {
223
+ reason = 'Shared mode reuses initialized children; switch to isolated mode before enabling a warm pool.';
224
+ }
225
+ const desired = poolAdvice(name)?.suggest.minWarm ?? poolTarget(name) ?? 1;
226
+ return {
227
+ eligible: !reason,
228
+ reason,
229
+ suggestedMinWarm: Math.max(1, Math.min(MAX_MIN_WARM, desired || 1)),
230
+ configuredMinWarm: def.minWarm ?? null,
231
+ };
232
+ }
233
+
234
+ // After a restart, a client whose session the bridge is holding open for it but which has not
235
+ // come back is very likely wedged: some clients treat one connection failure as permanent and
236
+ // never retry, so the user has to reload them by hand. The bridge cannot fix that from here,
237
+ // but it can stop the user having to guess.
238
+ const RESTART_NOTICE_MS = 30 * 60_000;
239
+ // How far back a resume record still implies a live client. The resume file keeps a day of
240
+ // records, most of which belong to sessions that ended normally long before the restart.
241
+ const STALE_CLIENT_WINDOW_MS = 30 * 60_000;
242
+ function restartStatus() {
243
+ const sinceSec = Math.round((Date.now() - startedAt) / 1000);
244
+ if (sinceSec * 1000 > RESTART_NOTICE_MS) return null;
245
+ let stale = 0;
246
+ for (const [id, r] of resumable) {
247
+ // A client that was live when the bridge went down: recorded before this process started,
248
+ // not seen since, and enough time has passed that an active one would have come back.
249
+ // Bounded to recently-active records, because the resume file holds a day of churn — one
250
+ // polling client here left hundreds of dead ids, and counting all of them reported 326
251
+ // stale clients where there were three.
252
+ const recordedBeforeRestart = (r.at || 0) < startedAt;
253
+ const activeNearTheRestart = (r.at || 0) > startedAt - STALE_CLIENT_WINDOW_MS;
254
+ const hasNotComeBack = !httpSessions.has(id);
255
+ const longEnoughToJudge = Date.now() - startedAt > 120_000;
256
+ if (recordedBeforeRestart &&
257
+ activeNearTheRestart &&
258
+ hasNotComeBack &&
259
+ longEnoughToJudge) {
260
+ stale++;
261
+ }
262
+ }
263
+ return { sinceSec, resumable: resumable.size, staleClients: stale };
264
+ }
265
+
266
+ // Health checks for the dashboard Health page (fast, no network).
267
+ function runDoctor() {
268
+ const checks = [];
269
+ const major = parseInt(process.versions.node.split('.')[0], 10);
270
+ checks.push({ name: 'Node.js', status: major >= 20 ? 'ok' : 'bad', detail: process.versions.node });
271
+ const names = Object.keys(servers);
272
+ checks.push({ name: 'config', status: names.length ? 'ok' : 'warn', detail: `${names.length} server(s)` });
273
+ for (const n of names) {
274
+ const d = servers[n];
275
+ const reserved = checkReservedName(n);
276
+ if (reserved) { checks.push(reserved); continue; }
277
+ if (!d.command && !d.url) { checks.push({ name: n, status: 'bad', detail: 'missing command/url' }); continue; }
278
+ if (d.type === 'http' && !d.auth && !d.audience && !d.headers) { checks.push({ name: n, status: 'warn', detail: 'http server with no auth' }); continue; }
279
+ const pathCheck = checkServerPaths(n, d, BASE_CWD);
280
+ if (pathCheck) { checks.push(pathCheck); continue; }
281
+ const h = healthOf(n);
282
+ if (h.state === 'failing') { checks.push({ name: n, status: 'bad', detail: `failing — ${h.consecutiveFailures} in a row: ${stat(n).lastError}` }); continue; }
283
+ checks.push({ name: n, status: 'ok', detail: d.type === 'http' ? `http ${d.url}` : `stdio ${d.command}` });
284
+ }
285
+ checks.push({ name: 'admin nonce', status: existsSync(NONCE_FILE) ? 'ok' : 'warn', detail: NONCE_FILE });
286
+ return { ran: new Date().toISOString(), checks };
287
+ }
288
+
289
+ function handleApi(sub, req, res) {
290
+ if (sub === 'status') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(richSnapshot())); return; }
291
+ if (sub === 'doctor') { res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify(runDoctor())); return; }
292
+ if (sub === 'events') {
293
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' });
294
+ res.write(`data: ${JSON.stringify(richSnapshot())}\n\n`);
295
+ snapClients.add(res);
296
+ req.on('close', () => snapClients.delete(res));
297
+ return;
298
+ }
299
+ if (sub === 'logs') {
300
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' });
301
+ for (const line of logBuffer.slice(-100)) res.write(`data: ${JSON.stringify(line)}\n\n`);
302
+ logClients.add(res);
303
+ req.on('close', () => logClients.delete(res));
304
+ return;
305
+ }
306
+ res.writeHead(404).end('unknown api');
307
+ }
308
+
309
+ // Loopback + nonce guarded control surface. The nonce is written to admin.nonce next to the
310
+ // config, so only same-box tooling (the CLI, the served dashboard) can read it.
311
+ function handleAdmin(url, req, res) {
312
+ const host = (req.headers.host || '').split(':')[0];
313
+ if (host !== '127.0.0.1' && host !== 'localhost') { res.writeHead(403).end('forbidden'); return; }
314
+ if (req.headers['x-mcp-nonce'] !== ADMIN_NONCE) { res.writeHead(401).end('bad nonce'); return; }
315
+ const parts = url.pathname.split('/').filter(Boolean); // ['admin','recycle'|'reload', <name>?]
316
+ if (req.method === 'POST' &&
317
+ parts.length === 4 &&
318
+ parts[1] === 'servers' &&
319
+ parts[3] === 'pooling') {
320
+ handlePoolingChange(parts[2], req, res);
321
+ return;
322
+ }
323
+ if (req.method === 'POST' && parts[1] === 'reload') {
324
+ poolingConfig.reloadNow().then((result) => {
325
+ if (res.destroyed) return;
326
+ res.writeHead(200, { 'Content-Type': 'application/json' });
327
+ res.end(JSON.stringify({ ...result, snapshot: richSnapshot() }));
328
+ }).catch((error) => {
329
+ if (res.destroyed) return;
330
+ res.writeHead(error.statusCode ?? 500, { 'Content-Type': 'application/json' });
331
+ res.end(JSON.stringify({
332
+ ok: false, error: error.statusCode ? error.message : 'Unable to reload the saved config.',
333
+ code: error.statusCode ? error.code : 'RELOAD_FAILED',
334
+ ...(error.commitState ? { commitState: error.commitState } : {}),
335
+ snapshot: richSnapshot(),
336
+ }));
337
+ });
338
+ return;
339
+ }
340
+ if (req.method === 'POST' && parts[1] === 'recycle') {
341
+ const target = parts[2];
342
+ const killed = recycleServer(target);
343
+ log(`admin: recycled ${killed} session(s)${target ? ` for ${target}` : ''}`);
344
+ res.writeHead(200, { 'Content-Type': 'application/json' }); res.end(JSON.stringify({ ok: true, recycled: killed }));
345
+ return;
346
+ }
347
+ res.writeHead(404).end('unknown admin action');
348
+ }
349
+
350
+ function handlePoolingChange(encodedName, req, res) {
351
+ let trace;
352
+ const reply = (status, body) => {
353
+ if (res.destroyed ||
354
+ res.writableEnded) return;
355
+ trace?.record('response', { status });
356
+ res.writeHead(status, { 'Content-Type': 'application/json' });
357
+ res.end(JSON.stringify(body));
358
+ };
359
+ if (shuttingDown) {
360
+ reply(503, { error: 'Bridge is shutting down.' });
361
+ req.resume();
362
+ return;
363
+ }
364
+ const origin = req.headers.origin;
365
+ if (origin &&
366
+ origin !== `http://${req.headers.host}`) {
367
+ reply(403, { error: 'Pooling changes require the dashboard origin.' });
368
+ req.resume();
369
+ return;
370
+ }
371
+ if (req.headers['x-mcp-pooling-batch'] !== '1') {
372
+ reply(409, { error: 'Reload the dashboard or update the CLI before changing pooling settings.' });
373
+ req.resume();
374
+ return;
375
+ }
376
+ const chunks = [];
377
+ let bytes = 0;
378
+ let rejected = false;
379
+ const controller = new AbortController();
380
+ const execution = {
381
+ signal: controller.signal,
382
+ deadline: process.hrtime.bigint() + BigInt(POOLING_BUDGET_MS) * 1000000n,
383
+ };
384
+ trace = poolingTrace.operation(execution.deadline);
385
+ execution.trace = trace;
386
+ trace?.record('request-start');
387
+ const bodyTimer = setTimeout(() => {
388
+ rejected = true;
389
+ chunks.length = 0;
390
+ controller.abort();
391
+ reply(408, { error: 'Pooling request body deadline exceeded. No change was submitted.' });
392
+ }, POOLING_BUDGET_MS);
393
+ res.on('close', () => {
394
+ clearTimeout(bodyTimer);
395
+ if (!res.writableFinished) controller.abort();
396
+ });
397
+ req.on('error', () => {
398
+ clearTimeout(bodyTimer);
399
+ controller.abort();
400
+ rejected = true;
401
+ chunks.length = 0;
402
+ log('pooling request interrupted before completion');
403
+ });
404
+ req.on('data', (chunk) => {
405
+ if (rejected) return;
406
+ bytes += chunk.length;
407
+ if (bytes > 4096) {
408
+ rejected = true;
409
+ chunks.length = 0;
410
+ reply(413, { error: 'Pooling request exceeds 4096 bytes.' });
411
+ return;
412
+ }
413
+ chunks.push(chunk);
414
+ });
415
+ req.on('end', async () => {
416
+ trace?.record('body-end', { bytes });
417
+ clearTimeout(bodyTimer);
418
+ if (rejected) return;
419
+ let body;
420
+ let name;
421
+ try {
422
+ body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
423
+ name = decodeURIComponent(encodedName);
424
+ } catch {
425
+ reply(400, { error: 'Invalid JSON or server name.' });
426
+ return;
427
+ }
428
+ if (!body ||
429
+ typeof body !== 'object' ||
430
+ Array.isArray(body) ||
431
+ Object.hasOwn(body, 'name')) {
432
+ reply(400, { error: 'A pooling request object is required; the route selects the server.' });
433
+ return;
434
+ }
435
+ try {
436
+ const result = await poolingConfig.stage({ ...body, name }, execution);
437
+ log(`[${name}] pooling ${result.pending ? 'batch staged' : result.cancelled ? 'batch cancelled' : 'unchanged'}`);
438
+ reply(result.pending ? 202 : 200, result);
439
+ } catch (error) {
440
+ const status = Number.isInteger(error.statusCode) ? error.statusCode : 500;
441
+ log(`pooling action rejected (${error.code || 'IO_ERROR'})`);
442
+ reply(status, {
443
+ error: error.statusCode ? error.message : 'Unable to stage pooling settings.',
444
+ ...(error.commitState ? { commitState: error.commitState } : {}),
445
+ });
446
+ }
447
+ });
448
+ }
449
+
450
+ /* ------------------------------ static dashboard ---------------------------- */
451
+ const UI_DIR = resolve(__dirname, '..', 'ui', 'dist');
452
+ const UI_MIME = { '.html': 'text/html; charset=utf-8', '.js': 'text/javascript; charset=utf-8', '.css': 'text/css; charset=utf-8', '.json': 'application/json', '.svg': 'image/svg+xml', '.ico': 'image/x-icon', '.png': 'image/png', '.woff2': 'font/woff2', '.map': 'application/json' };
453
+ function handleUi(pathname, res) {
454
+ let rel = pathname.replace(/^\/ui\/?/, '');
455
+ if (rel === '') rel = 'index.html';
456
+ let file = resolve(UI_DIR, rel);
457
+ // path-traversal guard + SPA fallback to index.html
458
+ if (!file.startsWith(UI_DIR) || !existsSync(file)) file = resolve(UI_DIR, 'index.html');
459
+ if (!existsSync(file)) { res.writeHead(404).end('dashboard not built (run: npm --prefix ui run build)'); return; }
460
+ const ext = extname(file);
461
+ if (ext === '.html') { res.writeHead(200, { 'Content-Type': UI_MIME['.html'] }); res.end(readFileSync(file, 'utf8').replace('__MCP_NONCE__', ADMIN_NONCE)); return; }
462
+ res.writeHead(200, { 'Content-Type': UI_MIME[ext] ?? 'application/octet-stream' });
463
+ res.end(readFileSync(file));
464
+ }
465
+
466
+ // Push snapshots to /api/events subscribers on an interval.
467
+ setInterval(publishSnapshot, 2000).unref();
468
+
469
+ /* ------------------------------ pluggable auth ------------------------------ */
470
+ const tokenCache = new Map(); // key -> { value, exp }
471
+ // key -> in-flight mint promise. On startup every server asks for its token at once; without
472
+ // this, each one spawns its own `az` and the concurrent calls are what make az exit 0 with no
473
+ // output. One mint per key, shared by all waiters.
474
+ const tokenInFlight = new Map();
475
+
476
+ // exec (not execFile with an explicit shell) so the command line is passed to the shell
477
+ // verbatim. Building `cmd /c <command>` by hand re-escapes any embedded quotes, which breaks
478
+ // every auth command that quotes an argument — a path containing spaces, for instance.
479
+ function runCommand(cmd) {
480
+ return new Promise((res, rej) => {
481
+ exec(cmd, { maxBuffer: 8 * 1024 * 1024, windowsHide: true },
482
+ (err, stdout) => (err ? rej(err) : res(stdout.toString().trim())));
483
+ });
484
+ }
485
+
486
+ // How long a minted credential can be trusted.
487
+ //
488
+ // `refreshMinutes` is only a fallback. An auth command usually returns a JWT, and the command
489
+ // often serves it from the provider's OWN cache — `az account get-access-token` hands back a
490
+ // token it minted earlier, which may be most of the way through its life already. Caching that
491
+ // for a fixed window serves an expired credential until the window is up, and every request in
492
+ // between comes back 401 while the bridge reports a healthy token.
493
+ //
494
+ // So prefer the token's own `exp` claim when there is one. The payload is only read, never
495
+ // trusted for authorization, so parsing it unverified is safe here: the upstream is what
496
+ // validates the signature. Anything unparseable (an opaque token, an API key) falls back to
497
+ // `refreshMinutes`. A safety margin covers clock skew and the request still in flight.
498
+ const TOKEN_EXPIRY_MARGIN_MS = 60_000;
499
+ function tokenLifetimeMs(value, fallbackMs) {
500
+ const parts = String(value).split('.');
501
+ if (parts.length !== 3) return fallbackMs; // not a JWT
502
+ try {
503
+ const pad = '='.repeat((4 - (parts[1].length % 4)) % 4);
504
+ const payload = JSON.parse(Buffer.from(parts[1].replace(/-/g, '+').replace(/_/g, '/') + pad, 'base64').toString('utf8'));
505
+ if (typeof payload.exp !== 'number') return fallbackMs;
506
+ const remaining = payload.exp * 1000 - Date.now() - TOKEN_EXPIRY_MARGIN_MS;
507
+ // A token already at or past its expiry is still returned to the caller — the upstream is
508
+ // the authority on that — but it must not be cached.
509
+ return remaining > 0 ? Math.min(remaining, fallbackMs) : 0;
510
+ } catch { return fallbackMs; }
511
+ }
512
+
513
+ // Drop a server's cached credential so the next request mints a fresh one. Used when the
514
+ // upstream rejects it: continuing to serve a credential known to be refused just repeats the
515
+ // failure for the rest of its cache window.
516
+ function invalidateToken(def) {
517
+ const key = authCacheKey(def);
518
+ if (key) tokenCache.delete(key);
519
+ }
520
+
521
+ // Normalize a server's auth config into { header, getValue() } or null.
522
+ function resolveAuth(def) {
523
+ const auth = normalizedAuth(def);
524
+ if (!auth || auth.type === 'none') return null;
525
+
526
+ const header = auth.header || 'Authorization';
527
+ const prefix = auth.prefix != null ? auth.prefix : (header.toLowerCase() === 'authorization' ? 'Bearer ' : '');
528
+
529
+ if (auth.type === 'static') return { header, getValue: async () => prefix + (auth.value ?? '') };
530
+ if (auth.type === 'env') {
531
+ return { header, getValue: async () => {
532
+ const v = process.env[auth.var];
533
+ if (!v) throw new Error(`env var ${auth.var} is not set`);
534
+ return prefix + v;
535
+ } };
536
+ }
537
+ if (auth.type === 'command') {
538
+ const key = auth.command;
539
+ const ttl = (auth.refreshMinutes ?? 50) * 60 * 1000;
540
+ return { header, getValue: async () => {
541
+ const c = tokenCache.get(key);
542
+ if (c && c.exp > Date.now()) return prefix + c.value;
543
+ const pending = tokenInFlight.get(key);
544
+ if (pending) return prefix + (await pending);
545
+ const mint = (async () => {
546
+ const value = await runCommand(auth.command);
547
+ // An auth command can exit 0 and print nothing. Caching that would serve an empty
548
+ // credential for the whole TTL, so every upstream request 401s until it expires.
549
+ if (!value) throw new Error(`auth command returned an empty value: ${auth.command}`);
550
+ const life = tokenLifetimeMs(value, ttl);
551
+ if (life > 0) tokenCache.set(key, { value, exp: Date.now() + life });
552
+ else tokenCache.delete(key); // already expired on arrival — re-mint next time
553
+ return value;
554
+ })();
555
+ const tracked = mint.finally(() => tokenInFlight.delete(key));
556
+ tokenInFlight.set(key, tracked);
557
+ return prefix + (await tracked);
558
+ } };
559
+ }
560
+ throw new Error(`unknown auth.type "${auth.type}"`);
561
+ }
562
+
563
+ /* ---------------------- stdio servers (HTTP+SSE transport) ------------------ */
564
+ const sessions = new Map(); // sessionId -> { child, res, name }
565
+
566
+ // Quote one argument for a Windows `cmd /d /s /c` line using MSVCRT / CommandLineToArgvW
567
+ // rules, wrapping in double quotes to also neutralize cmd metacharacters. (`%` and `!` can't
568
+ // be escaped inside `cmd /c`; server commands come from the user's own trusted config.)
569
+ function quoteWinArg(s) {
570
+ s = String(s);
571
+ if (s !== '' && !/[\s"&|<>()^%!]/.test(s)) return s;
572
+ let out = '"';
573
+ let bs = 0;
574
+ for (const c of s) {
575
+ if (c === '\\') { bs++; }
576
+ else if (c === '"') { out += '\\'.repeat(bs * 2 + 1) + '"'; bs = 0; }
577
+ else { out += '\\'.repeat(bs) + c; bs = 0; }
578
+ }
579
+ return out + '\\'.repeat(bs * 2) + '"';
580
+ }
581
+
582
+ // Spawn a stdio server child. Bare Windows command names (npx/uvx/dnx `.cmd` shims) must run
583
+ // through cmd.exe; we build the command line ourselves and pass windowsVerbatimArguments so
584
+ // Node doesn't re-quote it. This avoids `shell:true` (and its DEP0190 deprecation + arg-
585
+ // injection footgun) while still resolving `.cmd` shims via PATHEXT. Absolute/`.exe` paths
586
+ // and every non-Windows platform spawn the command directly with shell disabled.
587
+ function spawnServer(name, def) {
588
+ const args = def.args ?? [];
589
+ const opts = {
590
+ cwd: def.cwd ? resolve(BASE_CWD, def.cwd) : BASE_CWD,
591
+ env: { ...process.env, ...(def.env ?? {}) },
592
+ stdio: ['pipe', 'pipe', 'pipe'],
593
+ };
594
+ const bareWin = process.platform === 'win32' && !/[\\/]/.test(def.command) && !/\.(exe|com)$/i.test(def.command);
595
+ serverMetrics.attemptingSpawn(name);
596
+ let child;
597
+ try {
598
+ child = bareWin
599
+ ? spawn(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', '"' + [def.command, ...args].map(quoteWinArg).join(' ') + '"'], { ...opts, windowsVerbatimArguments: true })
600
+ : spawn(def.command, args, opts);
601
+ } catch (error) {
602
+ serverMetrics.failedSpawn(name);
603
+ throw error;
604
+ }
605
+ let spawned = false;
606
+ child.once('spawn', () => {
607
+ spawned = true;
608
+ serverMetrics.spawned(name);
609
+ });
610
+ child.once('error', () => {
611
+ if (!spawned) serverMetrics.failedSpawn(name);
612
+ });
613
+ child.__spawnedAt = Date.now(); // start of the cold-start clock; stopped when initialize lands
614
+ return child;
615
+ }
616
+
617
+ /* --------------------------- child failure reporting ------------------------ */
618
+ // Remember the tail of a child's stderr so a non-zero exit can be reported with its reason.
619
+ // Without this, a server that starts and then crashes leaves lastError empty: /api/status and
620
+ // `doctor` look healthy while every session dies on spawn.
621
+ const stderrTails = new Map(); // child -> recent stderr text
622
+ function pipeStderr(name, child) {
623
+ child.stderr.on('data', (d) => {
624
+ const s = d.toString();
625
+ stderrTails.set(child, ((stderrTails.get(child) ?? '') + s).slice(-2000));
626
+ log(`[${name}] (stderr) ${s.trimEnd()}`);
627
+ });
628
+ }
629
+ function noteExit(name, child, code) {
630
+ // Only an exit we did not cause is a failure worth reporting.
631
+ if (code && !child.__bridgeKilled) {
632
+ const tail = (stderrTails.get(child) ?? '').trim().split(/\r?\n/).filter(Boolean).slice(-3).join(' | ');
633
+ noteFailure(name, `exited code ${code}${tail ? `: ${tail}` : ''}`);
634
+ }
635
+ stderrTails.delete(child);
636
+ }
637
+
638
+ // Terminate a server child and everything it spawned.
639
+ //
640
+ // On Windows a bare command runs under a cmd.exe wrapper (see spawnServer), and child.kill()
641
+ // signals only that wrapper — the real server process survives, keeps its port/token, and is
642
+ // reparented. That orphans a process on every recycle, idle reap, session close and shutdown.
643
+ // `taskkill /T` takes down the whole tree instead. POSIX spawns the command directly, so
644
+ // kill() already targets the server itself.
645
+ function killTree(child, sync = false) {
646
+ if (!child) return;
647
+ if (child.__sharedSession) {
648
+ child.detach();
649
+ return;
650
+ }
651
+ if (child.exitCode != null ||
652
+ child.signalCode != null) return;
653
+ // Teardown we initiated. taskkill /F reports exit code 1, so without this every recycle,
654
+ // idle reap and session close would be recorded as a crash.
655
+ child.__bridgeKilled = true;
656
+ const pid = child.pid;
657
+ if (process.platform !== 'win32' || !pid) {
658
+ try { child.kill(); } catch { /* already gone */ }
659
+ return;
660
+ }
661
+ const args = ['/PID', String(pid), '/T', '/F'];
662
+ // Shutdown exits the process immediately, so it can't wait on an async callback.
663
+ if (sync) {
664
+ try { execFileSync('taskkill', args, { stdio: 'ignore' }); } catch { /* already gone */ }
665
+ return;
666
+ }
667
+ execFile('taskkill', args, () => {
668
+ // taskkill exits non-zero when the tree is already gone; kill() is a harmless backstop
669
+ // for the case where taskkill itself is unavailable.
670
+ try { child.kill(); } catch { /* already gone */ }
671
+ });
672
+ }
673
+
674
+ function startChild(name, res) {
675
+ const def = servers[name];
676
+ const sessionId = randomUUID();
677
+ const child = spawnServer(name, def);
678
+ serverMetrics.openedSession(name);
679
+ sessions.set(sessionId, { child, res, name });
680
+ log(`[${name}] session ${sessionId.slice(0, 8)} started (pid ${child.pid ?? '?'})`);
681
+
682
+ const rl = createInterface({ input: child.stdout });
683
+ rl.on('line', (line) => {
684
+ const t = line.trim();
685
+ if (!t) return;
686
+ try { JSON.parse(t); } catch { log(`[${name}] (non-json stdout) ${t}`); return; }
687
+ noteSuccess(name); // the classic transport is fire-and-forget, so a reply is the only signal
688
+ try { res.write(`event: message\ndata: ${t}\n\n`); } catch { /* client gone */ }
689
+ });
690
+ pipeStderr(name, child);
691
+ child.on('error', (e) => { noteFailure(name, e.message); log(`[${name}] spawn error: ${e.message}`); });
692
+ child.on('exit', (code) => {
693
+ noteExit(name, child, code);
694
+ log(`[${name}] session ${sessionId.slice(0, 8)} exited (code ${code})`);
695
+ try { res.end(); } catch { /* noop */ }
696
+ sessions.delete(sessionId);
697
+ });
698
+ return sessionId;
699
+ }
700
+
701
+ /* ---------------- stdio servers (Streamable HTTP transport) ------------------ */
702
+ // Newer MCP clients speak Streamable HTTP: POST /<name>/mcp for JSON-RPC, GET for a
703
+ // server->client SSE stream, DELETE to end. Unlike the SSE transport, one child persists
704
+ // ACROSS posts (keyed by Mcp-Session-Id), so we route responses back by JSON-RPC id.
705
+ const httpSessions = new Map(); // sessionId -> { child, name, pending, sseRes, lastActivity, clientInfo }
706
+ const bridgeReplies = new WeakMap();
707
+ function bridgeReply(id, code, message, kind) {
708
+ const reply = { jsonrpc: '2.0', id, error: { code, message } };
709
+ bridgeReplies.set(reply, kind);
710
+ return reply;
711
+ }
712
+
713
+ // How long the bridge waits for an upstream JSON-RPC response. `initialize` is separate
714
+ // because it also pays for spawning the server: a cold start that downloads the server on
715
+ // first run can take minutes, while a steady-state call that slow is a hang.
716
+ const REQUEST_TIMEOUT_MS = parseInt(process.env.MCP_REQUEST_TIMEOUT_MS || '30000', 10);
717
+ const INIT_TIMEOUT_MS = parseInt(process.env.MCP_INIT_TIMEOUT_MS || '180000', 10);
718
+
719
+ // Idle reaper: kill Streamable HTTP children idle longer than MCP_IDLE_TIMEOUT_MS (0 = off).
720
+ // On by default. Clients are not obliged to send DELETE, and some never do — one observed
721
+ // client opened a session per run and left 241 servers running in an hour. A service that
722
+ // stays up for weeks cannot rely on clients tidying up after themselves. Reaping is safe here
723
+ // because a reaped session is transparently re-established if that client comes back, so the
724
+ // only cost of reaping too eagerly is one server restart.
725
+ const IDLE_TIMEOUT_MS = parseInt(process.env.MCP_IDLE_TIMEOUT_MS || String(30 * 60 * 1000), 10);
726
+ // Optional per-server concurrency cap (0 = unlimited). Env default; per-server def.maxSessions overrides.
727
+ const MAX_SESSIONS_PER_SERVER = parseInt(process.env.MCP_MAX_SESSIONS_PER_SERVER || '0', 10);
728
+ // When at the cap, wait this long for a slot before giving up. A cap alone turns a burst into
729
+ // failed requests; a short wait absorbs the burst, while still refusing rather than queueing
730
+ // without limit. 0 disables waiting and rejects immediately.
731
+ const QUEUE_TIMEOUT_MS = parseInt(process.env.MCP_QUEUE_TIMEOUT_MS || '10000', 10);
732
+ const sessionCount = (name) => { let n = 0; for (const s of httpSessions.values()) if (s.name === name) n++; return n; };
733
+ const startingSessions = new Map();
734
+
735
+ function reserveSession(name, cap) {
736
+ const starting = startingSessions.get(name) || 0;
737
+ if (sessionCount(name) + starting >= cap) return false;
738
+ startingSessions.set(name, starting + 1);
739
+ return true;
740
+ }
741
+
742
+ function releaseSessionReservation(name) {
743
+ const remaining = (startingSessions.get(name) || 0) - 1;
744
+ if (remaining > 0) startingSessions.set(name, remaining);
745
+ else startingSessions.delete(name);
746
+ slotFreed(name);
747
+ }
748
+
749
+ // Resolve when a session slot is free for `name`, or false if the wait ran out.
750
+ const waiters = new Map(); // name -> [resolve, ...]
751
+ function slotFreed(name) {
752
+ const q = waiters.get(name);
753
+ if (q &&
754
+ q.length) q[0](true);
755
+ }
756
+ function awaitSlot(name, cap) {
757
+ if (reserveSession(name, cap)) return Promise.resolve(true);
758
+ if (QUEUE_TIMEOUT_MS <= 0) return Promise.resolve(false);
759
+ return new Promise((res) => {
760
+ if (!waiters.has(name)) waiters.set(name, []);
761
+ const q = waiters.get(name);
762
+ const done = (ok) => {
763
+ if (ok &&
764
+ !reserveSession(name, cap)) return;
765
+ const i = q.indexOf(done);
766
+ if (i >= 0) q.splice(i, 1);
767
+ clearTimeout(timer);
768
+ res(ok);
769
+ };
770
+ const timer = setTimeout(() => done(false), QUEUE_TIMEOUT_MS);
771
+ q.push(done);
772
+ });
773
+ }
774
+ if (IDLE_TIMEOUT_MS > 0) {
775
+ setInterval(() => {
776
+ const now = Date.now();
777
+ for (const [sid, s] of httpSessions) {
778
+ // A session with a request in flight is working, not idle. `lastActivity` is stamped when
779
+ // a request arrives, so a call that runs longer than the timeout would otherwise have its
780
+ // server killed mid-request and the caller left waiting for a reply that can never come.
781
+ if (s.pending.size > 0) continue;
782
+ if (s.lastActivity && now - s.lastActivity > IDLE_TIMEOUT_MS) {
783
+ log(`[${s.name}] reaping idle session ${sid.slice(0, 8)} (idle ${Math.round((now - s.lastActivity) / 1000)}s)`);
784
+ killTree(s.child);
785
+ httpSessions.delete(sid);
786
+ }
787
+ }
788
+ }, Math.min(IDLE_TIMEOUT_MS, 15000)).unref();
789
+ }
790
+
791
+ function writeSessionMessage(session, message) {
792
+ if (session.child.__sharedSession) {
793
+ session.child.writeMessage(message);
794
+ } else {
795
+ session.child.stdin.write(JSON.stringify(message) + '\n');
796
+ }
797
+ }
798
+
799
+ function writeSharedEvent(session, response, message) {
800
+ if (response.destroyed) return false;
801
+ const frame = `event: message\ndata: ${JSON.stringify(message)}\n\n`;
802
+ if (response.writableLength + Buffer.byteLength(frame) > session.sharedBufferLimit) {
803
+ log(`[${session.name}] shared response stream exceeded its buffer limit`);
804
+ response.destroy();
805
+ return false;
806
+ }
807
+ response.write(frame);
808
+ return true;
809
+ }
810
+
811
+ function startStreamableChild(name, warmChild, forcedSessionId) {
812
+ const def = servers[name];
813
+ const sessionId = forcedSessionId || randomUUID();
814
+ const child = warmChild || spawnServer(name, def);
815
+ serverMetrics.openedSession(name, Boolean(forcedSessionId));
816
+ const session = { child, name, pending: new Map(), sseRes: null, lastActivity: Date.now(), clientInfo: null };
817
+ session.sharedBufferLimit = def.sharedMaxBufferBytes ?? SHARED_DEFAULTS.sharedMaxBufferBytes;
818
+ httpSessions.set(sessionId, session);
819
+ const rl = createInterface({ input: child.stdout });
820
+ rl.on('line', (line) => {
821
+ const t = line.trim();
822
+ if (!t) return;
823
+ let msg;
824
+ try { msg = JSON.parse(t); } catch { log(`[${name}] (non-json stdout) ${t}`); return; }
825
+ if (!msg ||
826
+ typeof msg !== 'object') {
827
+ log(`[${name}] ignored invalid upstream message`);
828
+ return;
829
+ }
830
+ const id = msg.method === undefined ? msg.id : undefined;
831
+ const p = id != null ? session.pending.get(id) : undefined;
832
+ if (p) { session.pending.delete(id); clearTimeout(p.timer); p.resolve(msg); }
833
+ else if (session.sseRes) {
834
+ if (child.__sharedSession) {
835
+ if (msg.method !== undefined) writeSharedEvent(session, session.sseRes, msg);
836
+ } else {
837
+ try { session.sseRes.write(`event: message\ndata: ${t}\n\n`); } catch { /* noop */ }
838
+ }
839
+ }
840
+ });
841
+ if (!warmChild) pipeStderr(name, child);
842
+ if (child.__sharedSession) {
843
+ child.on('sharedFailure', (detail) => { session.sharedFailure = detail; });
844
+ child.on('sharedProgress', ({ requestId, message }) => {
845
+ const pending = session.pending.get(requestId);
846
+ if (!pending?.stream ||
847
+ pending.stream.destroyed) return;
848
+ writeSharedEvent(session, pending.stream, message);
849
+ });
850
+ }
851
+ child.on('error', (e) => { noteFailure(name, e.message); log(`[${name}] spawn error: ${e.message}`); });
852
+ child.on('exit', (code) => {
853
+ if (!child.__sharedSession) noteExit(name, child, code);
854
+ log(`[${name}] streamable session ${sessionId.slice(0, 8)} exited (code ${code})`);
855
+ // A child that dies with requests in flight must fail them now. Nothing did, so the caller
856
+ // waited out the full budget — 180s for an initialize — for an answer that could never come.
857
+ // A config reload recycling a server mid-request hits this exactly, and it surfaced as a
858
+ // three-minute CI hang rather than as the immediate failure it is.
859
+ for (const [id, p] of session.pending) {
860
+ clearTimeout(p.timer);
861
+ const reply = bridgeReply(id, -32000, `bridge: server exited (code ${code}) before answering`, 'exit');
862
+ if (session.sharedFailure) {
863
+ reply.error.data = { ...session.sharedFailure, phase: 'execution', execution: 'potentially-executed' };
864
+ }
865
+ p.resolve(reply);
866
+ }
867
+ session.pending.clear();
868
+ if (session.sseRes) { try { session.sseRes.end(); } catch { /* noop */ } }
869
+ // The same session id may already have been re-established on a fresh child by the time this
870
+ // fires: `taskkill /F /T` takes about a second on Windows, so a reap followed by a resume
871
+ // lands the replacement first. Only drop the mapping if it still points at this child, or a
872
+ // late exit tears down the session that replaced it.
873
+ if (httpSessions.get(sessionId) === session) httpSessions.delete(sessionId);
874
+ slotFreed(name); // a capped server may have a request waiting for this slot
875
+ });
876
+ log(`[${name}] streamable session ${sessionId.slice(0, 8)} ${child.__sharedSession ? 'attached to shared child' : 'started'} (pid ${child.pid ?? '?'})`);
877
+ return sessionId;
878
+ }
879
+
880
+ /* ------------------- cold-start cost, concurrency, spawn gating ------------- */
881
+ // The bridge spawns every child, so it is the only component positioned to know what a cold
882
+ // start actually costs — and it was discarding that. Without it there is no way to tell a server
883
+ // that starts in 200ms from one that shells out to a package manager and takes 20s, which is why
884
+ // the pool ended up full for a fast server and empty for the slow one that needed it.
885
+ function noteSpawnCost(name, ms) {
886
+ serverMetrics.initialized(name, ms);
887
+ }
888
+ function spawnStats(name) {
889
+ if (servers[name]?.type === 'http') return null;
890
+ return serverMetrics.snapshot(name);
891
+ }
892
+
893
+ // Rolling peak concurrency, so a pool can be sized from what a server is actually asked to do
894
+ // rather than from a number the user had to guess. Bucketed by 5 minutes over the last hour: a
895
+ // single burst should still size the pool, but yesterday's burst should not hold it open forever.
896
+ const CONCURRENCY_BUCKET_MS = 5 * 60_000;
897
+ const CONCURRENCY_BUCKETS = 12;
898
+ const concurrency = new Map(); // name -> [{ slot, peak }, ...]
899
+ function noteConcurrency(name, n) {
900
+ const slot = Math.floor(Date.now() / CONCURRENCY_BUCKET_MS);
901
+ const arr = concurrency.get(name) || [];
902
+ if (arr[0] && arr[0].slot === slot) arr[0].peak = Math.max(arr[0].peak, n);
903
+ else arr.unshift({ slot, peak: n });
904
+ if (arr.length > CONCURRENCY_BUCKETS) arr.length = CONCURRENCY_BUCKETS;
905
+ concurrency.set(name, arr);
906
+ }
907
+ const peakConcurrency = (name) => {
908
+ const arr = concurrency.get(name);
909
+ return arr && arr.length ? Math.max(...arr.map((b) => b.peak)) : 0;
910
+ };
911
+
912
+ // Running many package-manager-backed servers at once is how a shared npm/uv cache gets
913
+ // corrupted: 17 simultaneous `npx` invocations on one machine produced
914
+ // `npm error code ECOMPROMISED / Lock compromised`, and every one of those sessions failed. No
915
+ // server definition can fix that — it is a property of the concurrency, not of any one command —
916
+ // so the bridge has to own it. Global rather than per-server, because the cache is shared across
917
+ // servers.
918
+ //
919
+ // Two limits on how far this is allowed to go, both learned by getting it wrong:
920
+ //
921
+ // 1. Only commands that actually go through a package manager are gated. A plain `node server.js`
922
+ // shares no cache and was never at risk, and gating it put a global semaphore in front of every
923
+ // cold start — including a test that opens 60 sessions in a loop, which then took the full
924
+ // 180s init budget and failed on CI.
925
+ // 2. Waiting for a slot is bounded. If one does not come free in time the spawn proceeds anyway:
926
+ // a corrupted cache is a risk, but hanging a client request is a certainty, and the whole
927
+ // point of this bridge is that a client request does not hang.
928
+ const MAX_CONCURRENT_SPAWNS = Number(process.env.MCP_MAX_CONCURRENT_SPAWNS ?? 2);
929
+ const SPAWN_GATE_WAIT_MS = Number(process.env.MCP_SPAWN_GATE_WAIT_MS ?? 15_000);
930
+ const PACKAGE_RUNNERS = /(^|[\\/])(npx|pnpx|bunx|uvx|pipx|dnx)(\.cmd|\.exe|\.ps1)?$/i;
931
+ function usesSharedPackageCache(def) {
932
+ if (!def || !def.command) return false;
933
+ // Explicit wins: a wrapper script that shells out to a package manager is invisible to the
934
+ // heuristic below, and its author is the only one who knows.
935
+ if (typeof def.sharedPackageCache === 'boolean') return def.sharedPackageCache;
936
+ if (PACKAGE_RUNNERS.test(def.command)) return true;
937
+ // `npm exec`, `pnpm dlx`, `yarn dlx`, `uv run` — the runner is the first argument.
938
+ const first = (def.args && def.args[0]) || '';
939
+ return /^(npm|pnpm|yarn|uv|pip)$/i.test(def.command) && /^(exec|dlx|run|x)$/i.test(first);
940
+ }
941
+
942
+ const spawnGate = { active: 0, queue: [] };
943
+ // One release per acquisition, idempotent, so a double call cannot inflate the pool and a
944
+ // throw between acquire and release cannot strand a slot forever.
945
+ function makeRelease() {
946
+ let done = false;
947
+ return () => {
948
+ if (done) return;
949
+ done = true;
950
+ const next = spawnGate.queue.shift();
951
+ if (next) next(); // hand the slot straight over rather than dropping and re-taking it
952
+ else spawnGate.active = Math.max(0, spawnGate.active - 1);
953
+ };
954
+ }
955
+ const NOOP_RELEASE = () => {};
956
+ function acquireSpawn() {
957
+ if (MAX_CONCURRENT_SPAWNS <= 0) return Promise.resolve(NOOP_RELEASE);
958
+ if (spawnGate.active < MAX_CONCURRENT_SPAWNS) {
959
+ spawnGate.active++;
960
+ return Promise.resolve(makeRelease());
961
+ }
962
+ return new Promise((resolve) => {
963
+ let settled = false;
964
+ const waiter = () => {
965
+ if (settled) return;
966
+ settled = true;
967
+ clearTimeout(timer);
968
+ resolve(makeRelease());
969
+ };
970
+ spawnGate.queue.push(waiter);
971
+ const timer = setTimeout(() => {
972
+ if (settled) return;
973
+ settled = true;
974
+ const i = spawnGate.queue.indexOf(waiter);
975
+ if (i >= 0) spawnGate.queue.splice(i, 1);
976
+ resolve(NOOP_RELEASE); // proceed ungated rather than hold the caller any longer
977
+ }, SPAWN_GATE_WAIT_MS);
978
+ timer.unref?.();
979
+ });
980
+ }
981
+
982
+ // Pooling stays opt-in: the bridge says what it would cost and what it would size to, and the
983
+ // user decides. Turning it on automatically would spend a resident process per warm slot on
984
+ // someone's machine without asking.
985
+ const POOL_ADVICE_MS = Number(process.env.MCP_POOL_ADVICE_MS ?? 2000);
986
+ const WARM_MAX_PER_SERVER = Number(process.env.MCP_WARM_MAX ?? 8);
987
+ function poolAdvice(name) {
988
+ const def = servers[name];
989
+ if (!def ||
990
+ def.sharing === 'pool' ||
991
+ def.sharing === 'shared' ||
992
+ def.type === 'http') {
993
+ return null;
994
+ }
995
+ const st = spawnStats(name);
996
+ const enoughSamples = st && st.samples >= 3;
997
+ if (!enoughSamples ||
998
+ st.p50Ms < POOL_ADVICE_MS) {
999
+ return null;
1000
+ }
1001
+ return {
1002
+ reason: `cold start p50 ${(st.p50Ms / 1000).toFixed(1)}s over ${st.samples} spawns`,
1003
+ suggest: { sharing: 'pool', minWarm: Math.max(1, Math.min(peakConcurrency(name) || 1, WARM_MAX_PER_SERVER)) },
1004
+ };
1005
+ }
1006
+
1007
+ /* --------------------- keep-warm pool (sharing: "pool") --------------------- */
1008
+ // For servers marked sharing:"pool", keep `minWarm` pre-spawned (un-initialized) children ready
1009
+ // so a new session adopts a warm child (process already started + imports loaded) instead of a
1010
+ // cold spawn. The warm child receives its FIRST initialize from the real client (safe — no
1011
+ // re-initialize). Shared mode instead retains one initialized child.
1012
+ const warmPool = new Map(); // name -> [child, ...]
1013
+ const warmPending = new Map(); // name -> spawns in flight, so a refill pass cannot double-order
1014
+ // Explicit minWarm always wins. Without one, size from observed peak concurrency — the number a
1015
+ // user cannot reasonably know and the bridge measures for free. Opting into pooling and still
1016
+ // getting a pool of one is how a burst of 17 sessions ended up with 16 cold starts.
1017
+ const poolTarget = (name) => {
1018
+ const def = servers[name];
1019
+ if (!def ||
1020
+ def.sharing !== 'pool') {
1021
+ return 0;
1022
+ }
1023
+ if (def.minWarm != null) return def.minWarm;
1024
+ return Math.max(1, Math.min(peakConcurrency(name) || 1, WARM_MAX_PER_SERVER));
1025
+ };
1026
+ // Hold a spawn-gate slot until the child looks alive rather than until spawn() returns: the cost
1027
+ // being serialized is the package manager's, which happens after the process exists and before
1028
+ // it says anything. First output is the cheapest available proxy for "the expensive part is
1029
+ // over"; the timeout stops a silent child from wedging the gate.
1030
+ function releaseOnReady(child, release, maxMs = 30_000) {
1031
+ let done = false;
1032
+ const rel = () => {
1033
+ if (done) return;
1034
+ done = true;
1035
+ clearTimeout(timer);
1036
+ release();
1037
+ };
1038
+ const timer = setTimeout(rel, maxMs);
1039
+ timer.unref?.();
1040
+ child.stdout?.once('data', rel);
1041
+ child.stderr?.once('data', rel);
1042
+ child.once('exit', rel);
1043
+ }
1044
+
1045
+ const sharedManager = new SharedSessionManager({
1046
+ async spawn(name, definition) {
1047
+ const release = usesSharedPackageCache(definition) ? await acquireSpawn() : NOOP_RELEASE;
1048
+ try {
1049
+ if (shuttingDown ||
1050
+ sharedManager.inspect(name)?.state !== 'starting') {
1051
+ throw sharedManager.error('SHARED_RETIRED', 'spawn', 'unsent');
1052
+ }
1053
+ const child = spawnServer(name, definition);
1054
+ pipeStderr(name, child);
1055
+ releaseOnReady(child, release);
1056
+ child.once('close', () => stderrTails.delete(child));
1057
+ return child;
1058
+ } catch (error) {
1059
+ release();
1060
+ throw error;
1061
+ }
1062
+ },
1063
+ kill: (child) => killTree(child, shuttingDown),
1064
+ onFailure: (name, detail) => noteFailure(name, `shared: ${detail.kind}`),
1065
+ onInitialized: (name, ms) => { noteSpawnCost(name, ms); noteSuccess(name); },
1066
+ onResponse: (name) => noteSuccess(name),
1067
+ log: (name, text) => log(`[${name}] ${text}`),
1068
+ initTimeoutMs: INIT_TIMEOUT_MS,
1069
+ requestTimeoutMs: REQUEST_TIMEOUT_MS,
1070
+ });
1071
+ sharedManager.on('draining', ({ name, generation, reason }) => log(`[${name}] shared generation ${generation} draining (${reason})`));
1072
+ sharedManager.on('retired', ({ name, generation, reason }) => log(`[${name}] shared generation ${generation} retired (${reason})`));
1073
+
1074
+ async function startAdmittedSession(name, initializeParams, forcedSessionId, stillWanted) {
1075
+ const definition = servers[name];
1076
+ if (!definition) throw Object.assign(new Error('server was removed'), { statusCode: 404 });
1077
+ const cap = definition.maxSessions ?? MAX_SESSIONS_PER_SERVER;
1078
+ if (cap > 0 &&
1079
+ !(await awaitSlot(name, cap))) {
1080
+ noteFailure(name, `bridge refused a session: max sessions (${cap}) reached`);
1081
+ throw Object.assign(new Error(`bridge: max sessions (${cap}) reached for ${name}`), { statusCode: 503 });
1082
+ }
1083
+ let candidate = null;
1084
+ let release = NOOP_RELEASE;
1085
+ try {
1086
+ if (shuttingDown ||
1087
+ servers[name] !== definition) {
1088
+ throw Object.assign(new Error('server configuration changed while waiting for capacity; retry'), { statusCode: 409 });
1089
+ }
1090
+ if (!stillWanted()) return null;
1091
+ if (definition.sharing === 'shared') {
1092
+ candidate = await sharedManager.acquire(name, definition, initializeParams);
1093
+ } else {
1094
+ candidate = takeWarm(name);
1095
+ if (!candidate &&
1096
+ usesSharedPackageCache(definition)) release = await acquireSpawn();
1097
+ }
1098
+ if (shuttingDown ||
1099
+ servers[name] !== definition) {
1100
+ if (candidate) killTree(candidate);
1101
+ throw Object.assign(new Error('server configuration changed while starting; retry'), { statusCode: 409 });
1102
+ }
1103
+ if (!stillWanted()) {
1104
+ if (candidate) killTree(candidate);
1105
+ return null;
1106
+ }
1107
+ const id = startStreamableChild(name, candidate, forcedSessionId);
1108
+ const session = httpSessions.get(id);
1109
+ session.cold = !candidate;
1110
+ releaseOnReady(session.child, release);
1111
+ release = NOOP_RELEASE;
1112
+ noteConcurrency(name, sessionCount(name));
1113
+ return { id, session };
1114
+ } finally {
1115
+ release();
1116
+ if (cap > 0) releaseSessionReservation(name);
1117
+ }
1118
+ }
1119
+ // A warm child can die on its own: a server that self-exits when idle, a crash, a laptop sleep.
1120
+ // The exit handler used to remove the corpse and stop there, and nothing else refilled — the pool
1121
+ // only grew on boot, on take, on recycle and on reload. So a pooled server silently degraded to
1122
+ // cold-start-every-session, which is the exact failure pooling exists to prevent, and it looked
1123
+ // like nothing was wrong because an empty pool is indistinguishable from one that has simply not
1124
+ // been asked for anything yet. Seen on ev2: warm sat at 0 for three hours while kusto stayed full
1125
+ // purely because kusto is taken often enough that every take triggered a refill.
1126
+ //
1127
+ // Refilling on an unattended exit needs a brake, or a child that dies instantly becomes a spawn
1128
+ // loop. Children that die quickly back off exponentially; one that lived a while refills at once.
1129
+ const WARM_MIN_LIFETIME_MS = 5000;
1130
+ const WARM_BACKOFF_CAP_MS = 60_000;
1131
+ const warmFastExits = new Map(); // name -> consecutive too-fast exits
1132
+ let shuttingDown = false;
1133
+ function spawnWarm(name) {
1134
+ const child = spawnServer(name, servers[name]);
1135
+ const bornAt = Date.now();
1136
+ pipeStderr(name, child);
1137
+ child.on('exit', (code) => {
1138
+ // Once taken, the child belongs to its session, which registers its own exit handler. Both
1139
+ // used to run, so every failing child of a pooled server was counted as two failures and
1140
+ // drove that server's health down twice as fast as an identical unpooled one.
1141
+ if (child.__warmTaken) return;
1142
+ noteExit(name, child, code);
1143
+ const arr = warmPool.get(name); if (arr) { const i = arr.indexOf(child); if (i >= 0) arr.splice(i, 1); }
1144
+ // Deliberate kills refill themselves (recycle) or are meant to leave the pool empty (reload
1145
+ // removing a server, shutdown). Only an exit we did not ask for needs replacing here.
1146
+ const weAskedForThis = child.__bridgeKilled || shuttingDown;
1147
+ if (weAskedForThis ||
1148
+ !poolTarget(name)) {
1149
+ return;
1150
+ }
1151
+ const lived = Date.now() - bornAt;
1152
+ const fast = lived < WARM_MIN_LIFETIME_MS;
1153
+ const strikes = fast ? (warmFastExits.get(name) || 0) + 1 : 0;
1154
+ warmFastExits.set(name, strikes);
1155
+ const delay = strikes ? Math.min(WARM_BACKOFF_CAP_MS, 1000 * 2 ** (strikes - 1)) : 0;
1156
+ log(`[${name}] warm child exited after ${lived}ms (code ${code})${strikes ? `; ${strikes} fast exits, refilling in ${delay}ms` : '; refilling'}`);
1157
+ setTimeout(() => refillPool(name), delay).unref();
1158
+ });
1159
+ return child;
1160
+ }
1161
+ function refillPool(name) {
1162
+ const target = poolTarget(name);
1163
+ if (!target ||
1164
+ shuttingDown) {
1165
+ return;
1166
+ }
1167
+ if (!warmPool.has(name)) warmPool.set(name, []);
1168
+ const arr = warmPool.get(name);
1169
+ // Refill toward the target in one pass instead of one child per take. Reactive single-child
1170
+ // refill meant a burst that emptied the pool recovered long after the burst was over, which is
1171
+ // the window pooling exists to cover. The spawn gate decides how many actually start at once.
1172
+ while (arr.length + (warmPending.get(name) || 0) < target) {
1173
+ warmPending.set(name, (warmPending.get(name) || 0) + 1);
1174
+ const gated = usesSharedPackageCache(servers[name]);
1175
+ const slot = gated ? acquireSpawn() : Promise.resolve(NOOP_RELEASE);
1176
+ slot.then((release) => {
1177
+ warmPending.set(name, Math.max(0, (warmPending.get(name) || 1) - 1));
1178
+ const want = poolTarget(name);
1179
+ const cur = warmPool.get(name);
1180
+ // The wait for a slot can be long enough for the answer to change: a reload removed the
1181
+ // server, a shutdown started, or other refills already met the target.
1182
+ const noLongerWanted = shuttingDown || !want || !cur;
1183
+ const alreadyMet = cur && cur.length >= want;
1184
+ if (noLongerWanted ||
1185
+ alreadyMet) {
1186
+ release();
1187
+ return;
1188
+ }
1189
+ const child = spawnWarm(name);
1190
+ cur.push(child);
1191
+ log(`[${name}] pre-warmed pool child (${cur.length}/${want})`);
1192
+ releaseOnReady(child, release);
1193
+ });
1194
+ }
1195
+ }
1196
+ function takeWarm(name) {
1197
+ const arr = warmPool.get(name);
1198
+ if (arr && arr.length) {
1199
+ const c = arr.shift();
1200
+ serverMetrics.adoptedWarmChild(name);
1201
+ c.__warmTaken = true; // hand ownership to the session; the warm exit handler stands down
1202
+ warmFastExits.delete(name); // a child that survived to be used clears the backoff
1203
+ setImmediate(() => refillPool(name));
1204
+ return c;
1205
+ }
1206
+ return null;
1207
+ }
1208
+ for (const poolName of Object.keys(servers)) refillPool(poolName); // pre-warm pool servers at boot
1209
+
1210
+ /* --------------------- resumable sessions (survive a restart) --------------- */
1211
+ // Streamable HTTP session ids live only in memory, so restarting the bridge — or recycling a
1212
+ // server — made every live client session 404. The spec says a client should then re-initialize,
1213
+ // but in practice clients surface the failure instead, which is exactly the outage this project
1214
+ // exists to prevent: a keep-alive service must survive its own restart.
1215
+ //
1216
+ // The client's initialize request is recorded, so an id this process has never seen can still be
1217
+ // re-established: spawn the server, replay that initialize, then serve the request that arrived.
1218
+ // Only the handshake is replayed. Any state the server accumulated is genuinely gone, so this
1219
+ // restores the session, not its history.
1220
+ const RESUME_ENABLED = process.env.MCP_RESUME !== '0';
1221
+ const RESUME_TTL_MS = parseInt(process.env.MCP_RESUME_TTL_MS || String(24 * 60 * 60 * 1000), 10);
1222
+ const RESUME_FILE = resolve(dirname(CONFIG), 'sessions.json');
1223
+ const resumable = new Map(); // sessionId -> { server, initialize, at }
1224
+ const resuming = new Map(); // sessionId -> handshake promise, before any asynchronous admission
1225
+
1226
+ function saveResumable() {
1227
+ if (!RESUME_ENABLED) return;
1228
+ try { writeFileSync(RESUME_FILE, JSON.stringify(Object.fromEntries(resumable))); } catch { /* best effort */ }
1229
+ }
1230
+ // Records past the TTL are dropped. Without this the window was only ever applied when the file
1231
+ // was loaded at startup, so a long-running bridge kept honouring — and re-persisting — records
1232
+ // far older than the retention window it documents. Returns true if anything was removed.
1233
+ // Session ids we know existed and can no longer honour. Every released revision that has
1234
+ // sessions (2025-03-26 through 2025-11-25, Streamable HTTP / Session Management §3) requires 404
1235
+ // for a terminated session, and §4 makes re-initializing on 404 a client MUST — so the status
1236
+ // code stays 404. A 410 would read better to a human but would put a compliant client into
1237
+ // undefined territory and lose the one recovery path the spec defines. (The draft revision drops
1238
+ // sessions from this transport altogether, so 404 is also the last word on the subject.)
1239
+ // We keep the set purely to tell "known-gone" from "never existed" in the log, which is what
1240
+ // actually distinguishes a stale client after a restart from a misconfigured one.
1241
+ const GONE_MAX = 500;
1242
+ const goneSessions = new Set();
1243
+ function markGone(sessionId) {
1244
+ goneSessions.add(sessionId);
1245
+ if (goneSessions.size > GONE_MAX) goneSessions.delete(goneSessions.values().next().value);
1246
+ }
1247
+
1248
+ function pruneResumable(now = Date.now()) {
1249
+ let dropped = false;
1250
+ for (const [id, r] of resumable) {
1251
+ if (now - (r.at || 0) >= RESUME_TTL_MS) { resumable.delete(id); markGone(id); dropped = true; }
1252
+ }
1253
+ return dropped;
1254
+ }
1255
+ function rememberSession(sessionId, name, initMsg) {
1256
+ if (!RESUME_ENABLED || !initMsg) return;
1257
+ resumable.set(sessionId, { server: name, initialize: initMsg.params ?? {}, at: Date.now() });
1258
+ goneSessions.delete(sessionId); // a live id again
1259
+ pruneResumable();
1260
+ saveResumable();
1261
+ }
1262
+ function forgetSession(sessionId) {
1263
+ if (resumable.delete(sessionId)) saveResumable();
1264
+ markGone(sessionId); // an explicit DELETE is gone for good, not "never existed"
1265
+ }
1266
+ if (RESUME_ENABLED) {
1267
+ try {
1268
+ const raw = JSON.parse(readFileSync(RESUME_FILE, 'utf8'));
1269
+ const now = Date.now();
1270
+ for (const [id, r] of Object.entries(raw)) {
1271
+ // Drop records for servers that no longer exist, and anything past the TTL.
1272
+ if (r && r.server && servers[r.server] && now - (r.at || 0) < RESUME_TTL_MS) resumable.set(id, r);
1273
+ }
1274
+ if (resumable.size) log(`${resumable.size} session(s) resumable after restart`);
1275
+ } catch { /* no prior state */ }
1276
+ }
1277
+
1278
+ // Re-establish a session the client still believes in.
1279
+ async function resumeSession(name, sessionId) {
1280
+ const rec = resumable.get(sessionId);
1281
+ if (!rec || rec.server !== name) return null;
1282
+ if (resuming.has(sessionId)) return resuming.get(sessionId);
1283
+ // The retention window is a promise about how long a session id stays valid, so it has to be
1284
+ // checked here too — not only when the file is read at startup.
1285
+ if (Date.now() - (rec.at || 0) >= RESUME_TTL_MS) {
1286
+ resumable.delete(sessionId);
1287
+ markGone(sessionId);
1288
+ saveResumable();
1289
+ return null;
1290
+ }
1291
+ const pending = restoreSession(name, sessionId, rec);
1292
+ resuming.set(sessionId, pending);
1293
+ try {
1294
+ return await pending;
1295
+ } finally {
1296
+ if (resuming.get(sessionId) === pending) resuming.delete(sessionId);
1297
+ }
1298
+ }
1299
+
1300
+ async function restoreSession(name, sessionId, rec) {
1301
+ const stillWanted = () => resumable.get(sessionId) === rec && !goneSessions.has(sessionId);
1302
+ const restored = await startAdmittedSession(name, rec.initialize, sessionId, stillWanted);
1303
+ if (!restored) return null;
1304
+ const { session } = restored;
1305
+ const initId = `bridge-resume-${randomUUID()}`;
1306
+ const reply = await new Promise((resolveWait) => {
1307
+ const timer = setTimeout(() => { session.pending.delete(initId); resolveWait(null); }, INIT_TIMEOUT_MS);
1308
+ session.pending.set(initId, { resolve: resolveWait, timer });
1309
+ try {
1310
+ writeSessionMessage(session, { jsonrpc: '2.0', id: initId, method: 'initialize', params: rec.initialize });
1311
+ } catch { clearTimeout(timer); session.pending.delete(initId); resolveWait(null); }
1312
+ });
1313
+ if (!reply ||
1314
+ reply.error ||
1315
+ !stillWanted()) {
1316
+ killTree(session.child);
1317
+ if (httpSessions.get(sessionId) === session) httpSessions.delete(sessionId);
1318
+ slotFreed(name);
1319
+ log(`[${name}] could not resume session ${sessionId.slice(0, 8)}`);
1320
+ if (!stillWanted()) return null;
1321
+ throw Object.assign(new Error('bridge: session initialization failed'), { upstreamError: reply?.error, statusCode: 502 });
1322
+ }
1323
+ writeSessionMessage(session, { jsonrpc: '2.0', method: 'notifications/initialized' });
1324
+ session.clientInfo = rec.initialize.clientInfo ?? null;
1325
+ session.protocolVersion = reply.result.protocolVersion;
1326
+ if (session.cold) {
1327
+ noteSpawnCost(name, Date.now() - session.child.__spawnedAt);
1328
+ session.cold = false;
1329
+ }
1330
+ rec.at = Date.now();
1331
+ saveResumable();
1332
+ log(`[${name}] resumed session ${sessionId.slice(0, 8)}`);
1333
+ return session;
1334
+ }
1335
+
1336
+ /* ------------------------------ scheduled recycle --------------------------- */
1337
+ // Some servers hold a credential they can only refresh interactively, so they have to be
1338
+ // restarted periodically. Now that an unknown session id is transparently re-established, a
1339
+ // recycle is invisible to connected clients, so the bridge can do it itself rather than needing
1340
+ // anything driving it from inside an editor. Per-server `recycleMinutes`, or MCP_RECYCLE_MINUTES
1341
+ // for all.
1342
+ const RECYCLE_MINUTES = parseFloat(process.env.MCP_RECYCLE_MINUTES || '0');
1343
+ const recycleMinutesFor = (name) => (servers[name] && servers[name].recycleMinutes) || RECYCLE_MINUTES;
1344
+ const lastRecycle = new Map();
1345
+
1346
+ function recycleServer(name) {
1347
+ let killed = 0;
1348
+ // A session with a request in flight is left alone, the same rule the idle reaper already
1349
+ // follows: killing the child underneath a caller means it waits for a reply that can never
1350
+ // arrive. Its credential is replaced on the next request instead, which is a moment later.
1351
+ const busy = (s) => s.pending && s.pending.size > 0;
1352
+ for (const s of sessions.values()) if ((!name || s.name === name) && !busy(s)) { killTree(s.child); killed++; }
1353
+ for (const s of httpSessions.values()) {
1354
+ if ((!name || s.name === name) &&
1355
+ !s.child.__sharedSession &&
1356
+ !busy(s)) { killTree(s.child); killed++; }
1357
+ }
1358
+ // Warm children hold the same stale credential, so they have to go too, or a recycled server
1359
+ // is immediately replaced by a pre-spawned copy of what was just discarded.
1360
+ for (const n of (name ? [name] : Object.keys(servers))) {
1361
+ if (sharedManager.recycle(n).found) killed++;
1362
+ for (const c of (warmPool.get(n) || []).slice()) killTree(c);
1363
+ setTimeout(() => refillPool(n), 1000).unref();
1364
+ }
1365
+ return killed;
1366
+ }
1367
+
1368
+ // The tick adapts to the shortest configured period, so it has to be rebuilt whenever the config
1369
+ // changes: a reload can introduce the first `recycleMinutes` on a bridge that had none, or
1370
+ // shorten the interval below the current tick.
1371
+ let recycleTimer = null;
1372
+ function ensureRecycleTimer() {
1373
+ const periods = Object.keys(servers).map(recycleMinutesFor).filter((m) => m > 0);
1374
+ if (recycleTimer) { clearInterval(recycleTimer); recycleTimer = null; }
1375
+ if (!periods.length) return;
1376
+ // Check often enough to honour the shortest configured period, but never busier than needed.
1377
+ const tick = Math.max(200, Math.min(30_000, (Math.min(...periods) * 60_000) / 2));
1378
+ recycleTimer = setInterval(() => {
1379
+ const now = Date.now();
1380
+ for (const name of Object.keys(servers)) {
1381
+ const mins = recycleMinutesFor(name);
1382
+ if (!mins) continue;
1383
+ const last = lastRecycle.get(name) || startedAt;
1384
+ if (now - last < mins * 60_000) continue;
1385
+ const killed = recycleServer(name);
1386
+ lastRecycle.set(name, now);
1387
+ log(`[${name}] scheduled recycle after ${mins}m (${killed} session(s))`);
1388
+ }
1389
+ }, tick);
1390
+ recycleTimer.unref();
1391
+ }
1392
+ ensureRecycleTimer();
1393
+
1394
+ /* ------------------------------- config reload ------------------------------ */
1395
+ // Apply an edited servers.json without restarting.
1396
+ //
1397
+ // A restart is the blunt alternative, and it costs every live session on every server — adding
1398
+ // one server should not disturb the fourteen that were working. So the reload is a diff: servers
1399
+ // whose definition is unchanged are left completely alone, and only the ones that actually
1400
+ // changed are torn down so the next request picks up the new definition.
1401
+ //
1402
+ // `servers` is mutated in place rather than rebound, because the rest of the bridge closes over
1403
+ // that object; rebinding it would leave timers and handlers reading the old config forever.
1404
+ function diffConfig(next) {
1405
+ const before = Object.keys(servers);
1406
+ const after = Object.keys(next);
1407
+ return {
1408
+ added: after.filter((n) => !before.includes(n)),
1409
+ removed: before.filter((n) => !after.includes(n)),
1410
+ changed: after.filter((n) => before.includes(n) && JSON.stringify(servers[n]) !== JSON.stringify(next[n])),
1411
+ };
1412
+ }
1413
+
1414
+ function applyConfig(next) {
1415
+ const { added, removed, changed } = diffConfig(next);
1416
+ let restarted = 0;
1417
+ // A changed or removed server's children were started from the old definition, so they are
1418
+ // stale. Untouched servers are deliberately not disturbed.
1419
+ for (const name of [...removed, ...changed]) {
1420
+ if (next[name] &&
1421
+ onlyPoolingPolicyChanged(servers[name], next[name])) {
1422
+ continue;
1423
+ }
1424
+ invalidateToken(servers[name]);
1425
+ restarted += recycleServer(name);
1426
+ }
1427
+ for (const name of removed) {
1428
+ sharedManager.remove(name);
1429
+ delete servers[name];
1430
+ warmPool.delete(name); // recycleServer queued a refill; it must not resurrect a deleted server
1431
+ }
1432
+ for (const name of [...added, ...changed]) servers[name] = next[name];
1433
+ for (const name of [...added, ...changed]) {
1434
+ const pool = warmPool.get(name);
1435
+ const target = poolTarget(name);
1436
+ while (pool &&
1437
+ pool.length > 0 &&
1438
+ pool.length > target) {
1439
+ killTree(pool.pop());
1440
+ }
1441
+ refillPool(name);
1442
+ }
1443
+ ensureRecycleTimer(); // a reload can add the first recycleMinutes, or shorten the interval
1444
+ return { added, removed, changed, restarted };
1445
+ }
1446
+
1447
+ function onlyPoolingPolicyChanged(before, after) {
1448
+ if (before.sharing === 'shared' ||
1449
+ after.sharing === 'shared') {
1450
+ return false;
1451
+ }
1452
+ const withoutPolicy = ({ sharing, minWarm, ...rest }) => rest;
1453
+ return JSON.stringify(withoutPolicy(before)) === JSON.stringify(withoutPolicy(after));
1454
+ }
1455
+
1456
+ let lastConfigText = (() => { try { return readFileSync(CONFIG, 'utf8'); } catch { return null; } })();
1457
+
1458
+ function reloadConfig(reason) {
1459
+ let text;
1460
+ try { text = readFileSync(CONFIG, 'utf8'); }
1461
+ catch {
1462
+ return { ok: false, statusCode: 500, code: 'CONFIG_READ_FAILED',
1463
+ publicError: 'Unable to read the saved configuration.' };
1464
+ }
1465
+ if (text === lastConfigText) return { ok: true, unchanged: true, added: [], removed: [], changed: [], restarted: 0 };
1466
+
1467
+ // An invalid file must never take working servers down — a half-written save from an editor
1468
+ // looks exactly like this. Keep serving what is already in memory and report the problem.
1469
+ let next;
1470
+ try { next = JSON.parse(text); }
1471
+ catch {
1472
+ log('config reload rejected: invalid JSON');
1473
+ return { ok: false, statusCode: 400, code: 'INVALID_CONFIG_JSON',
1474
+ publicError: 'Saved config contains invalid JSON.' };
1475
+ }
1476
+ if (!next ||
1477
+ typeof next !== 'object' ||
1478
+ Array.isArray(next)) {
1479
+ log('config reload rejected: top level must be an object of server definitions');
1480
+ return { ok: false, statusCode: 400, code: 'INVALID_CONFIG_ROOT',
1481
+ publicError: 'Config top level must be an object of server definitions.' };
1482
+ }
1483
+ for (const d of Object.values(next)) {
1484
+ if (!d ||
1485
+ typeof d !== 'object' ||
1486
+ (!d.command &&
1487
+ !d.url)) {
1488
+ log('config reload rejected: a server has neither "command" nor "url"');
1489
+ return { ok: false, statusCode: 400, code: 'INVALID_SERVER_DEFINITION',
1490
+ publicError: 'A server definition has neither "command" nor "url".' };
1491
+ }
1492
+ }
1493
+
1494
+ lastConfigText = text;
1495
+ const r = applyConfig(next);
1496
+ const summary = [
1497
+ r.added.length ? `+${r.added.join(', ')}` : null,
1498
+ r.removed.length ? `-${r.removed.join(', ')}` : null,
1499
+ r.changed.length ? `~${r.changed.join(', ')}` : null,
1500
+ ].filter(Boolean).join(' ');
1501
+ log(summary
1502
+ ? `config reloaded (${reason}): ${summary} — ${r.restarted} session(s) restarted`
1503
+ : `config reloaded (${reason}): no server definitions changed`);
1504
+ return { ok: true, ...r };
1505
+ }
1506
+
1507
+ // Watching the file makes an edit take effect on its own, which is the point: the config exists
1508
+ // to be edited. Debounced because editors save in bursts (write, rename, truncate), and re-armed
1509
+ // after each burst because an atomic save replaces the inode the watcher was holding.
1510
+ if (process.env.MCP_CONFIG_WATCH !== '0') {
1511
+ let timer = null;
1512
+ let watcher = null;
1513
+ const arm = () => {
1514
+ try {
1515
+ watcher = watch(CONFIG, () => {
1516
+ clearTimeout(timer);
1517
+ timer = setTimeout(() => {
1518
+ poolingConfig.reloadSaved('file changed')
1519
+ .catch(() => log('config watcher could not reload the saved configuration'))
1520
+ .finally(rearm);
1521
+ }, 300);
1522
+ });
1523
+ watcher.unref?.();
1524
+ } catch { /* watching is best effort; /admin/reload still works */ }
1525
+ };
1526
+ const rearm = () => { try { watcher?.close(); } catch { /* noop */ } arm(); };
1527
+ arm();
1528
+ }
1529
+
1530
+ /* --------------------------- active health probing -------------------------- */
1531
+ // Passive health above only learns anything when somebody makes a request. A server nobody has
1532
+ // called today therefore reads 'unknown' indefinitely — which is honest, but it also means a
1533
+ // credential can expire overnight and you find out from a failed tool call rather than the
1534
+ // dashboard. Probing closes that gap for HTTP servers, where the failure is a silent 401.
1535
+ //
1536
+ // Off by default: a probe is a real request to somebody else's service, and how often that is
1537
+ // acceptable is not the bridge's call to make. stdio servers are not probed — a probe would mean
1538
+ // spawning a process, which costs more than the request it is meant to pre-empt, and their
1539
+ // failures (spawn error, non-zero exit) are already observed for free.
1540
+ const HEALTH_INTERVAL_MS = Number(process.env.MCP_HEALTH_INTERVAL_MS || 0);
1541
+
1542
+ function probeServer(name) {
1543
+ const def = servers[name];
1544
+ if (!def || def.type !== 'http' || !def.url) return;
1545
+ // A server the client authenticates cannot be probed usefully: the bridge holds no credential
1546
+ // for it, so every probe would 401 and report a working server as broken.
1547
+ if (!resolveAuth(def) && !def.headers) return;
1548
+ stat(name).lastProbe = Date.now();
1549
+ const body = JSON.stringify({
1550
+ jsonrpc: '2.0', id: `probe-${Date.now()}`, method: 'initialize',
1551
+ params: { protocolVersion: '2025-06-18', capabilities: {}, clientInfo: { name: 'mcp-pacemaker-health', version: VERSION } },
1552
+ });
1553
+ (async () => {
1554
+ const headers = { 'content-type': 'application/json', accept: 'application/json, text/event-stream', 'content-length': Buffer.byteLength(body) };
1555
+ const a = resolveAuth(def);
1556
+ if (a) headers[a.header.toLowerCase()] = await a.getValue();
1557
+ for (const [k, v] of Object.entries(def.headers ?? {})) headers[k] = v;
1558
+ const target = new URL(def.url);
1559
+ const lib = target.protocol === 'https:' ? https : http;
1560
+ return new Promise((done) => {
1561
+ const r = lib.request(target, { method: 'POST', headers }, (resp) => {
1562
+ const code = resp.statusCode ?? 502;
1563
+ resp.resume();
1564
+ if (code === 401 || code === 403 || code >= 500) noteFailure(name, `health probe: upstream returned ${code}`);
1565
+ else noteSuccess(name);
1566
+ done(); });
1567
+ r.on('error', (e) => { noteFailure(name, `health probe: ${e.message}`); done(); });
1568
+ r.setTimeout(10_000, () => { r.destroy(new Error('timed out')); });
1569
+ r.end(body);
1570
+ });
1571
+ })().catch((e) => noteFailure(name, `health probe: ${e.message}`));
1572
+ }
1573
+
1574
+ if (HEALTH_INTERVAL_MS > 0) {
1575
+ const tick = Math.max(1000, Math.min(HEALTH_INTERVAL_MS, 60_000));
1576
+ setInterval(() => {
1577
+ const now = Date.now();
1578
+ for (const name of Object.keys(servers)) {
1579
+ const def = servers[name];
1580
+ if (def.type !== 'http') continue;
1581
+ // A per-server interval of 0 opts one server out of an otherwise global schedule.
1582
+ const every = def.healthIntervalMinutes != null ? def.healthIntervalMinutes * 60_000 : HEALTH_INTERVAL_MS;
1583
+ if (!every) continue;
1584
+ const s = stat(name);
1585
+ // A server that just served a real request has already proven itself; probing it as well
1586
+ // is pure extra load on the upstream.
1587
+ if (now - Math.max(s.lastProbe, s.lastSuccess, s.lastErrorAt) < every) continue;
1588
+ probeServer(name);
1589
+ }
1590
+ }, tick).unref();
1591
+ log(`health probing every ${Math.round(HEALTH_INTERVAL_MS / 1000)}s (http servers only)`);
1592
+ }
1593
+
1594
+ /* --------------------------- proactive token refresh ------------------------ */
1595
+ // Renew a cached credential shortly before it expires instead of discovering the expiry on a
1596
+ // request. A bridge that stays up for days would otherwise sit on a dead token until the next
1597
+ // call, which then pays the mint cost or fails outright.
1598
+ const TOKEN_REFRESH_LEAD_MS = parseInt(process.env.MCP_TOKEN_REFRESH_LEAD_MS || String(5 * 60 * 1000), 10);
1599
+ if (TOKEN_REFRESH_LEAD_MS > 0) {
1600
+ // Check often enough to act inside the lead window, but no busier than that requires.
1601
+ const tick = Math.max(500, Math.min(60_000, TOKEN_REFRESH_LEAD_MS / 2));
1602
+ setInterval(() => {
1603
+ const now = Date.now();
1604
+ for (const [name, def] of Object.entries(servers)) {
1605
+ const key = authCacheKey(def);
1606
+ if (!key) continue;
1607
+ const cached = tokenCache.get(key);
1608
+ // Only refresh credentials already in use; never mint for a server nobody has touched.
1609
+ if (!cached || cached.exp - now > TOKEN_REFRESH_LEAD_MS) continue;
1610
+ tokenCache.delete(key);
1611
+ const auth = resolveAuth(def);
1612
+ if (!auth) continue;
1613
+ auth.getValue()
1614
+ .then(() => log(`[${name}] refreshed credential ahead of expiry`))
1615
+ .catch((e) => { noteFailure(name, `token refresh failed: ${e.message}`); log(`[${name}] token refresh failed: ${e.message}`); });
1616
+ }
1617
+ }, tick).unref();
1618
+ }
1619
+
1620
+ function handleStreamable(name, req, res) {
1621
+ const sid = req.headers['mcp-session-id'];
1622
+ const boundName = httpSessions.get(sid)?.name ?? resumable.get(sid)?.server;
1623
+ if (boundName &&
1624
+ boundName !== name) {
1625
+ res.writeHead(404).end('no such session');
1626
+ return;
1627
+ }
1628
+
1629
+ if (req.method === 'GET') {
1630
+ const session = sid ? httpSessions.get(sid) : null;
1631
+ if (!session) { res.writeHead(404).end('no such session'); return; }
1632
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' });
1633
+ session.sseRes = res;
1634
+ const ka = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* noop */ } }, 15000);
1635
+ req.on('close', () => { clearInterval(ka); if (session.sseRes === res) session.sseRes = null; });
1636
+ return;
1637
+ }
1638
+
1639
+ if (req.method === 'DELETE') {
1640
+ const session = sid ? httpSessions.get(sid) : null;
1641
+ if (session) { killTree(session.child); httpSessions.delete(sid); slotFreed(name); }
1642
+ // An explicit DELETE ends the session for good; it must not come back on the next request.
1643
+ if (sid) forgetSession(sid);
1644
+ res.writeHead(204).end();
1645
+ return;
1646
+ }
1647
+
1648
+ if (req.method === 'POST') {
1649
+ const definition = servers[name];
1650
+ const chunks = [];
1651
+ let bytes = 0;
1652
+ let tooLarge = false;
1653
+ const shared = servers[name].sharing === 'shared';
1654
+ const configuredLimit = servers[name].sharedMaxLineBytes;
1655
+ const validLimit = Number.isSafeInteger(configuredLimit) && configuredLimit > 0;
1656
+ const bodyLimit = shared ? Math.min(validLimit ? configuredLimit : SHARED_DEFAULTS.sharedMaxLineBytes, 16_777_216) : Infinity;
1657
+ req.on('data', (chunk) => {
1658
+ if (tooLarge) return;
1659
+ bytes += chunk.length;
1660
+ if (bytes > bodyLimit) {
1661
+ tooLarge = true;
1662
+ chunks.length = 0;
1663
+ res.writeHead(413).end('shared request exceeds the input limit');
1664
+ return;
1665
+ }
1666
+ chunks.push(chunk);
1667
+ });
1668
+ req.on('end', async () => {
1669
+ if (tooLarge ||
1670
+ res.destroyed) return;
1671
+ if (servers[name] !== definition) {
1672
+ res.writeHead(409).end('server configuration changed while receiving request; retry');
1673
+ return;
1674
+ }
1675
+ let parsed;
1676
+ try { parsed = JSON.parse(Buffer.concat(chunks).toString() || 'null'); } catch { res.writeHead(400).end('invalid json'); return; }
1677
+ const messages = Array.isArray(parsed) ? parsed : (parsed == null ? [] : [parsed]);
1678
+ const isInit = messages.some((m) => m && m.method === 'initialize');
1679
+ const initMessage = messages.find((m) => m && m.method === 'initialize');
1680
+ if (shared) {
1681
+ const invalidMessage = messages.some((message) => !message ||
1682
+ typeof message !== 'object' || Array.isArray(message) ||
1683
+ message.jsonrpc !== '2.0' || typeof message.method !== 'string' ||
1684
+ (Object.hasOwn(message, 'id') && !sharedManager.validId(message.id)));
1685
+ const invalidInitialize = isInit && (Array.isArray(parsed) || messages.length !== 1 || !Object.hasOwn(initMessage, 'id'));
1686
+ if (!messages.length ||
1687
+ invalidMessage ||
1688
+ invalidInitialize) {
1689
+ res.writeHead(400).end('invalid shared JSON-RPC request');
1690
+ return;
1691
+ }
1692
+ }
1693
+ try {
1694
+ let sessionId = sid;
1695
+ let session = sid ? httpSessions.get(sid) : null;
1696
+ if (sid &&
1697
+ (!session || resuming.has(sid))) {
1698
+ // An id this process has not seen: it may predate a restart or a recycle.
1699
+ session = await resumeSession(name, sid);
1700
+ if (session) sessionId = sid;
1701
+ }
1702
+ if (!session) {
1703
+ if (!isInit) {
1704
+ // Both cases are 404 because the spec requires it, but they mean different things to
1705
+ // an operator: a known-gone id is a client that outlived a restart, an unknown id is a
1706
+ // client that never initialized here. Say which in the body and the log.
1707
+ if (sid &&
1708
+ goneSessions.has(sid)) {
1709
+ log(`${name}: rejecting known-gone session ${sid} (client predates a restart; it must re-initialize)`);
1710
+ res.writeHead(404).end('session terminated (re-initialize)');
1711
+ return;
1712
+ }
1713
+ res.writeHead(404).end('no session (send initialize first)');
1714
+ return;
1715
+ }
1716
+ const started = await startAdmittedSession(name, initMessage.params, undefined, () => !res.destroyed);
1717
+ if (!started) return;
1718
+ ({ id: sessionId, session } = started);
1719
+ }
1720
+ session.lastActivity = Date.now();
1721
+ if (session.child.__sharedSession) {
1722
+ const wrongVersion = !isInit && req.headers['mcp-protocol-version'] &&
1723
+ req.headers['mcp-protocol-version'] !== session.protocolVersion;
1724
+ const unsupportedBatch = Array.isArray(parsed) && session.protocolVersion !== '2025-03-26';
1725
+ if (wrongVersion ||
1726
+ unsupportedBatch) {
1727
+ res.writeHead(400).end('request does not match the negotiated shared protocol version');
1728
+ return;
1729
+ }
1730
+ }
1731
+ if (isInit) {
1732
+ const im = messages.find((m) => m && m.method === 'initialize');
1733
+ const ci = im && im.params && im.params.clientInfo;
1734
+ if (ci) session.clientInfo = { name: ci.name, version: ci.version };
1735
+ }
1736
+ const requestIds = messages.filter((m) => m && m.id != null && m.method).map((m) => m.id);
1737
+ if (new Set(requestIds).size !== requestIds.length ||
1738
+ requestIds.some((id) => session.pending.has(id))) {
1739
+ res.writeHead(400).end('duplicate pending JSON-RPC request id');
1740
+ return;
1741
+ }
1742
+ const headers = { 'Mcp-Session-Id': sessionId };
1743
+ const hasProgress = messages.some((message) => message?.params?._meta &&
1744
+ Object.hasOwn(message.params._meta, 'progressToken'));
1745
+ const stream = session.child.__sharedSession && requestIds.length > 0 && hasProgress;
1746
+ const arrayResponse = requestIds.length > 1 || (session.child.__sharedSession && Array.isArray(parsed));
1747
+ let resultBytes = arrayResponse ? 1 : 0;
1748
+ let responseOverflow = false;
1749
+ const collect = (resolveWait, reply) => {
1750
+ if (responseOverflow) { resolveWait(null); return; }
1751
+ if (session.child.__sharedSession) {
1752
+ try {
1753
+ resultBytes += Buffer.byteLength(JSON.stringify(reply)) + (arrayResponse ? 1 : 0);
1754
+ } catch (error) {
1755
+ if (!(error instanceof RangeError)) throw error;
1756
+ resultBytes = Infinity;
1757
+ }
1758
+ if (resultBytes > session.sharedBufferLimit) {
1759
+ responseOverflow = true;
1760
+ noteFailure(name, 'shared response exceeds the output limit');
1761
+ if (res.headersSent) res.destroy();
1762
+ else res.writeHead(502, { 'Content-Type': 'application/json' }).end(JSON.stringify({
1763
+ error: {
1764
+ code: -32002, message: 'shared response exceeds the output limit; calls were not replayed',
1765
+ data: { execution: 'potentially-executed' },
1766
+ },
1767
+ }));
1768
+ for (const id of requestIds) {
1769
+ const pending = session.pending.get(id);
1770
+ if (!pending) continue;
1771
+ clearTimeout(pending.timer);
1772
+ session.pending.delete(id);
1773
+ pending.resolve(null);
1774
+ if (session.child.stdin.writable) {
1775
+ writeSessionMessage(session, {
1776
+ jsonrpc: '2.0', method: 'notifications/cancelled', params: { requestId: id },
1777
+ });
1778
+ }
1779
+ }
1780
+ resolveWait(null);
1781
+ return;
1782
+ }
1783
+ }
1784
+ resolveWait(reply);
1785
+ };
1786
+ if (stream) {
1787
+ res.writeHead(200, { ...headers, 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform' });
1788
+ res.flushHeaders();
1789
+ }
1790
+ // initialize is the request that pays for spawning the server, and a cold start can be
1791
+ // slow (a package manager fetching the server on first run), so it gets a longer budget
1792
+ // than steady-state calls.
1793
+ const budget = isInit ? INIT_TIMEOUT_MS : REQUEST_TIMEOUT_MS;
1794
+ const waits = requestIds.map((id) => new Promise((resolveWait) => {
1795
+ const timer = setTimeout(() => {
1796
+ if (session.pending.has(id)) {
1797
+ session.pending.delete(id);
1798
+ resolveWait(bridgeReply(id, -32001, 'bridge: upstream timeout', 'timeout'));
1799
+ }
1800
+ }, budget);
1801
+ if (session.child.__sharedSession) clearTimeout(timer);
1802
+ session.pending.set(id, { resolve: (reply) => collect(resolveWait, reply), timer, stream: stream ? res : null });
1803
+ }));
1804
+ try {
1805
+ for (const message of messages) {
1806
+ if (responseOverflow) break;
1807
+ writeSessionMessage(session, message);
1808
+ }
1809
+ }
1810
+ catch (error) {
1811
+ for (const id of requestIds) {
1812
+ const pending = session.pending.get(id);
1813
+ if (!pending) continue;
1814
+ clearTimeout(pending.timer);
1815
+ session.pending.delete(id);
1816
+ pending.resolve(bridgeReply(id, -32000, 'bridge: upstream write failed', 'write'));
1817
+ }
1818
+ throw error;
1819
+ }
1820
+ if (!requestIds.length) { res.writeHead(202, headers).end(); return; }
1821
+ const results = await Promise.all(waits);
1822
+ if (responseOverflow) return;
1823
+ // A JSON-RPC error from the server is the server working — it answered. Only the bridge's
1824
+ // own synthetic errors mean the server is unhealthy, and even then a timeout is worth
1825
+ // recording only while the child is alive: if it already exited, its exit code and stderr
1826
+ // are the actual reason and must not be overwritten by the symptom.
1827
+ if (results.some((reply) => bridgeReplies.get(reply) === 'timeout')) {
1828
+ if (session.child.exitCode === null &&
1829
+ session.child.signalCode === null) noteFailure(name, 'upstream timeout');
1830
+ } else if (results.some((reply) => bridgeReplies.get(reply) === 'exit')) {
1831
+ // The child exited mid-request. Its exit handler already recorded the real reason with
1832
+ // the stderr tail, so counting it again here would report one dead child as two failures.
1833
+ } else if (!session.child.__sharedSession) {
1834
+ noteSuccess(name);
1835
+ // A cold start is only measurable once the server has answered: spawn() returning tells
1836
+ // us nothing about the package manager work that follows it. Recorded once per child.
1837
+ const initialized = results.some((reply) => reply.result &&
1838
+ messages.some((message) => message && message.method === 'initialize' && message.id === reply.id));
1839
+ const measurableColdStart = session.cold && initialized && session.child.__spawnedAt;
1840
+ if (measurableColdStart) {
1841
+ noteSpawnCost(name, Date.now() - session.child.__spawnedAt);
1842
+ session.cold = false;
1843
+ }
1844
+ }
1845
+ const initialized = isInit && results.some((reply) => reply?.result && reply.id === initMessage.id);
1846
+ if (initialized) {
1847
+ session.protocolVersion = results.find((reply) => reply.id === initMessage.id).result.protocolVersion;
1848
+ rememberSession(sessionId, name, initMessage);
1849
+ }
1850
+ // Stamp again on completion: the idle clock should measure time since the request finished,
1851
+ // not since it started, or a call slower than the timeout is reapable the moment it returns.
1852
+ session.lastActivity = Date.now();
1853
+ if (stream) {
1854
+ for (const result of results) {
1855
+ if (!writeSharedEvent(session, res, result)) return;
1856
+ }
1857
+ res.end();
1858
+ } else {
1859
+ headers['Content-Type'] = 'application/json';
1860
+ res.writeHead(200, headers);
1861
+ res.end(JSON.stringify(arrayResponse ? results : results[0]));
1862
+ }
1863
+ } catch (error) {
1864
+ log(`[${name}] session request failed: ${error.message}`);
1865
+ if (res.headersSent) { res.destroy(); return; }
1866
+ const rpcError = error.upstreamError ?? (error.data
1867
+ ? { code: -32002, message: error.message, data: error.data } : null);
1868
+ if (rpcError) {
1869
+ const request = messages.find((message) => message?.id != null);
1870
+ res.writeHead(200, { 'Content-Type': 'application/json' });
1871
+ res.end(JSON.stringify({ jsonrpc: '2.0', id: request?.id ?? null, error: rpcError }));
1872
+ } else {
1873
+ res.writeHead(error.statusCode ?? 502).end(error.message);
1874
+ }
1875
+ }
1876
+ });
1877
+ return;
1878
+ }
1879
+
1880
+ res.writeHead(405).end('method not allowed');
1881
+ }
1882
+
1883
+ /* ---------------------- http servers (reverse proxy + auth) ----------------- */
1884
+ // OAuth discovery for proxied servers.
1885
+ //
1886
+ // A client authenticating against http://127.0.0.1:<port>/<name> derives the metadata URL from
1887
+ // the bridge's own origin — /.well-known/oauth-protected-resource/<name> — not from the upstream
1888
+ // it is eventually talking to. Without an answer here the client fails at "could not discover
1889
+ // authorization server metadata" and can never reach the real login.
1890
+ //
1891
+ // The upstream document is relayed with one change, made in `handleWellKnown` below: `resource`
1892
+ // is rewritten to this bridge's URL. RFC 9728 requires the client to check that field against
1893
+ // the resource it is actually addressing, so the upstream's own identifier is rejected. The
1894
+ // authorization server and scopes are relayed untouched, and those are what determine the
1895
+ // audience of the issued token, so it stays valid for the upstream behind this bridge.
1896
+ // Hop-by-hop headers are connection-scoped and must not cross a proxy (RFC 9110 §7.6.1).
1897
+ // Relaying them leaks connection-scoped state to the client: a header the upstream named in its
1898
+ // own `Connection` header, or a `trailer` it announced, is meaningful only on that hop and is
1899
+ // misleading or invalid on the next one.
1900
+ const HOP_BY_HOP = new Set([
1901
+ 'connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization',
1902
+ 'te', 'trailer', 'transfer-encoding', 'upgrade',
1903
+ ]);
1904
+ function stripHopByHop(headers) {
1905
+ const out = {};
1906
+ // Anything named in `Connection` is also hop-by-hop for this message.
1907
+ const listed = String(headers.connection ?? '').split(',').map((s) => s.trim().toLowerCase()).filter(Boolean);
1908
+ for (const [k, v] of Object.entries(headers)) {
1909
+ const key = k.toLowerCase();
1910
+ if (HOP_BY_HOP.has(key) || listed.includes(key)) continue;
1911
+ out[k] = v;
1912
+ }
1913
+ return out;
1914
+ }
1915
+
1916
+ // The upstream document is relayed with one change: `resource` is rewritten to this bridge's URL
1917
+ // for the server. RFC 9728 requires the client to check that field against the resource it is
1918
+ // actually addressing; left as the upstream's own identifier, the client rejects the document and
1919
+ // reports that discovery failed. The authorization server and scopes are untouched, and those
1920
+ // determine the audience of the issued token, so it stays valid for the upstream behind this
1921
+ // bridge.
1922
+ function handleWellKnown(url, req, res) {
1923
+ const m = url.pathname.match(/^\/\.well-known\/([^/]+)\/(.+)$/);
1924
+ if (!m) return false;
1925
+ const [, metadata, rest] = m;
1926
+ const name = rest.split('/')[0];
1927
+ const def = servers[name];
1928
+ if (!def || def.type !== 'http') return false;
1929
+
1930
+ const upstream = new URL(def.url);
1931
+ const target = new URL(`/.well-known/${metadata}${upstream.pathname}`, upstream.origin);
1932
+ const lib = target.protocol === 'https:' ? https : http;
1933
+ const headers = stripHopByHop(req.headers);
1934
+ delete headers.host;
1935
+ delete headers['content-length'];
1936
+ delete headers['accept-encoding']; // keep the body readable so `resource` can be rewritten
1937
+ const upReq = lib.request(target, { method: 'GET', headers }, (upRes) => {
1938
+ const chunks = [];
1939
+ upRes.on('data', (c) => chunks.push(c));
1940
+ upRes.on('end', () => {
1941
+ const outHeaders = stripHopByHop(upRes.headers);
1942
+ let body = Buffer.concat(chunks);
1943
+ try {
1944
+ const doc = JSON.parse(body.toString('utf8'));
1945
+ if (doc && typeof doc === 'object' && doc.resource) {
1946
+ doc.resource = `http://${req.headers.host}/${name}`;
1947
+ body = Buffer.from(JSON.stringify(doc));
1948
+ }
1949
+ } catch { /* not JSON — relay untouched */ }
1950
+ outHeaders['content-length'] = String(body.length);
1951
+ res.writeHead(upRes.statusCode ?? 502, outHeaders);
1952
+ res.end(body);
1953
+ });
1954
+ });
1955
+ upReq.on('error', (e) => {
1956
+ log(`[${name}] oauth metadata error: ${e.message}`);
1957
+ try { res.writeHead(502).end('oauth metadata error'); } catch { /* noop */ }
1958
+ });
1959
+ upReq.end();
1960
+ return true;
1961
+ }
1962
+
1963
+ function proxyHttp(name, def, req, res) {
1964
+ const chunks = [];
1965
+ req.on('data', (c) => chunks.push(c));
1966
+ req.on('end', async () => {
1967
+ const body = Buffer.concat(chunks);
1968
+ const headers = stripHopByHop(req.headers);
1969
+ delete headers.host;
1970
+ delete headers['content-length'];
1971
+ let bridgeAuthenticates = false;
1972
+ try {
1973
+ const a = resolveAuth(def);
1974
+ if (a) { headers[a.header.toLowerCase()] = await a.getValue(); bridgeAuthenticates = true; }
1975
+ } catch (e) {
1976
+ noteFailure(name, e.message);
1977
+ log(`[${name}] auth error: ${e.message}`);
1978
+ try { res.writeHead(502).end('auth error'); } catch { /* noop */ }
1979
+ return;
1980
+ }
1981
+ for (const [k, v] of Object.entries(def.headers ?? {})) headers[k] = v;
1982
+ // A static credential in `headers` makes the bridge the authenticating party just as much as
1983
+ // an auth command does. Without this, a 401 on such a server counted as neither success nor
1984
+ // failure, and its challenge was relayed to a client that cannot act on it.
1985
+ if (Object.keys(def.headers ?? {}).some((k) => k.toLowerCase() === 'authorization')) bridgeAuthenticates = true;
1986
+
1987
+ const remainder = req.url.substring(('/' + name).length);
1988
+ const target = new URL(remainder ? def.url.replace(/\/$/, '') + remainder : def.url);
1989
+ const lib = target.protocol === 'https:' ? https : http;
1990
+ const upReq = lib.request(target, { method: req.method, headers }, (upRes) => {
1991
+ const outHeaders = stripHopByHop(upRes.headers);
1992
+ const wwwKey = Object.keys(outHeaders).find((k) => k.toLowerCase() === 'www-authenticate');
1993
+ let healthCounted = false;
1994
+ if (upRes.statusCode === 401 && wwwKey) {
1995
+ if (bridgeAuthenticates) {
1996
+ // The bridge supplied the credential, so a 401 is the bridge's problem, not the
1997
+ // client's. Relaying a challenge here starts a login the client cannot win: whatever
1998
+ // token it comes back with is overwritten by the bridge's own on the next request, so
1999
+ // it 401s again and the client is sent back to the browser, forever. Drop the
2000
+ // challenge and surface the failure where an operator will see it instead.
2001
+ delete outHeaders[wwwKey];
2002
+ const source = def.audience ? `audience ${def.audience}`
2003
+ : (def.auth && def.auth.command) ? 'auth.command'
2004
+ : 'the configured authorization header';
2005
+ const detail = `upstream rejected the credential from ${source} (401)`;
2006
+ noteFailure(name, detail);
2007
+ healthCounted = true; // this response is already recorded; do not count it twice below
2008
+ log(`[${name}] ${detail} — the cached credential is being discarded`);
2009
+ // Whatever is cached is not working, so do not keep serving it for the rest of its TTL.
2010
+ invalidateToken(def);
2011
+ } else {
2012
+ // The client authenticates for this server, and it derives the metadata URL from the
2013
+ // bridge origin it is addressing. The upstream names its own origin, which the client
2014
+ // cannot match, so point it at the bridge's copy — the one that answers with a
2015
+ // matching `resource`.
2016
+ outHeaders[wwwKey] = String(outHeaders[wwwKey]).replace(
2017
+ /resource_metadata="[^"]*"/i,
2018
+ `resource_metadata="http://${req.headers.host}/.well-known/oauth-protected-resource/${name}"`,
2019
+ );
2020
+ }
2021
+ }
2022
+ res.writeHead(upRes.statusCode ?? 502, outHeaders);
2023
+ // Health means "did the client's command work". A 401 challenge on a server the client
2024
+ // authenticates is not a failure — it is the first half of the OAuth handshake and the
2025
+ // client is about to complete it. Counting those marked a healthy server as failing for
2026
+ // the three seconds of every handshake, which is most of what the dashboard showed.
2027
+ const code = upRes.statusCode ?? 502;
2028
+ const bridgeCredentialRejected = (code === 401 || code === 403) && bridgeAuthenticates;
2029
+ if (healthCounted) { /* the challenge branch above already recorded this response */ }
2030
+ else if (code >= 500) noteFailure(name, `upstream returned ${code}`);
2031
+ else if (bridgeCredentialRejected) noteFailure(name, `upstream rejected the bridge credential (${code})`);
2032
+ else if (code < 400) noteSuccess(name);
2033
+ upRes.pipe(res);
2034
+ });
2035
+ upReq.on('error', (e) => {
2036
+ noteFailure(name, e.message);
2037
+ log(`[${name}] upstream error: ${e.message}`);
2038
+ try { res.writeHead(502).end('upstream error'); } catch { /* noop */ }
2039
+ });
2040
+ if (body.length) upReq.write(body);
2041
+ upReq.end();
2042
+ });
2043
+ }
2044
+
2045
+ /* --------------------------------- server ---------------------------------- */
2046
+ const server = http.createServer((req, res) => {
2047
+ const url = new URL(req.url, `http://${req.headers.host}`);
2048
+ const [name, kind] = url.pathname.split('/').filter(Boolean);
2049
+
2050
+ if (name === 'status' && !kind) {
2051
+ res.writeHead(200, { 'Content-Type': 'application/json' });
2052
+ res.end(JSON.stringify({ ok: true, service: 'mcp-pacemaker', version: VERSION, port: PORT, servers: Object.keys(servers), sessions: sessions.size }));
2053
+ return;
2054
+ }
2055
+ if (name === 'api') { handleApi(kind, req, res); return; }
2056
+ if (name === 'admin') { handleAdmin(url, req, res); return; }
2057
+ if (name === 'ui') { handleUi(url.pathname, res); return; }
2058
+ // Must precede the server lookup: ".well-known" is not a server name.
2059
+ if (name === '.well-known' && handleWellKnown(url, req, res)) return;
2060
+ if (!name || !servers[name]) { res.writeHead(404).end('unknown server'); return; }
2061
+
2062
+ stat(name).requests++; stat(name).lastActivity = Date.now();
2063
+ const def = servers[name];
2064
+ if (def.type === 'http') { proxyHttp(name, def, req, res); return; }
2065
+
2066
+ if (kind === 'mcp') { handleStreamable(name, req, res); return; }
2067
+ if (def.sharing === 'shared') {
2068
+ res.writeHead(400).end('shared mode requires Streamable HTTP; use /' + name + '/mcp');
2069
+ return;
2070
+ }
2071
+
2072
+ if (req.method === 'GET' && kind === 'sse') {
2073
+ res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache, no-transform', Connection: 'keep-alive' });
2074
+ const sessionId = startChild(name, res);
2075
+ res.write(`event: endpoint\ndata: http://${req.headers.host}/${name}/message?sessionId=${sessionId}\n\n`);
2076
+ const keepAlive = setInterval(() => { try { res.write(': keepalive\n\n'); } catch { /* noop */ } }, 15000);
2077
+ req.on('close', () => {
2078
+ clearInterval(keepAlive);
2079
+ const s = sessions.get(sessionId);
2080
+ if (s) { killTree(s.child); sessions.delete(sessionId); }
2081
+ });
2082
+ return;
2083
+ }
2084
+
2085
+ if (req.method === 'POST' && kind === 'message') {
2086
+ const s = sessions.get(url.searchParams.get('sessionId'));
2087
+ if (!s ||
2088
+ s.name !== name) { res.writeHead(404).end('no such session'); return; }
2089
+ let body = '';
2090
+ req.on('data', (c) => { body += c; });
2091
+ req.on('end', () => {
2092
+ let parsed;
2093
+ try { parsed = JSON.parse(body); } catch { res.writeHead(400).end('invalid json'); return; }
2094
+ const messages = Array.isArray(parsed) ? parsed : [parsed];
2095
+ try {
2096
+ for (const m of messages) s.child.stdin.write(JSON.stringify(m) + '\n');
2097
+ res.writeHead(202).end('accepted');
2098
+ } catch (e) {
2099
+ log(`[${s.name}] stdin write failed: ${e.message}`);
2100
+ res.writeHead(500).end('write failed');
2101
+ }
2102
+ });
2103
+ return;
2104
+ }
2105
+
2106
+ res.writeHead(404).end('not found');
2107
+ });
2108
+
2109
+ server.on('error', (e) => {
2110
+ if (e.code === 'EADDRINUSE') {
2111
+ // P5: classify what already holds the port — a pacemaker peer (adopt) vs a foreign service (collision).
2112
+ const probe = http.get({ host: HOST, port: PORT, path: '/status', timeout: 1500 }, (resp) => {
2113
+ let d = ''; resp.on('data', (c) => (d += c));
2114
+ resp.on('end', () => {
2115
+ let sig = null; try { sig = JSON.parse(d); } catch { /* not json */ }
2116
+ if (sig && sig.service === 'mcp-pacemaker') log(`port ${PORT} already served by mcp-pacemaker v${sig.version ?? '?'} — adopting existing bridge; exiting.`);
2117
+ else log(`port ${PORT} is held by a non-pacemaker service — choose another --port or migrate; exiting.`);
2118
+ process.exit(3);
2119
+ });
2120
+ });
2121
+ probe.on('error', () => { log(`port ${PORT} in use (status probe failed) — exiting.`); process.exit(3); });
2122
+ probe.on('timeout', () => { probe.destroy(); log(`port ${PORT} in use (status probe timeout) — exiting.`); process.exit(3); });
2123
+ return;
2124
+ }
2125
+ log(`server error: ${e.message}`); process.exit(1);
2126
+ });
2127
+
2128
+ // Node closes idle keep-alive sockets after 5s by default. MCP clients pool connections and
2129
+ // routinely idle far longer than that between tool calls, so the socket is closed underneath
2130
+ // them and the next request fails with ECONNRESET. Hold sockets open past typical think time;
2131
+ // headersTimeout must stay above keepAliveTimeout or the two race.
2132
+ server.keepAliveTimeout = 65_000;
2133
+ server.headersTimeout = 70_000;
2134
+
2135
+ server.listen(PORT, HOST, () => {
2136
+ try { writeFileSync(NONCE_FILE, ADMIN_NONCE); } catch { /* noop */ }
2137
+ log(`listening on http://${HOST}:${PORT} (config ${CONFIG}, base cwd ${BASE_CWD})`);
2138
+ log(`dashboard: http://${HOST}:${PORT}/ui · api /api/status|/api/events|/api/logs · admin nonce -> ${NONCE_FILE}`);
2139
+ for (const [n, d] of Object.entries(servers)) {
2140
+ log(` ${n} -> ${d.type === 'http' ? `http proxy ${d.url}` : `stdio /${n}/sse | /${n}/mcp`}`);
2141
+ }
2142
+ });
2143
+
2144
+ // Ending the streams before exiting matters: a client that sees its SSE stream close cleanly
2145
+ // treats it as "reconnect", while a dropped TCP connection reads as a transport fault and some
2146
+ // clients latch on that permanently. This only helps a graceful stop — a force-kill runs no
2147
+ // handler at all, which is why the supervisor should ask rather than kill.
2148
+ async function shutdown() {
2149
+ if (shuttingDown) return;
2150
+ shuttingDown = true; // stop warm-pool refills racing the exit
2151
+ await poolingConfig.close();
2152
+ sharedManager.shutdown();
2153
+ for (const s of sessions.values()) { try { s.res?.end(); } catch { /* already gone */ } }
2154
+ for (const s of httpSessions.values()) { try { s.sseRes?.end(); } catch { /* already gone */ } }
2155
+ for (const s of sessions.values()) killTree(s.child, true);
2156
+ for (const s of httpSessions.values()) killTree(s.child, true);
2157
+ for (const arr of warmPool.values()) for (const c of arr) killTree(c, true);
2158
+ try { server.close(); } catch { /* noop */ }
2159
+ process.exit(0);
2160
+ }
2161
+ process.on('SIGINT', shutdown);
2162
+ process.on('SIGTERM', shutdown);