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.
- package/CHANGELOG.md +424 -0
- package/CONTRIBUTING.md +156 -0
- package/LICENSE +21 -0
- package/README.md +620 -0
- package/SECURITY.md +110 -0
- package/THIRD_PARTY_NOTICES.txt +366 -0
- package/autostart/windows/launcher.vbs +11 -0
- package/autostart/windows/register-task.ps1 +63 -0
- package/bin/cli.mjs +880 -0
- package/bin/config-checks.mjs +62 -0
- package/bin/logs.mjs +465 -0
- package/bin/mcp-bridge.mjs +2113 -0
- package/bin/pooling-config.mjs +553 -0
- package/bin/pooling-execution.mjs +91 -0
- package/bin/pooling-writer.mjs +169 -0
- package/bin/prewarm.mjs +152 -0
- package/bin/server-metrics.mjs +69 -0
- package/bin/service-control.mjs +124 -0
- package/bin/shared-sessions.mjs +1083 -0
- package/bin/top.mjs +115 -0
- package/bin/update-check.mjs +153 -0
- package/bin/windows/PoolingSecurityHelper.build.json +39 -0
- package/bin/windows/PoolingSecurityHelper.exe +0 -0
- package/bin/windows/src/AssemblyInfo.cs +11 -0
- package/bin/windows/src/PoolingSecurityHelper.cs +165 -0
- package/bin/windows/src/PoolingSecurityReader.cs +50 -0
- package/docs/shared-sessions.md +181 -0
- package/examples/servers.example.json +31 -0
- package/package.json +77 -0
- package/supervisor/bridge-child.mjs +14 -0
- package/supervisor/supervise.mjs +172 -0
- package/supervisor/supervise.ps1 +23 -0
- package/supervisor/supervise.sh +11 -0
- package/tools/windows-security-helper/README.md +95 -0
- package/tools/windows-security-helper/build.ps1 +262 -0
- package/ui/dist/THIRD_PARTY_NOTICES.txt +366 -0
- package/ui/dist/assets/index-CIy7uxfd.css +1 -0
- package/ui/dist/assets/index-CtaaQsB9.js +8 -0
- package/ui/dist/index.html +14 -0
- 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);
|