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