mcp-compose 0.3.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +80 -4
  2. package/dist/bin/mcp-compose.js +78 -20
  3. package/dist/bin/mcp-compose.js.map +1 -1
  4. package/dist/bin/mcp-gateway.js +92 -17
  5. package/dist/bin/mcp-gateway.js.map +1 -1
  6. package/dist/package.json +21 -3
  7. package/dist/src/command.d.ts +5 -1
  8. package/dist/src/command.d.ts.map +1 -1
  9. package/dist/src/command.js +24 -22
  10. package/dist/src/command.js.map +1 -1
  11. package/dist/src/config.d.ts +4 -1
  12. package/dist/src/config.d.ts.map +1 -1
  13. package/dist/src/config.js +20 -4
  14. package/dist/src/config.js.map +1 -1
  15. package/dist/src/gateway.d.ts +1 -0
  16. package/dist/src/gateway.d.ts.map +1 -1
  17. package/dist/src/gateway.js +2070 -222
  18. package/dist/src/gateway.js.map +1 -1
  19. package/dist/src/oauth.d.ts +27 -3
  20. package/dist/src/oauth.d.ts.map +1 -1
  21. package/dist/src/oauth.js +557 -78
  22. package/dist/src/oauth.js.map +1 -1
  23. package/dist/src/pm2-args.d.ts +2 -0
  24. package/dist/src/pm2-args.d.ts.map +1 -0
  25. package/dist/src/pm2-args.js +19 -0
  26. package/dist/src/pm2-args.js.map +1 -0
  27. package/dist/src/pm2.d.ts +3 -1
  28. package/dist/src/pm2.d.ts.map +1 -1
  29. package/dist/src/pm2.js +85 -75
  30. package/dist/src/pm2.js.map +1 -1
  31. package/dist/src/process-tree.d.ts +4 -0
  32. package/dist/src/process-tree.d.ts.map +1 -0
  33. package/dist/src/process-tree.js +81 -0
  34. package/dist/src/process-tree.js.map +1 -0
  35. package/dist/src/sync.d.ts.map +1 -1
  36. package/dist/src/sync.js +70 -4
  37. package/dist/src/sync.js.map +1 -1
  38. package/dist/src/types.d.ts +9 -0
  39. package/dist/src/types.d.ts.map +1 -1
  40. package/package.json +21 -3
@@ -1,39 +1,551 @@
1
1
  import { createServer as createHttpServer } from 'node:http';
2
- import { spawn } from 'node:child_process';
3
2
  import { randomUUID } from 'node:crypto';
3
+ import crossSpawn from 'cross-spawn';
4
4
  import { OAuthClient } from './oauth.js';
5
+ import { killProcessTree } from './process-tree.js';
5
6
  class UnauthorizedError extends Error {
6
7
  wwwAuthenticate;
7
- constructor(wwwAuthenticate) {
8
- super('Upstream returned 401 Unauthorized');
8
+ status;
9
+ constructor(wwwAuthenticate, status = 401) {
10
+ super(status === 403 ? 'Upstream returned 403 Forbidden' : 'Upstream returned 401 Unauthorized');
9
11
  this.wwwAuthenticate = wwwAuthenticate;
12
+ this.status = status;
10
13
  this.name = 'UnauthorizedError';
11
14
  }
12
15
  }
16
+ /** A legacy upstream has one shared server session, so it cannot safely
17
+ * impersonate more than one passthrough caller at a time. */
18
+ class CredentialAffinityError extends Error {
19
+ constructor() {
20
+ super('The shared legacy upstream session belongs to another credential; retry after that session is closed');
21
+ this.name = 'CredentialAffinityError';
22
+ }
23
+ }
13
24
  // --- Helpers ---
14
25
  const REQUEST_TIMEOUT_MS = 120_000;
15
- const MAX_NOTIFICATION_BUFFER = 1000;
26
+ const BACKEND_PROBE_TIMEOUT_MS = 5_000;
16
27
  const MAX_STDOUT_LINE_LENGTH = 10 * 1024 * 1024; // 10 MB
17
28
  const MAX_SSE_BUFFER = 10 * 1024 * 1024; // 10 MB
18
- function isJsonRpcMessage(value) {
19
- return typeof value === 'object' && value !== null && 'jsonrpc' in value;
29
+ const MAX_REQUEST_BODY_BYTES = MAX_STDOUT_LINE_LENGTH;
30
+ const MAX_SSE_WRITE_QUEUE_BYTES = MAX_SSE_BUFFER;
31
+ const MAX_PRE_HEADER_EVENT_BYTES = MAX_SSE_BUFFER;
32
+ const SSE_KEEP_ALIVE_MS = 15_000;
33
+ const CURRENT_MODERN_PROTOCOL_VERSION = '2026-07-28';
34
+ const SUPPORTED_MODERN_PROTOCOL_VERSIONS = [CURRENT_MODERN_PROTOCOL_VERSION];
35
+ const LEGACY_PROTOCOL_VERSION = '2025-11-25';
36
+ const GATEWAY_CLIENT_INFO = { name: 'mcp-compose', version: '0.3.2' };
37
+ const MODERN_META_PROTOCOL_VERSION = 'io.modelcontextprotocol/protocolVersion';
38
+ const MODERN_META_CLIENT_INFO = 'io.modelcontextprotocol/clientInfo';
39
+ const MODERN_META_CLIENT_CAPABILITIES = 'io.modelcontextprotocol/clientCapabilities';
40
+ const MODERN_META_SERVER_INFO = 'io.modelcontextprotocol/serverInfo';
41
+ const SUBSCRIPTION_ID_META = 'io.modelcontextprotocol/subscriptionId';
42
+ const HTTP_RESPONSE_META = Symbol('gateway HTTP response metadata');
43
+ class UpstreamBodyTooLargeError extends Error {
44
+ constructor() {
45
+ super('Upstream response exceeds gateway limit');
46
+ this.name = 'UpstreamBodyTooLargeError';
47
+ }
48
+ }
49
+ function isRecord(value) {
50
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
51
+ }
52
+ function isModernProtocolVersion(version) {
53
+ return typeof version === 'string' && /^\d{4}-\d{2}-\d{2}$/.test(version)
54
+ && version >= CURRENT_MODERN_PROTOCOL_VERSION;
55
+ }
56
+ function hasModernProtocolMetaKey(message) {
57
+ return isRecord(message.params)
58
+ && isRecord(message.params['_meta'])
59
+ && hasOwn(message.params['_meta'], MODERN_META_PROTOCOL_VERSION);
60
+ }
61
+ function modernProtocolVersionFromMessage(message) {
62
+ if (!isRecord(message.params) || !isRecord(message.params['_meta']))
63
+ return undefined;
64
+ const version = message.params['_meta'][MODERN_META_PROTOCOL_VERSION];
65
+ return typeof version === 'string' ? version : undefined;
66
+ }
67
+ function isModernClientRequest(message, protocolVersionHeader) {
68
+ // A protocol-version meta member is an explicit attempt to use the modern
69
+ // transport, even if its value is malformed or unsupported. It must be
70
+ // validated below rather than falling through into a legacy session.
71
+ return hasModernProtocolMetaKey(message)
72
+ || isModernProtocolVersion(protocolVersionHeader);
73
+ }
74
+ function decodedMcpNameHeader(value) {
75
+ const encoded = /^=\?base64\?([A-Za-z0-9+/]*={0,2})\?=$/.exec(value);
76
+ if (!encoded)
77
+ return value;
78
+ try {
79
+ const payload = encoded[1];
80
+ if (payload === undefined)
81
+ return undefined;
82
+ const decoded = Buffer.from(payload, 'base64');
83
+ // Buffer silently accepts malformed base64, so require its canonical form.
84
+ if (decoded.toString('base64') !== encoded[1])
85
+ return undefined;
86
+ const text = decoded.toString('utf8');
87
+ // Node replaces malformed UTF-8 with U+FFFD. Reject it instead of
88
+ // accepting a lossy name that no longer identifies the JSON-RPC request.
89
+ if (!Buffer.from(text, 'utf8').equals(decoded))
90
+ return undefined;
91
+ return text;
92
+ }
93
+ catch {
94
+ return undefined;
95
+ }
96
+ }
97
+ function requestName(message) {
98
+ if (!isRecord(message.params))
99
+ return undefined;
100
+ if (message.method === 'tools/call' || message.method === 'prompts/get') {
101
+ return typeof message.params['name'] === 'string' ? message.params['name'] : undefined;
102
+ }
103
+ if (message.method === 'resources/read') {
104
+ return typeof message.params['uri'] === 'string' ? message.params['uri'] : undefined;
105
+ }
106
+ return undefined;
107
+ }
108
+ function requiresMcpName(method) {
109
+ return method === 'tools/call' || method === 'resources/read' || method === 'prompts/get';
110
+ }
111
+ function hasModernProtocolMeta(message, version) {
112
+ return modernProtocolVersionFromMessage(message) === version;
113
+ }
114
+ function hasRequiredModernClientCapabilities(message) {
115
+ if (!isRecord(message.params) || !isRecord(message.params['_meta']))
116
+ return false;
117
+ return isRecord(message.params['_meta'][MODERN_META_CLIENT_CAPABILITIES]);
118
+ }
119
+ function hasValidModernClientMetadata(message) {
120
+ if (!isRecord(message.params) || !isRecord(message.params['_meta']))
121
+ return false;
122
+ const meta = message.params['_meta'];
123
+ const clientInfo = meta[MODERN_META_CLIENT_INFO];
124
+ return clientInfo === undefined || (isRecord(clientInfo)
125
+ && typeof clientInfo['name'] === 'string' && typeof clientInfo['version'] === 'string');
126
+ }
127
+ function stripModernMeta(message) {
128
+ if (!isRecord(message.params) || !isRecord(message.params['_meta']))
129
+ return message;
130
+ const meta = Object.fromEntries(Object.entries(message.params['_meta'])
131
+ .filter(([key]) => key !== MODERN_META_PROTOCOL_VERSION
132
+ && key !== MODERN_META_CLIENT_INFO && key !== MODERN_META_CLIENT_CAPABILITIES));
133
+ return { ...message, params: { ...message.params, _meta: meta } };
134
+ }
135
+ function injectModernMeta(message, protocolVersion, clientCapabilities = {}, clientInfo = GATEWAY_CLIENT_INFO) {
136
+ const params = isRecord(message.params) ? message.params : {};
137
+ const meta = isRecord(params['_meta']) ? params['_meta'] : {};
138
+ return {
139
+ ...message,
140
+ params: {
141
+ ...params,
142
+ _meta: {
143
+ ...meta,
144
+ [MODERN_META_PROTOCOL_VERSION]: protocolVersion,
145
+ [MODERN_META_CLIENT_INFO]: clientInfo,
146
+ [MODERN_META_CLIENT_CAPABILITIES]: clientCapabilities,
147
+ },
148
+ },
149
+ };
150
+ }
151
+ function transformBackendResponse(response, method, clientEra, backendEra) {
152
+ if (clientEra === backendEra)
153
+ return response;
154
+ if (isRecord(response.error)) {
155
+ const error = { ...response.error };
156
+ // Only resource reads changed their not-found code. Extension methods are
157
+ // opaque and may legitimately use either value for their own semantics.
158
+ const isResourceRead = method === 'resources/read' || method === 'resources/templates/read';
159
+ if (isResourceRead && clientEra === 'modern' && backendEra === 'legacy' && error['code'] === -32002) {
160
+ error['code'] = -32602;
161
+ }
162
+ if (isResourceRead && clientEra === 'legacy' && backendEra === 'modern' && error['code'] === -32602) {
163
+ error['code'] = -32002;
164
+ }
165
+ return { ...response, error };
166
+ }
167
+ if (!isRecord(response.result))
168
+ return response;
169
+ const result = { ...response.result };
170
+ if (clientEra === 'modern' && backendEra === 'legacy')
171
+ result['resultType'] = 'complete';
172
+ if (clientEra === 'legacy' && backendEra === 'modern')
173
+ delete result['resultType'];
174
+ return { ...response, result };
175
+ }
176
+ function ensureModernCompleteResult(response) {
177
+ if (!isRecord(response.result) || hasOwn(response.result, 'resultType'))
178
+ return response;
179
+ return { ...response, result: { ...response.result, resultType: 'complete' } };
180
+ }
181
+ function discoverFromInitialize(initializeResult) {
182
+ const initialize = isRecord(initializeResult) ? initializeResult : {};
183
+ const result = {
184
+ supportedVersions: SUPPORTED_MODERN_PROTOCOL_VERSIONS,
185
+ resultType: 'complete',
186
+ };
187
+ if (hasOwn(initialize, 'capabilities'))
188
+ result['capabilities'] = initialize['capabilities'];
189
+ if (hasOwn(initialize, 'instructions'))
190
+ result['instructions'] = initialize['instructions'];
191
+ if (hasOwn(initialize, 'serverInfo')) {
192
+ result['_meta'] = { [MODERN_META_SERVER_INFO]: initialize['serverInfo'] };
193
+ }
194
+ return result;
195
+ }
196
+ function initializeFromDiscover(discoverResult) {
197
+ const discover = isRecord(discoverResult) ? discoverResult : {};
198
+ const meta = isRecord(discover['_meta']) ? discover['_meta'] : {};
199
+ const result = {
200
+ protocolVersion: LEGACY_PROTOCOL_VERSION,
201
+ capabilities: discover['capabilities'] ?? {},
202
+ serverInfo: meta[MODERN_META_SERVER_INFO] ?? GATEWAY_CLIENT_INFO,
203
+ };
204
+ if (hasOwn(discover, 'instructions'))
205
+ result['instructions'] = discover['instructions'];
206
+ return result;
207
+ }
208
+ function hasOwn(value, key) {
209
+ return Object.hasOwn(value, key);
210
+ }
211
+ function isJsonRpcId(value) {
212
+ return typeof value === 'string' || (typeof value === 'number' && Number.isSafeInteger(value));
20
213
  }
21
214
  function hasRequestId(msg) {
22
- return msg.id !== undefined && msg.id !== null;
215
+ return Object.hasOwn(msg, 'id');
216
+ }
217
+ function hasBackendRequestId(msg) {
218
+ return hasRequestId(msg);
219
+ }
220
+ function isJsonRpcError(value) {
221
+ return isRecord(value)
222
+ && typeof value['code'] === 'number'
223
+ && Number.isFinite(value['code'])
224
+ && typeof value['message'] === 'string';
225
+ }
226
+ /** A client or server request. Notifications are requests with no id member. */
227
+ function isJsonRpcRequest(value) {
228
+ if (!isRecord(value) || value['jsonrpc'] !== '2.0' || typeof value['method'] !== 'string')
229
+ return false;
230
+ if ((hasOwn(value, 'id') && !isJsonRpcId(value['id']))
231
+ || (hasOwn(value, 'params') && !isRecord(value['params'])))
232
+ return false;
233
+ return !hasOwn(value, 'result') && !hasOwn(value, 'error');
234
+ }
235
+ function isJsonRpcResponse(value) {
236
+ if (!isRecord(value) || value['jsonrpc'] !== '2.0' || !hasOwn(value, 'id') || !isJsonRpcId(value['id'])) {
237
+ return false;
238
+ }
239
+ if (hasOwn(value, 'method') || hasOwn(value, 'params'))
240
+ return false;
241
+ const hasResult = hasOwn(value, 'result');
242
+ const hasError = hasOwn(value, 'error');
243
+ return hasResult !== hasError && (!hasError || isJsonRpcError(value['error']));
244
+ }
245
+ function mcpNameHeaderValue(msg) {
246
+ const name = requestName(msg);
247
+ if (name === undefined)
248
+ return '';
249
+ return /^[\x21-\x7e](?:[\x20-\x7e]*[\x21-\x7e])?$/.test(name)
250
+ && !/^=\?base64\?.*\?=$/.test(name)
251
+ ? name
252
+ : `=?base64?${Buffer.from(name, 'utf8').toString('base64')}?=`;
253
+ }
254
+ function protocolVersionFromInitializeResponse(msg, response) {
255
+ if (msg.method !== 'initialize' || typeof response?.result !== 'object' || response.result === null) {
256
+ return undefined;
257
+ }
258
+ const protocolVersion = 'protocolVersion' in response.result
259
+ ? response.result.protocolVersion
260
+ : undefined;
261
+ return typeof protocolVersion === 'string' ? protocolVersion : undefined;
262
+ }
263
+ function supportedVersions(value) {
264
+ if (!isRecord(value))
265
+ return undefined;
266
+ const versions = value['supportedVersions'];
267
+ return Array.isArray(versions) && versions.every((version) => typeof version === 'string')
268
+ ? versions
269
+ : undefined;
270
+ }
271
+ function chooseSupportedModernVersion(value) {
272
+ const remoteVersions = supportedVersions(value);
273
+ if (!remoteVersions)
274
+ return undefined;
275
+ return SUPPORTED_MODERN_PROTOCOL_VERSIONS.find((version) => remoteVersions.includes(version));
276
+ }
277
+ /** Error response payloads use a pre-discovery shape in existing servers. */
278
+ function chooseSupportedModernErrorVersion(value) {
279
+ if (!isRecord(value))
280
+ return undefined;
281
+ const versions = value['supportedVersions'] ?? value['supported'];
282
+ if (!Array.isArray(versions) || !versions.every((version) => typeof version === 'string'))
283
+ return undefined;
284
+ return SUPPORTED_MODERN_PROTOCOL_VERSIONS.find((version) => versions.includes(version));
285
+ }
286
+ function isRecognizableModernError(message) {
287
+ if (!isRecord(message.error) || typeof message.error['code'] !== 'number')
288
+ return false;
289
+ // These codes were introduced for modern HTTP protocol validation and
290
+ // version negotiation. A legacy method-not-found response is intentionally
291
+ // not included: it is the signal to fall back to the legacy handshake.
292
+ return message.error['code'] === -32020 || message.error['code'] === -32021 || message.error['code'] === -32022;
293
+ }
294
+ function isRecognizableModernProbeError(response, requestId) {
295
+ return isMatchingResponse(response, requestId) && isRecognizableModernError(response);
296
+ }
297
+ function modernErrorDiscoveryVersion(response, requestId) {
298
+ if (!isMatchingResponse(response, requestId) || !isRecognizableModernError(response))
299
+ return undefined;
300
+ const errorData = isRecord(response.error) ? response.error['data'] : undefined;
301
+ return chooseSupportedModernErrorVersion(errorData);
302
+ }
303
+ /** A Streamable HTTP endpoint may reject the temporary discovery method while
304
+ * still proving it understands a correlated JSON-RPC 2.0 request. */
305
+ function isModernMissingDiscoveryMethod(response, requestId) {
306
+ return isMatchingResponse(response, requestId)
307
+ && isRecord(response.error)
308
+ && response.error['code'] === -32601
309
+ && responseMeta(response)?.status === 404;
310
+ }
311
+ function isMatchingResponse(response, requestId) {
312
+ return isJsonRpcResponse(response) && response.id === requestId;
313
+ }
314
+ function modernDiscoveryVersion(response, requestId) {
315
+ return isMatchingResponse(response, requestId)
316
+ ? chooseSupportedModernVersion(response.result)
317
+ : undefined;
318
+ }
319
+ function isLegacyInitializeResponse(response, requestId) {
320
+ return isMatchingResponse(response, requestId)
321
+ && isRecord(response.result)
322
+ && response.result['protocolVersion'] === LEGACY_PROTOCOL_VERSION;
323
+ }
324
+ function probeTimeout(operation) {
325
+ return new Promise((resolve, reject) => {
326
+ const controller = new AbortController();
327
+ const timer = setTimeout(() => {
328
+ controller.abort();
329
+ resolve(undefined);
330
+ }, BACKEND_PROBE_TIMEOUT_MS);
331
+ timer.unref();
332
+ operation(controller.signal).then((value) => {
333
+ clearTimeout(timer);
334
+ resolve(value);
335
+ }, (err) => {
336
+ clearTimeout(timer);
337
+ // Authentication challenges are actionable by the current client. Do
338
+ // not hide one as an inconclusive probe, or negotiation would fall
339
+ // through into a legacy handshake using the same invalid credential.
340
+ if (err instanceof UnauthorizedError)
341
+ reject(err);
342
+ else
343
+ resolve(undefined);
344
+ });
345
+ });
346
+ }
347
+ const CORS_ALLOWED_HEADERS = [
348
+ 'Authorization',
349
+ 'Content-Type',
350
+ 'Accept',
351
+ 'Mcp-Session-Id',
352
+ 'MCP-Protocol-Version',
353
+ 'Mcp-Method',
354
+ 'Mcp-Name',
355
+ ];
356
+ function corsAllowedHeaders(requestedHeaders) {
357
+ const allowed = new Map(CORS_ALLOWED_HEADERS.map((header) => [header.toLowerCase(), header]));
358
+ if (!requestedHeaders)
359
+ return CORS_ALLOWED_HEADERS.join(', ');
360
+ for (const requested of requestedHeaders.split(',')) {
361
+ const header = requested.trim();
362
+ // Header names are tokens, so this both limits the reflected prefix and
363
+ // prevents a malformed preflight header from becoming a response header.
364
+ if (/^mcp-param-[!#$%&'*+.^_`|~0-9a-z-]+$/i.test(header)) {
365
+ allowed.set(header.toLowerCase(), header);
366
+ }
367
+ }
368
+ return [...allowed.values()].join(', ');
369
+ }
370
+ function isSafeLocalOrigin(origin, port) {
371
+ let parsed;
372
+ try {
373
+ parsed = new URL(origin);
374
+ }
375
+ catch {
376
+ return false;
377
+ }
378
+ if (parsed.origin !== origin || (parsed.protocol !== 'http:' && parsed.protocol !== 'https:'))
379
+ return false;
380
+ // Do not trust a hostname merely because it resolves locally: DNS rebinding
381
+ // can change that resolution after validation. Only literal loopback names
382
+ // are accepted, at any local development port, plus this endpoint itself.
383
+ const loopback = parsed.hostname === '127.0.0.1'
384
+ || parsed.hostname === 'localhost' || parsed.hostname === '[::1]';
385
+ return loopback || parsed.origin === `http://127.0.0.1:${String(port)}`;
23
386
  }
24
387
  function readBody(req) {
25
388
  return new Promise((resolve, reject) => {
26
389
  const chunks = [];
27
- req.on('data', (chunk) => { chunks.push(chunk); });
28
- req.on('end', () => { resolve(Buffer.concat(chunks).toString('utf8')); });
390
+ let size = 0;
391
+ let tooLarge = false;
392
+ req.on('data', (chunk) => {
393
+ size += chunk.length;
394
+ if (size > MAX_REQUEST_BODY_BYTES) {
395
+ tooLarge = true;
396
+ chunks.length = 0;
397
+ // Consume the rest of the request rather than leaving a keep-alive
398
+ // socket with an unread body, but never retain it in memory.
399
+ req.resume();
400
+ return;
401
+ }
402
+ if (!tooLarge)
403
+ chunks.push(chunk);
404
+ });
405
+ req.on('end', () => {
406
+ if (tooLarge)
407
+ reject(new Error('Request body exceeds gateway limit'));
408
+ else
409
+ resolve(Buffer.concat(chunks).toString('utf8'));
410
+ });
29
411
  req.on('error', reject);
30
412
  });
31
413
  }
32
- let sseEventId = 0;
33
- function sendSSE(res, msg) {
34
- if (res.writableEnded || res.destroyed)
414
+ function safeResponseHeaders(headers) {
415
+ const safeHeaders = {};
416
+ // This is deliberately small: framing, connection, cookie, and arbitrary
417
+ // upstream headers belong to the upstream hop, not the gateway response.
418
+ for (const name of [
419
+ 'content-type', 'cache-control', 'www-authenticate', 'mcp-protocol-version',
420
+ ]) {
421
+ const value = headers.get(name);
422
+ if (value)
423
+ safeHeaders[name] = value;
424
+ }
425
+ return safeHeaders;
426
+ }
427
+ /** Consume a fetch body without allowing an upstream peer to allocate an
428
+ * unbounded string in the gateway. Aborting the owning request also stops a
429
+ * peer that keeps writing after the limit has been reached. */
430
+ async function readBoundedResponseText(response, controller) {
431
+ const reader = response.body?.getReader();
432
+ if (!reader)
433
+ return '';
434
+ const chunks = [];
435
+ let size = 0;
436
+ try {
437
+ for (;;) {
438
+ const { done, value } = await reader.read();
439
+ if (done)
440
+ break;
441
+ size += value.byteLength;
442
+ if (size > MAX_REQUEST_BODY_BYTES) {
443
+ controller?.abort(new UpstreamBodyTooLargeError());
444
+ await reader.cancel().catch(() => { });
445
+ throw new UpstreamBodyTooLargeError();
446
+ }
447
+ chunks.push(value);
448
+ }
449
+ return new TextDecoder().decode(Buffer.concat(chunks));
450
+ }
451
+ finally {
452
+ reader.releaseLock();
453
+ }
454
+ }
455
+ async function drainBoundedResponse(response, controller) {
456
+ await readBoundedResponseText(response, controller).catch(() => { });
457
+ }
458
+ function setHttpResponseMeta(message, status, headers) {
459
+ const safeHeaders = safeResponseHeaders(headers);
460
+ Object.defineProperty(message, HTTP_RESPONSE_META, {
461
+ value: { status, headers: safeHeaders },
462
+ enumerable: false,
463
+ });
464
+ return message;
465
+ }
466
+ function responseMeta(message) {
467
+ return message[HTTP_RESPONSE_META];
468
+ }
469
+ function createSseWriter(res) {
470
+ let queue = [];
471
+ let queueBytes = 0;
472
+ let draining = false;
473
+ let closed = false;
474
+ let ending = false;
475
+ let endPromise;
476
+ let resolveEnd;
477
+ const closeOverflow = () => {
478
+ closed = true;
479
+ queue = [];
480
+ queueBytes = 0;
481
+ // A reader that cannot drain a bounded queue must not pin an unbounded
482
+ // response buffer or an SSE subscription forever.
483
+ res.destroy();
35
484
  return false;
36
- return res.write(`event: message\nid: ${String(++sseEventId)}\ndata: ${JSON.stringify(msg)}\n\n`);
485
+ };
486
+ const finish = () => {
487
+ if (!ending || draining || queue.length > 0 || closed)
488
+ return;
489
+ closed = true;
490
+ res.removeListener('drain', onDrain);
491
+ if (res.writableEnded || res.destroyed) {
492
+ resolveEnd?.();
493
+ return;
494
+ }
495
+ res.end(() => { resolveEnd?.(); });
496
+ };
497
+ const flush = () => {
498
+ draining = false;
499
+ while (!closed && queue.length > 0) {
500
+ const chunk = queue.shift();
501
+ if (chunk === undefined)
502
+ continue;
503
+ queueBytes -= Buffer.byteLength(chunk);
504
+ if (!res.write(chunk)) {
505
+ draining = true;
506
+ return;
507
+ }
508
+ }
509
+ finish();
510
+ };
511
+ const write = (chunk) => {
512
+ if (closed || res.writableEnded || res.destroyed)
513
+ return false;
514
+ if (draining) {
515
+ const bytes = Buffer.byteLength(chunk);
516
+ if (queueBytes + bytes > MAX_SSE_WRITE_QUEUE_BYTES)
517
+ return closeOverflow();
518
+ queue.push(chunk);
519
+ queueBytes += bytes;
520
+ return true;
521
+ }
522
+ if (!res.write(chunk))
523
+ draining = true;
524
+ return true;
525
+ };
526
+ const onDrain = () => { flush(); };
527
+ const onClose = () => {
528
+ closed = true;
529
+ queue = [];
530
+ queueBytes = 0;
531
+ resolveEnd?.();
532
+ };
533
+ res.on('drain', onDrain);
534
+ res.once('close', onClose);
535
+ return {
536
+ writeMessage: (msg) => write(`event: message\r\ndata: ${JSON.stringify(msg)}\r\n\r\n`),
537
+ writeComment: () => write(':\r\n\r\n'),
538
+ end: () => {
539
+ if (endPromise)
540
+ return endPromise;
541
+ if (closed)
542
+ return Promise.resolve();
543
+ ending = true;
544
+ endPromise = new Promise((resolve) => { resolveEnd = resolve; });
545
+ finish();
546
+ return endPromise;
547
+ },
548
+ };
37
549
  }
38
550
  function makeErrorResponse(id, code, message) {
39
551
  return JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id });
@@ -51,8 +563,13 @@ function buildProtectedResourceUrls(remoteUrl) {
51
563
  return urls;
52
564
  }
53
565
  // --- Stdio Backend ---
54
- function createStdioBackend(command, logger) {
55
- const child = spawn(command, { stdio: ['pipe', 'pipe', 'pipe'], shell: true, detached: true });
566
+ function createStdioBackend(command, args, logger) {
567
+ const child = crossSpawn(command, args, {
568
+ stdio: ['pipe', 'pipe', 'pipe'],
569
+ shell: false,
570
+ detached: process.platform !== 'win32',
571
+ windowsHide: true,
572
+ });
56
573
  // stdio: ['pipe','pipe','pipe'] guarantees non-null streams
57
574
  if (!child.stdout || !child.stderr || !child.stdin) {
58
575
  throw new Error('Failed to create stdio pipes');
@@ -61,17 +578,52 @@ function createStdioBackend(command, logger) {
61
578
  const childStderr = child.stderr;
62
579
  const childStdin = child.stdin;
63
580
  const pendingRequests = new Map();
581
+ const cancelledRequestIds = new Set();
582
+ const cancelledRequestTimers = new Map();
64
583
  let stdoutBuffer = '';
65
584
  const backend = {
585
+ era: 'legacy',
586
+ ready: Promise.resolve(),
587
+ beginReadiness() { return this.ready; },
66
588
  onServerMessage: null,
67
589
  onClose: null,
68
- sendRequest(msg, _ctx) {
590
+ sendRequest(msg, _ctx, signal) {
69
591
  return new Promise((resolve) => {
70
- const timer = setTimeout(() => {
592
+ let settled = false;
593
+ const settle = (response) => {
594
+ if (settled)
595
+ return;
596
+ settled = true;
71
597
  pendingRequests.delete(msg.id);
72
- resolve({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'Request timeout' } });
598
+ clearTimeout(timer);
599
+ signal?.removeEventListener('abort', onAbort);
600
+ resolve(response);
601
+ };
602
+ const timer = setTimeout(() => {
603
+ settle({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'Request timeout' } });
73
604
  }, REQUEST_TIMEOUT_MS);
74
- pendingRequests.set(msg.id, { resolve, timer });
605
+ const onAbort = () => {
606
+ cancelledRequestIds.add(msg.id);
607
+ const oldTimer = cancelledRequestTimers.get(msg.id);
608
+ if (oldTimer)
609
+ clearTimeout(oldTimer);
610
+ const expiry = setTimeout(() => {
611
+ cancelledRequestIds.delete(msg.id);
612
+ cancelledRequestTimers.delete(msg.id);
613
+ }, REQUEST_TIMEOUT_MS);
614
+ expiry.unref();
615
+ cancelledRequestTimers.set(msg.id, expiry);
616
+ settle({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'Request cancelled' } });
617
+ };
618
+ pendingRequests.set(msg.id, {
619
+ resolve: (response) => { settle(response); },
620
+ timer,
621
+ });
622
+ if (signal?.aborted) {
623
+ onAbort();
624
+ return;
625
+ }
626
+ signal?.addEventListener('abort', onAbort, { once: true });
75
627
  childStdin.write(JSON.stringify(msg) + '\n');
76
628
  });
77
629
  },
@@ -89,17 +641,21 @@ function createStdioBackend(command, logger) {
89
641
  clearTimeout(pending.timer);
90
642
  }
91
643
  pendingRequests.clear();
92
- // Kill the entire process group (shell + actual MCP server process)
93
- try {
94
- if (child.pid)
95
- process.kill(-child.pid, 'SIGTERM');
96
- }
97
- catch {
98
- try {
99
- child.kill('SIGTERM');
100
- }
101
- catch { /* already dead */ }
644
+ for (const timer of cancelledRequestTimers.values())
645
+ clearTimeout(timer);
646
+ cancelledRequestTimers.clear();
647
+ cancelledRequestIds.clear();
648
+ if (child.pid)
649
+ killProcessTree(child.pid);
650
+ // Detach every stdio handle synchronously. Waiting only for SIGTERM's
651
+ // eventual exit event leaves an idle gateway process pinned by its pipes.
652
+ childStdin.destroy();
653
+ childStdout.destroy();
654
+ childStderr.destroy();
655
+ for (const stream of [childStdin, childStdout, childStderr]) {
656
+ stream.unref?.();
102
657
  }
658
+ child.unref();
103
659
  },
104
660
  };
105
661
  childStdout.on('data', (chunk) => {
@@ -116,18 +672,31 @@ function createStdioBackend(command, logger) {
116
672
  continue;
117
673
  try {
118
674
  const parsed = JSON.parse(line);
119
- if (!isJsonRpcMessage(parsed))
120
- continue;
121
- // Route response to pending request, or forward as server-initiated message
122
- if (hasRequestId(parsed) && pendingRequests.has(parsed.id)) {
123
- const pending = pendingRequests.get(parsed.id);
124
- if (pending) {
125
- pendingRequests.delete(parsed.id);
126
- clearTimeout(pending.timer);
127
- pending.resolve(parsed);
675
+ if (isJsonRpcResponse(parsed)) {
676
+ // A response is only meaningful to the matching pending request. Do
677
+ // not broadcast malformed or mismatched responses as notifications.
678
+ if (!hasBackendRequestId(parsed))
679
+ continue;
680
+ if (cancelledRequestIds.delete(parsed.id)) {
681
+ const timer = cancelledRequestTimers.get(parsed.id);
682
+ if (timer)
683
+ clearTimeout(timer);
684
+ cancelledRequestTimers.delete(parsed.id);
685
+ // A stdio peer cannot be force-cancelled at the protocol layer. Do
686
+ // not turn a late response to a locally cancelled request into a
687
+ // server notification for every connected client.
688
+ continue;
689
+ }
690
+ if (pendingRequests.has(parsed.id)) {
691
+ const pending = pendingRequests.get(parsed.id);
692
+ if (pending) {
693
+ pendingRequests.delete(parsed.id);
694
+ clearTimeout(pending.timer);
695
+ pending.resolve(parsed);
696
+ }
128
697
  }
129
698
  }
130
- else {
699
+ else if (isJsonRpcRequest(parsed)) {
131
700
  backend.onServerMessage?.(parsed);
132
701
  }
133
702
  }
@@ -160,42 +729,52 @@ function createStdioBackend(command, logger) {
160
729
  * in real time so in-flight clients see progress without waiting for the
161
730
  * entire response. Returns the JSON-RPC response matching originalMsg.
162
731
  */
163
- async function parseSseResponse(res, originalMsg, backend, logger) {
732
+ async function parseSseResponse(res, originalMsg, backend, logger, onEvent, controller) {
164
733
  const reader = res.body?.getReader();
165
734
  if (!reader)
166
735
  return undefined;
167
- const requestId = hasRequestId(originalMsg) ? originalMsg.id : null;
736
+ const requestId = hasBackendRequestId(originalMsg) ? originalMsg.id : undefined;
168
737
  const decoder = new TextDecoder();
169
738
  let buffer = '';
170
739
  let response;
171
740
  const handleBlock = (block) => {
172
- let data;
173
- for (const line of block.split('\n')) {
174
- if (line.startsWith('data: '))
175
- data = line.slice(6);
176
- else if (line.startsWith('data:'))
177
- data = line.slice(5);
741
+ const dataLines = [];
742
+ for (const line of block.split(/\r?\n/)) {
743
+ // SSE comments and fields other than data are intentionally ignored.
744
+ if (line.startsWith('data:'))
745
+ dataLines.push(line.slice(5).replace(/^ /, ''));
178
746
  }
179
- if (!data?.trim())
180
- return;
747
+ const data = dataLines.join('\n');
748
+ if (!data.trim())
749
+ return false;
181
750
  let parsed;
182
751
  try {
183
752
  parsed = JSON.parse(data);
184
753
  }
185
754
  catch {
186
- return;
755
+ return false;
187
756
  }
188
- if (!isJsonRpcMessage(parsed))
189
- return;
190
- if (requestId !== null && hasRequestId(parsed) && parsed.id === requestId) {
757
+ if (isJsonRpcResponse(parsed) && requestId !== undefined && parsed.id === requestId) {
191
758
  response = parsed;
759
+ return true;
192
760
  }
193
- else {
194
- backend.onServerMessage?.(parsed);
761
+ else if (isJsonRpcRequest(parsed)) {
762
+ if (hasRequestId(parsed)) {
763
+ // Server-initiated requests need MRTR to be unambiguous. This gateway
764
+ // intentionally has no MRTR bridge, so never leak them to another
765
+ // request or subscription stream.
766
+ logger('debug', `Dropping server-initiated request ${parsed.method ?? '<unknown>'}`);
767
+ }
768
+ else if (onEvent) {
769
+ onEvent(parsed);
770
+ }
771
+ else {
772
+ backend.onServerMessage?.(parsed);
773
+ }
195
774
  }
775
+ return false;
196
776
  };
197
777
  try {
198
- let searchFrom = 0;
199
778
  for (;;) {
200
779
  const { done, value } = await reader.read();
201
780
  if (done)
@@ -203,17 +782,22 @@ async function parseSseResponse(res, originalMsg, backend, logger) {
203
782
  buffer += decoder.decode(value, { stream: true });
204
783
  if (buffer.length > MAX_SSE_BUFFER) {
205
784
  logger('info', `SSE event exceeded ${String(MAX_SSE_BUFFER)} bytes without terminator; aborting`);
785
+ controller?.abort(new UpstreamBodyTooLargeError());
206
786
  await reader.cancel().catch(() => { });
207
- return response;
787
+ throw new UpstreamBodyTooLargeError();
208
788
  }
209
- let boundary = buffer.indexOf('\n\n', searchFrom);
210
- while (boundary !== -1) {
211
- handleBlock(buffer.slice(0, boundary));
212
- buffer = buffer.slice(boundary + 2);
213
- boundary = buffer.indexOf('\n\n');
789
+ let boundary = /\r?\n\r?\n/.exec(buffer);
790
+ while (boundary?.index !== undefined) {
791
+ const completed = handleBlock(buffer.slice(0, boundary.index));
792
+ buffer = buffer.slice(boundary.index + boundary[0].length);
793
+ if (completed) {
794
+ await reader.cancel().catch(() => { });
795
+ return response;
796
+ }
797
+ boundary = /\r?\n\r?\n/.exec(buffer);
214
798
  }
215
- searchFrom = Math.max(0, buffer.length - 1);
216
799
  }
800
+ buffer += decoder.decode();
217
801
  if (buffer.trim())
218
802
  handleBlock(buffer);
219
803
  }
@@ -226,107 +810,706 @@ async function parseSseResponse(res, originalMsg, backend, logger) {
226
810
  }
227
811
  return response;
228
812
  }
229
- function createProxyBackend(remoteUrl, headers, authMode, oauthClient, logger) {
813
+ function createProxyBackend(remoteUrl, transport, headers, authMode, oauthClient, logger) {
230
814
  let remoteSessionId;
815
+ let remoteSessionAuthorization;
816
+ let hasRemoteSessionAuthorization = false;
817
+ let negotiatedProtocolVersion;
231
818
  let accessToken;
232
- // Preload cached token (managed mode only) so the first request avoids
233
- // an unnecessary 401 roundtrip. Passthrough mode never holds tokens.
234
- const initialTokenLoad = oauthClient
235
- ? oauthClient.getAccessToken().then((token) => {
819
+ const activeFetches = new Set();
820
+ const ssePending = new Map();
821
+ let sseEndpoint;
822
+ let sseConnect;
823
+ let sseController;
824
+ // An old SSE transport has one shared GET connection even when its server
825
+ // does not issue Mcp-Session-Id. Bind that connection at creation time,
826
+ // rather than waiting for a header which some valid legacy servers omit.
827
+ let sseAuthorization;
828
+ let hasSseAuthorization = false;
829
+ function invalidateLegacySse(error) {
830
+ sseEndpoint = undefined;
831
+ remoteSessionId = undefined;
832
+ remoteSessionAuthorization = undefined;
833
+ hasRemoteSessionAuthorization = false;
834
+ sseAuthorization = undefined;
835
+ hasSseAuthorization = false;
836
+ sseConnect = undefined;
837
+ for (const [, pending] of ssePending) {
838
+ clearTimeout(pending.timer);
839
+ pending.reject(error);
840
+ }
841
+ ssePending.clear();
842
+ }
843
+ // Do not even read cached credentials until a client-triggered request
844
+ // reaches this backend. OAuth work is request-owned so an abandoned client
845
+ // or gateway stop can abort metadata and token I/O as well as fetches.
846
+ async function loadCachedToken(signal) {
847
+ if (!oauthClient || accessToken)
848
+ return;
849
+ try {
850
+ const token = await oauthClient.getAccessToken(signal ? { signal } : {});
236
851
  if (token && !accessToken)
237
852
  accessToken = token;
238
- }).catch(() => { })
239
- : Promise.resolve();
240
- async function forwardToRemote(msg, ctx, retryCount = 0) {
241
- await initialTokenLoad;
242
- const reqHeaders = {
243
- 'Content-Type': 'application/json',
244
- 'Accept': 'application/json, text/event-stream',
245
- ...headers,
246
- };
247
- if (authMode === 'passthrough') {
248
- if (ctx?.authorization)
249
- reqHeaders['Authorization'] = ctx.authorization;
250
853
  }
251
- else if (accessToken) {
252
- reqHeaders['Authorization'] = `Bearer ${accessToken}`;
854
+ catch (err) {
855
+ if (signal?.aborted)
856
+ throw err;
857
+ // A cache/discovery failure is not an auth failure. Let the upstream
858
+ // issue its normal challenge, which may select different metadata.
253
859
  }
254
- if (remoteSessionId && msg.method !== 'initialize') {
255
- reqHeaders['Mcp-Session-Id'] = remoteSessionId;
860
+ }
861
+ function assertLegacySessionAffinity(ctx) {
862
+ if (authMode !== 'passthrough' || !remoteSessionId)
863
+ return;
864
+ if (!hasRemoteSessionAuthorization || remoteSessionAuthorization !== ctx?.authorization) {
865
+ throw new CredentialAffinityError();
866
+ }
867
+ }
868
+ function assertLegacySseAffinity(ctx) {
869
+ if (authMode !== 'passthrough' || !hasSseAuthorization)
870
+ return;
871
+ if (sseAuthorization !== ctx?.authorization)
872
+ throw new CredentialAffinityError();
873
+ }
874
+ function claimLegacySseAffinity(ctx) {
875
+ assertLegacySseAffinity(ctx);
876
+ if (authMode === 'passthrough' && !hasSseAuthorization) {
877
+ sseAuthorization = ctx?.authorization;
878
+ hasSseAuthorization = true;
256
879
  }
257
- let res;
880
+ }
881
+ async function refreshAfterUnauthorized(wwwAuth, signal) {
882
+ if (!oauthClient)
883
+ return;
884
+ accessToken = await oauthClient.handleUnauthorized(wwwAuth, signal ? { signal } : {});
885
+ }
886
+ function isInsufficientScopeChallenge(status, wwwAuthenticate) {
887
+ if (status !== 403 || !wwwAuthenticate)
888
+ return false;
889
+ const bearer = /(?:^|,)\s*Bearer\s+([\s\S]*)/i.exec(wwwAuthenticate);
890
+ if (!bearer?.[1])
891
+ return false;
892
+ // Stop at a following auth-scheme, but retain comma-separated Bearer
893
+ // parameters such as scope and resource_metadata.
894
+ const challenge = bearer[1].split(/,\s*[A-Za-z][A-Za-z0-9_-]*\s+(?=[A-Za-z_-]+=)/)[0] ?? '';
895
+ return /(?:^|,)\s*error\s*=\s*(?:"insufficient_scope"|insufficient_scope)(?:\s*,|\s*$)/i.test(challenge);
896
+ }
897
+ async function forwardToRemote(msg, ctx, retryCount = 0, signal, onEvent, onResponseStart, deadline = Date.now() + REQUEST_TIMEOUT_MS) {
898
+ const controller = new AbortController();
899
+ const requestTimeout = new Error('Request timeout');
900
+ const remaining = deadline - Date.now();
901
+ if (remaining <= 0)
902
+ controller.abort(requestTimeout);
903
+ const timeout = setTimeout(() => { controller.abort(requestTimeout); }, Math.max(0, remaining));
904
+ const abortFromCaller = () => { controller.abort(signal?.reason); };
905
+ if (signal?.aborted)
906
+ controller.abort(signal.reason);
907
+ else
908
+ signal?.addEventListener('abort', abortFromCaller, { once: true });
909
+ activeFetches.add(controller);
258
910
  try {
259
- res = await fetch(remoteUrl, {
260
- method: 'POST',
261
- headers: reqHeaders,
262
- body: JSON.stringify(msg),
911
+ await loadCachedToken(controller.signal);
912
+ assertLegacySessionAffinity(ctx);
913
+ const inboundAccept = ctx?.accept;
914
+ const accepts = (mediaType) => {
915
+ const escaped = mediaType.replace('/', '\\/');
916
+ const match = new RegExp(`(?:^|,)\\s*${escaped}(?:\\s*;([^,]*))?(?:,|$)`, 'i').exec(inboundAccept ?? '');
917
+ return match !== null && !/(?:^|;)\\s*q\\s*=\\s*0(?:\\.0*)?\\s*(?:;|$)/i.test(match[1] ?? '');
918
+ };
919
+ const accept = [
920
+ inboundAccept,
921
+ !accepts('application/json') ? 'application/json' : undefined,
922
+ !accepts('text/event-stream') ? 'text/event-stream' : undefined,
923
+ ].filter((value) => value !== undefined).join(', ');
924
+ const reqHeaders = new Headers({
925
+ ...headers,
926
+ 'Content-Type': 'application/json',
927
+ 'Accept': accept,
263
928
  });
929
+ const protocolVersion = ctx?.protocolVersion ?? backend.protocolVersion ?? negotiatedProtocolVersion;
930
+ if (protocolVersion)
931
+ reqHeaders.set('MCP-Protocol-Version', protocolVersion);
932
+ reqHeaders.set('Mcp-Method', msg.method ?? '');
933
+ if (requestName(msg) !== undefined)
934
+ reqHeaders.set('Mcp-Name', mcpNameHeaderValue(msg));
935
+ for (const [name, value] of ctx?.mcpParamHeaders ?? []) {
936
+ reqHeaders.append(name, value);
937
+ }
938
+ if (authMode === 'passthrough') {
939
+ if (ctx?.authorization)
940
+ reqHeaders.set('Authorization', ctx.authorization);
941
+ }
942
+ else if (accessToken) {
943
+ reqHeaders.set('Authorization', `Bearer ${accessToken}`);
944
+ }
945
+ if (backend.era === 'legacy' && remoteSessionId && msg.method !== 'initialize') {
946
+ reqHeaders.set('Mcp-Session-Id', remoteSessionId);
947
+ }
948
+ let res;
949
+ try {
950
+ res = await fetch(remoteUrl, {
951
+ method: 'POST',
952
+ headers: reqHeaders,
953
+ body: JSON.stringify(msg),
954
+ signal: controller.signal,
955
+ redirect: 'error',
956
+ });
957
+ }
958
+ catch (err) {
959
+ const errMsg = err instanceof Error ? err.message : String(err);
960
+ logger('info', `Remote fetch failed: ${errMsg}`);
961
+ return {
962
+ jsonrpc: '2.0',
963
+ id: hasRequestId(msg) ? msg.id : null,
964
+ error: {
965
+ code: -32000,
966
+ message: controller.signal.reason === requestTimeout
967
+ ? 'Request timeout'
968
+ : `Remote server unreachable: ${errMsg}`,
969
+ },
970
+ };
971
+ }
972
+ if (res.status === 401 || isInsufficientScopeChallenge(res.status, res.headers.get('www-authenticate') ?? undefined)) {
973
+ const wwwAuth = res.headers.get('www-authenticate') ?? undefined;
974
+ if (authMode === 'passthrough' && res.status === 401) {
975
+ throw new UnauthorizedError(wwwAuth);
976
+ }
977
+ if (oauthClient && retryCount < 2) {
978
+ // Only a response that will actually be retried may be consumed.
979
+ await drainBoundedResponse(res, controller);
980
+ logger('info', `Received ${String(res.status)} authentication challenge, starting OAuth flow...`);
981
+ await refreshAfterUnauthorized(wwwAuth, controller.signal);
982
+ logger('info', 'OAuth completed, retrying request...');
983
+ return await forwardToRemote(msg, ctx, retryCount + 1, signal, onEvent, onResponseStart, deadline);
984
+ }
985
+ }
986
+ const sessionHeader = res.headers.get('mcp-session-id');
987
+ if (sessionHeader) {
988
+ remoteSessionId = sessionHeader;
989
+ // A legacy upstream session is owned by the client credential that
990
+ // created it. Keep that exact credential solely for its cleanup.
991
+ remoteSessionAuthorization = authMode === 'passthrough' ? ctx?.authorization : undefined;
992
+ hasRemoteSessionAuthorization = authMode === 'passthrough';
993
+ }
994
+ if (res.status === 202) {
995
+ await drainBoundedResponse(res, controller);
996
+ return undefined;
997
+ }
998
+ // HTTP status and safe response headers are available as soon as fetch
999
+ // resolves, before the potentially long-lived SSE body is decoded.
1000
+ // Commit downstream SSE framing here so notifications can flow live.
1001
+ onResponseStart?.({ status: res.status, headers: safeResponseHeaders(res.headers) });
1002
+ if (!res.ok) {
1003
+ let text;
1004
+ try {
1005
+ text = await readBoundedResponseText(res, controller);
1006
+ }
1007
+ catch (err) {
1008
+ if (err instanceof UpstreamBodyTooLargeError) {
1009
+ return {
1010
+ jsonrpc: '2.0', id: hasRequestId(msg) ? msg.id : null,
1011
+ error: { code: -32000, message: err.message },
1012
+ };
1013
+ }
1014
+ throw err;
1015
+ }
1016
+ logger('info', `Remote server returned HTTP ${String(res.status)}`);
1017
+ try {
1018
+ const response = JSON.parse(text);
1019
+ if (isJsonRpcResponse(response))
1020
+ return setHttpResponseMeta(response, res.status, res.headers);
1021
+ }
1022
+ catch {
1023
+ // A non-JSON error cannot identify a modern JSON-RPC endpoint.
1024
+ }
1025
+ return {
1026
+ jsonrpc: '2.0',
1027
+ id: hasRequestId(msg) ? msg.id : null,
1028
+ error: { code: -32000, message: `Remote server error: ${String(res.status)}` },
1029
+ };
1030
+ }
1031
+ const contentType = res.headers.get('content-type') ?? '';
1032
+ if (contentType.includes('text/event-stream')) {
1033
+ let response;
1034
+ try {
1035
+ response = await parseSseResponse(res, msg, backend, logger, onEvent, controller);
1036
+ }
1037
+ catch (err) {
1038
+ if (err instanceof UpstreamBodyTooLargeError) {
1039
+ return {
1040
+ jsonrpc: '2.0', id: hasRequestId(msg) ? msg.id : null,
1041
+ error: { code: -32000, message: err.message },
1042
+ };
1043
+ }
1044
+ throw err;
1045
+ }
1046
+ negotiatedProtocolVersion ??= protocolVersionFromInitializeResponse(msg, response);
1047
+ return response && setHttpResponseMeta(response, res.status, res.headers);
1048
+ }
1049
+ let response;
1050
+ try {
1051
+ response = JSON.parse(await readBoundedResponseText(res, controller));
1052
+ }
1053
+ catch (err) {
1054
+ if (err instanceof UpstreamBodyTooLargeError) {
1055
+ return {
1056
+ jsonrpc: '2.0', id: hasRequestId(msg) ? msg.id : null,
1057
+ error: { code: -32000, message: err.message },
1058
+ };
1059
+ }
1060
+ return {
1061
+ jsonrpc: '2.0', id: hasRequestId(msg) ? msg.id : null,
1062
+ error: { code: -32603, message: 'Remote server returned an invalid JSON-RPC response' },
1063
+ };
1064
+ }
1065
+ if (!isJsonRpcResponse(response)) {
1066
+ return {
1067
+ jsonrpc: '2.0',
1068
+ id: hasRequestId(msg) ? msg.id : null,
1069
+ error: { code: -32603, message: 'Remote server returned an invalid JSON-RPC response' },
1070
+ };
1071
+ }
1072
+ negotiatedProtocolVersion ??= protocolVersionFromInitializeResponse(msg, response);
1073
+ return setHttpResponseMeta(response, res.status, res.headers);
264
1074
  }
265
- catch (err) {
266
- const errMsg = err instanceof Error ? err.message : String(err);
267
- logger('info', `Remote fetch failed: ${errMsg}`);
268
- return {
269
- jsonrpc: '2.0',
270
- id: hasRequestId(msg) ? msg.id : null,
271
- error: { code: -32000, message: `Remote server unreachable: ${errMsg}` },
272
- };
1075
+ finally {
1076
+ clearTimeout(timeout);
1077
+ signal?.removeEventListener('abort', abortFromCaller);
1078
+ activeFetches.delete(controller);
273
1079
  }
274
- if (res.status === 401) {
275
- const wwwAuth = res.headers.get('www-authenticate') ?? undefined;
276
- if (authMode === 'passthrough') {
277
- // Drain body so the connection can be reused.
278
- await res.text().catch(() => { });
279
- throw new UnauthorizedError(wwwAuth);
1080
+ }
1081
+ function sseHeaders(ctx) {
1082
+ const requestHeaders = new Headers({ ...headers, Accept: 'text/event-stream' });
1083
+ if (ctx?.protocolVersion)
1084
+ requestHeaders.set('MCP-Protocol-Version', ctx.protocolVersion);
1085
+ for (const [name, value] of ctx?.mcpParamHeaders ?? [])
1086
+ requestHeaders.append(name, value);
1087
+ if (authMode === 'passthrough') {
1088
+ if (ctx?.authorization)
1089
+ requestHeaders.set('Authorization', ctx.authorization);
1090
+ }
1091
+ else if (accessToken) {
1092
+ requestHeaders.set('Authorization', `Bearer ${accessToken}`);
1093
+ }
1094
+ return requestHeaders;
1095
+ }
1096
+ async function connectLegacySse(ctx, signal) {
1097
+ assertLegacySessionAffinity(ctx);
1098
+ assertLegacySseAffinity(ctx);
1099
+ if (sseEndpoint)
1100
+ return;
1101
+ if (sseConnect)
1102
+ return sseConnect;
1103
+ claimLegacySseAffinity(ctx);
1104
+ sseConnect = (async () => {
1105
+ const controller = new AbortController();
1106
+ const abortFromCaller = () => { controller.abort(signal?.reason); };
1107
+ if (signal?.aborted)
1108
+ controller.abort(signal.reason);
1109
+ else
1110
+ signal?.addEventListener('abort', abortFromCaller, { once: true });
1111
+ const timeout = setTimeout(() => { controller.abort(new Error('Legacy SSE connection timed out')); }, REQUEST_TIMEOUT_MS);
1112
+ timeout.unref();
1113
+ await loadCachedToken(controller.signal);
1114
+ sseController = controller;
1115
+ activeFetches.add(controller);
1116
+ let endpointResolve;
1117
+ let endpointReject;
1118
+ const endpointReady = new Promise((resolve, reject) => {
1119
+ endpointResolve = resolve;
1120
+ endpointReject = reject;
1121
+ });
1122
+ try {
1123
+ let response;
1124
+ for (let retryCount = 0; retryCount <= 2; retryCount += 1) {
1125
+ response = await fetch(remoteUrl, {
1126
+ method: 'GET', headers: sseHeaders(ctx), signal: controller.signal, redirect: 'error',
1127
+ });
1128
+ const wwwAuth = response.headers.get('www-authenticate') ?? undefined;
1129
+ if (response.status !== 401 && !isInsufficientScopeChallenge(response.status, wwwAuth))
1130
+ break;
1131
+ await drainBoundedResponse(response, controller);
1132
+ if (authMode === 'passthrough')
1133
+ throw new UnauthorizedError(wwwAuth, response.status);
1134
+ if (!oauthClient || retryCount === 2)
1135
+ break;
1136
+ await refreshAfterUnauthorized(wwwAuth, controller.signal);
1137
+ }
1138
+ if (!response)
1139
+ throw new Error('Legacy SSE endpoint did not return a response');
1140
+ if (!response.ok || !response.body) {
1141
+ await drainBoundedResponse(response, controller);
1142
+ const wwwAuth = response.headers.get('www-authenticate') ?? undefined;
1143
+ if (response.status === 401 || isInsufficientScopeChallenge(response.status, wwwAuth)) {
1144
+ throw new UnauthorizedError(wwwAuth, response.status);
1145
+ }
1146
+ throw new Error(`Legacy SSE endpoint returned ${String(response.status)}`);
1147
+ }
1148
+ const remoteSession = response.headers.get('mcp-session-id');
1149
+ if (remoteSession) {
1150
+ remoteSessionId = remoteSession;
1151
+ remoteSessionAuthorization = authMode === 'passthrough' ? ctx?.authorization : undefined;
1152
+ hasRemoteSessionAuthorization = authMode === 'passthrough';
1153
+ }
1154
+ const reader = response.body.getReader();
1155
+ const decoder = new TextDecoder();
1156
+ let buffer = '';
1157
+ const rejectInvalidEndpoint = (error) => {
1158
+ // An endpoint event is untrusted input. Abort both the fetch and its
1159
+ // reader before rejecting readiness so an invalid event cannot leave
1160
+ // a credential-bearing GET stream alive in the background.
1161
+ endpointReject?.(error);
1162
+ controller.abort(error);
1163
+ void reader.cancel(error).catch(() => { });
1164
+ };
1165
+ const handleBlock = (block) => {
1166
+ let event = '';
1167
+ const data = [];
1168
+ for (const line of block.split(/\r?\n/)) {
1169
+ if (line.startsWith('event:'))
1170
+ event = line.slice(6).trim();
1171
+ if (line.startsWith('data:'))
1172
+ data.push(line.slice(5).replace(/^ /, ''));
1173
+ }
1174
+ const payload = data.join('\n');
1175
+ if (!payload)
1176
+ return;
1177
+ if (event === 'endpoint') {
1178
+ try {
1179
+ const endpoint = new URL(payload, remoteUrl);
1180
+ const configured = new URL(remoteUrl);
1181
+ if ((endpoint.protocol !== 'http:' && endpoint.protocol !== 'https:')
1182
+ || endpoint.origin !== configured.origin
1183
+ || endpoint.username !== configured.username
1184
+ || endpoint.password !== configured.password) {
1185
+ throw new Error('Legacy SSE endpoint must use the configured upstream origin');
1186
+ }
1187
+ // The endpoint is a one-time connection property. A later
1188
+ // endpoint event must not retarget an established session.
1189
+ if (sseEndpoint)
1190
+ return;
1191
+ sseEndpoint = endpoint.toString();
1192
+ endpointResolve?.();
1193
+ }
1194
+ catch (err) {
1195
+ rejectInvalidEndpoint(err instanceof Error
1196
+ ? err : new Error('Legacy SSE endpoint event contained an invalid URL'));
1197
+ }
1198
+ return;
1199
+ }
1200
+ try {
1201
+ const message = JSON.parse(payload);
1202
+ if (isJsonRpcResponse(message) && hasBackendRequestId(message)) {
1203
+ const pending = ssePending.get(message.id);
1204
+ if (pending) {
1205
+ ssePending.delete(message.id);
1206
+ clearTimeout(pending.timer);
1207
+ pending.resolve(message);
1208
+ }
1209
+ }
1210
+ else if (isJsonRpcRequest(message)) {
1211
+ if (hasRequestId(message))
1212
+ logger('debug', `Dropping server-initiated request ${message.method ?? '<unknown>'}`);
1213
+ else
1214
+ backend.onServerMessage?.(message);
1215
+ }
1216
+ }
1217
+ catch {
1218
+ logger('debug', 'Ignoring invalid legacy SSE event');
1219
+ }
1220
+ };
1221
+ const streamFinished = (async () => {
1222
+ try {
1223
+ for (;;) {
1224
+ const { done, value } = await reader.read();
1225
+ if (done)
1226
+ break;
1227
+ buffer += decoder.decode(value, { stream: true });
1228
+ if (buffer.length > MAX_SSE_BUFFER) {
1229
+ controller.abort(new UpstreamBodyTooLargeError());
1230
+ throw new UpstreamBodyTooLargeError();
1231
+ }
1232
+ let boundary = /\r?\n\r?\n/.exec(buffer);
1233
+ while (boundary?.index !== undefined) {
1234
+ handleBlock(buffer.slice(0, boundary.index));
1235
+ buffer = buffer.slice(boundary.index + boundary[0].length);
1236
+ boundary = /\r?\n\r?\n/.exec(buffer);
1237
+ }
1238
+ }
1239
+ buffer += decoder.decode();
1240
+ if (buffer.trim())
1241
+ handleBlock(buffer);
1242
+ }
1243
+ catch (err) {
1244
+ if (!controller.signal.aborted)
1245
+ logger('info', `Legacy SSE stream failed: ${err instanceof Error ? err.message : String(err)}`);
1246
+ }
1247
+ finally {
1248
+ try {
1249
+ reader.releaseLock();
1250
+ }
1251
+ catch { /* ignore */ }
1252
+ clearTimeout(timeout);
1253
+ signal?.removeEventListener('abort', abortFromCaller);
1254
+ activeFetches.delete(controller);
1255
+ if (sseController === controller)
1256
+ sseController = undefined;
1257
+ const streamError = new Error(controller.signal.aborted
1258
+ ? 'Legacy SSE stream aborted' : 'Legacy SSE stream closed');
1259
+ endpointReject?.(streamError);
1260
+ invalidateLegacySse(streamError);
1261
+ }
1262
+ })();
1263
+ try {
1264
+ await endpointReady;
1265
+ }
1266
+ catch (err) {
1267
+ const error = err instanceof Error ? err : new Error(String(err));
1268
+ controller.abort(error);
1269
+ await streamFinished;
1270
+ throw error;
1271
+ }
1272
+ // The connection now belongs to the shared legacy session, not to
1273
+ // the request that happened to establish it.
1274
+ signal?.removeEventListener('abort', abortFromCaller);
280
1275
  }
281
- if (oauthClient && retryCount < 2) {
282
- logger('info', 'Received 401, starting OAuth flow...');
283
- accessToken = await oauthClient.handleUnauthorized(wwwAuth);
284
- logger('info', 'OAuth completed, retrying request...');
285
- return await forwardToRemote(msg, ctx, retryCount + 1);
1276
+ finally {
1277
+ if (!sseEndpoint) {
1278
+ clearTimeout(timeout);
1279
+ signal?.removeEventListener('abort', abortFromCaller);
1280
+ activeFetches.delete(controller);
1281
+ if (sseController === controller)
1282
+ sseController = undefined;
1283
+ }
286
1284
  }
1285
+ })();
1286
+ try {
1287
+ await sseConnect;
287
1288
  }
288
- const sessionHeader = res.headers.get('mcp-session-id');
289
- if (sessionHeader) {
290
- remoteSessionId = sessionHeader;
1289
+ catch (err) {
1290
+ sseConnect = undefined;
1291
+ sseEndpoint = undefined;
1292
+ throw err;
291
1293
  }
292
- if (res.status === 202)
293
- return undefined;
294
- if (!res.ok) {
295
- const text = await res.text();
296
- logger('info', `Remote error: ${String(res.status)} ${text.slice(0, 200)}`);
297
- return {
298
- jsonrpc: '2.0',
299
- id: hasRequestId(msg) ? msg.id : null,
300
- error: { code: -32000, message: `Remote server error: ${String(res.status)}` },
1294
+ }
1295
+ async function sendOverLegacySse(msg, ctx, signal) {
1296
+ await connectLegacySse(ctx, signal);
1297
+ assertLegacySessionAffinity(ctx);
1298
+ assertLegacySseAffinity(ctx);
1299
+ const endpoint = sseEndpoint;
1300
+ if (!endpoint)
1301
+ throw new Error('Legacy SSE endpoint is unavailable');
1302
+ return await new Promise((resolve, reject) => {
1303
+ let settled = false;
1304
+ const postController = new AbortController();
1305
+ const postTimeout = setTimeout(() => { postController.abort(new Error('Request timeout')); }, REQUEST_TIMEOUT_MS);
1306
+ postTimeout.unref();
1307
+ const settle = (response) => {
1308
+ if (settled)
1309
+ return;
1310
+ settled = true;
1311
+ ssePending.delete(msg.id);
1312
+ clearTimeout(timer);
1313
+ clearTimeout(postTimeout);
1314
+ signal?.removeEventListener('abort', onAbort);
1315
+ activeFetches.delete(postController);
1316
+ resolve(response);
1317
+ };
1318
+ const fail = (error) => {
1319
+ if (settled)
1320
+ return;
1321
+ settled = true;
1322
+ ssePending.delete(msg.id);
1323
+ clearTimeout(timer);
1324
+ clearTimeout(postTimeout);
1325
+ signal?.removeEventListener('abort', onAbort);
1326
+ activeFetches.delete(postController);
1327
+ reject(error);
1328
+ };
1329
+ const timer = setTimeout(() => {
1330
+ settle({
1331
+ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'Request timeout' },
1332
+ });
1333
+ }, REQUEST_TIMEOUT_MS);
1334
+ const onAbort = () => {
1335
+ postController.abort(signal?.reason);
1336
+ settle({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'Request cancelled' } });
301
1337
  };
1338
+ if (signal?.aborted) {
1339
+ onAbort();
1340
+ return;
1341
+ }
1342
+ signal?.addEventListener('abort', onAbort, { once: true });
1343
+ ssePending.set(msg.id, { resolve: settle, reject: fail, timer });
1344
+ const requestHeaders = sseHeaders(ctx);
1345
+ requestHeaders.set('Content-Type', 'application/json');
1346
+ requestHeaders.set('Mcp-Method', msg.method ?? '');
1347
+ if (requestName(msg) !== undefined)
1348
+ requestHeaders.set('Mcp-Name', mcpNameHeaderValue(msg));
1349
+ if (remoteSessionId)
1350
+ requestHeaders.set('Mcp-Session-Id', remoteSessionId);
1351
+ activeFetches.add(postController);
1352
+ const postMessage = async (retryCount = 0) => {
1353
+ const response = await fetch(endpoint, {
1354
+ method: 'POST', headers: requestHeaders, body: JSON.stringify(msg), signal: postController.signal, redirect: 'error',
1355
+ });
1356
+ if (response.ok || response.status === 202) {
1357
+ await drainBoundedResponse(response, postController);
1358
+ return;
1359
+ }
1360
+ if (response.status === 401 || isInsufficientScopeChallenge(response.status, response.headers.get('www-authenticate') ?? undefined)) {
1361
+ const wwwAuth = response.headers.get('www-authenticate') ?? undefined;
1362
+ if (authMode === 'passthrough') {
1363
+ await drainBoundedResponse(response, postController);
1364
+ fail(new UnauthorizedError(wwwAuth, response.status));
1365
+ return;
1366
+ }
1367
+ if (!oauthClient || retryCount === 2) {
1368
+ await drainBoundedResponse(response, postController);
1369
+ fail(new UnauthorizedError(wwwAuth, response.status));
1370
+ return;
1371
+ }
1372
+ await drainBoundedResponse(response, postController);
1373
+ await refreshAfterUnauthorized(wwwAuth, postController.signal);
1374
+ if (accessToken)
1375
+ requestHeaders.set('Authorization', `Bearer ${accessToken}`);
1376
+ await postMessage(retryCount + 1);
1377
+ return;
1378
+ }
1379
+ let text;
1380
+ try {
1381
+ text = await readBoundedResponseText(response, postController);
1382
+ }
1383
+ catch (err) {
1384
+ if (err instanceof UpstreamBodyTooLargeError) {
1385
+ settle({ jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: err.message } });
1386
+ return;
1387
+ }
1388
+ throw err;
1389
+ }
1390
+ settle({ jsonrpc: '2.0', id: msg.id, error: {
1391
+ code: -32000, message: `Legacy SSE message post failed: ${String(response.status)} ${text.slice(0, 200)}`,
1392
+ } });
1393
+ };
1394
+ void postMessage().catch((err) => {
1395
+ if (postController.signal.aborted)
1396
+ return;
1397
+ fail(err instanceof Error ? err : new Error(String(err)));
1398
+ });
1399
+ });
1400
+ }
1401
+ async function sendLegacySseNotification(msg, ctx, signal) {
1402
+ await connectLegacySse(ctx, signal);
1403
+ assertLegacySessionAffinity(ctx);
1404
+ assertLegacySseAffinity(ctx);
1405
+ const endpoint = sseEndpoint;
1406
+ if (!endpoint)
1407
+ throw new Error('Legacy SSE endpoint is unavailable');
1408
+ const controller = new AbortController();
1409
+ const abortFromCaller = () => { controller.abort(signal?.reason); };
1410
+ if (signal?.aborted)
1411
+ controller.abort(signal.reason);
1412
+ else
1413
+ signal?.addEventListener('abort', abortFromCaller, { once: true });
1414
+ const timeout = setTimeout(() => { controller.abort(new Error('Request timeout')); }, REQUEST_TIMEOUT_MS);
1415
+ timeout.unref();
1416
+ activeFetches.add(controller);
1417
+ try {
1418
+ for (let retryCount = 0; retryCount <= 2; retryCount += 1) {
1419
+ const requestHeaders = sseHeaders(ctx);
1420
+ requestHeaders.set('Content-Type', 'application/json');
1421
+ requestHeaders.set('Mcp-Method', msg.method ?? '');
1422
+ if (requestName(msg) !== undefined)
1423
+ requestHeaders.set('Mcp-Name', mcpNameHeaderValue(msg));
1424
+ if (remoteSessionId)
1425
+ requestHeaders.set('Mcp-Session-Id', remoteSessionId);
1426
+ const response = await fetch(endpoint, {
1427
+ method: 'POST', headers: requestHeaders, body: JSON.stringify(msg), signal: controller.signal, redirect: 'error',
1428
+ });
1429
+ if (response.status === 401 || isInsufficientScopeChallenge(response.status, response.headers.get('www-authenticate') ?? undefined)) {
1430
+ const wwwAuth = response.headers.get('www-authenticate') ?? undefined;
1431
+ if (authMode === 'passthrough') {
1432
+ await drainBoundedResponse(response, controller);
1433
+ throw new UnauthorizedError(wwwAuth, response.status);
1434
+ }
1435
+ if (!oauthClient || retryCount === 2) {
1436
+ await drainBoundedResponse(response, controller);
1437
+ throw new UnauthorizedError(wwwAuth, response.status);
1438
+ }
1439
+ await drainBoundedResponse(response, controller);
1440
+ await refreshAfterUnauthorized(wwwAuth, controller.signal);
1441
+ continue;
1442
+ }
1443
+ if (!response.ok && response.status !== 202) {
1444
+ try {
1445
+ await readBoundedResponseText(response, controller);
1446
+ }
1447
+ catch (err) {
1448
+ if (err instanceof UpstreamBodyTooLargeError)
1449
+ throw err;
1450
+ }
1451
+ throw new Error(`Legacy SSE notification post failed: ${String(response.status)}`);
1452
+ }
1453
+ await drainBoundedResponse(response, controller);
1454
+ return;
1455
+ }
302
1456
  }
303
- const contentType = res.headers.get('content-type') ?? '';
304
- // SSE response: parse events and extract JSON-RPC messages
305
- if (contentType.includes('text/event-stream')) {
306
- return await parseSseResponse(res, msg, backend, logger);
1457
+ finally {
1458
+ clearTimeout(timeout);
1459
+ signal?.removeEventListener('abort', abortFromCaller);
1460
+ activeFetches.delete(controller);
307
1461
  }
308
- return await res.json();
309
1462
  }
310
1463
  const backend = {
1464
+ era: 'legacy',
1465
+ ready: Promise.resolve(),
1466
+ beginReadiness() { return this.ready; },
311
1467
  onServerMessage: null,
312
1468
  onClose: null,
313
- async sendRequest(msg, ctx) {
314
- const response = await forwardToRemote(msg, ctx);
1469
+ async sendRequest(msg, ctx, signal, onEvent, onResponseStart) {
1470
+ if (transport === 'sse')
1471
+ return sendOverLegacySse(msg, ctx, signal);
1472
+ const response = await forwardToRemote(msg, ctx, 0, signal, onEvent, onResponseStart);
315
1473
  return response ?? { jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'No response from remote' } };
316
1474
  },
317
- sendNotification(msg, ctx) {
318
- forwardToRemote(msg, ctx).catch((err) => {
1475
+ sendNotification(msg, ctx, signal) {
1476
+ if (transport === 'sse') {
1477
+ void sendLegacySseNotification(msg, ctx, signal).catch((err) => {
1478
+ logger('debug', `Notification forward failed: ${err instanceof Error ? err.message : String(err)}`);
1479
+ });
1480
+ return;
1481
+ }
1482
+ forwardToRemote(msg, ctx, 0, signal).catch((err) => {
319
1483
  const errMsg = err instanceof Error ? err.message : String(err);
320
1484
  logger('debug', `Notification forward failed: ${errMsg}`);
321
1485
  });
322
1486
  },
323
1487
  close() {
1488
+ sseController?.abort();
1489
+ for (const [id, pending] of ssePending) {
1490
+ clearTimeout(pending.timer);
1491
+ pending.reject(new Error(`Gateway stopped while waiting for legacy SSE request ${String(id)}`));
1492
+ }
1493
+ ssePending.clear();
1494
+ for (const controller of activeFetches)
1495
+ controller.abort();
1496
+ activeFetches.clear();
324
1497
  // Send DELETE to close remote session
325
- if (remoteSessionId) {
1498
+ if (backend.era === 'legacy' && remoteSessionId) {
326
1499
  const reqHeaders = { ...headers, 'Mcp-Session-Id': remoteSessionId };
327
- if (accessToken)
1500
+ if (authMode === 'passthrough') {
1501
+ if (remoteSessionAuthorization)
1502
+ reqHeaders['Authorization'] = remoteSessionAuthorization;
1503
+ }
1504
+ else if (accessToken) {
328
1505
  reqHeaders['Authorization'] = `Bearer ${accessToken}`;
329
- void fetch(remoteUrl, { method: 'DELETE', headers: reqHeaders }).catch(() => { });
1506
+ }
1507
+ void fetch(remoteUrl, {
1508
+ method: 'DELETE',
1509
+ headers: reqHeaders,
1510
+ signal: AbortSignal.timeout(BACKEND_PROBE_TIMEOUT_MS),
1511
+ redirect: 'error',
1512
+ }).then((response) => drainBoundedResponse(response)).catch(() => { });
330
1513
  }
331
1514
  },
332
1515
  };
@@ -337,6 +1520,7 @@ export function createGateway(options) {
337
1520
  const logLevel = options.logLevel ?? 'info';
338
1521
  const { port } = options;
339
1522
  const sessions = new Map();
1523
+ const subscriptions = new Map();
340
1524
  const proxyAuthMode = options.mode === 'proxy'
341
1525
  ? options.authMode ?? 'managed'
342
1526
  : 'managed';
@@ -355,27 +1539,209 @@ export function createGateway(options) {
355
1539
  let nextRequestId = 1;
356
1540
  let restartBackoff = 1000;
357
1541
  let restartTimer = null;
1542
+ function requestedNotifications(params) {
1543
+ if (!isRecord(params) || !isRecord(params['notifications']))
1544
+ return {};
1545
+ // Local synthesis can only relay notifications it receives, but must not
1546
+ // erase extension filters before the acknowledgement establishes what the
1547
+ // caller asked to observe.
1548
+ return { ...params['notifications'] };
1549
+ }
1550
+ function subscriptionMatches(subscription, message) {
1551
+ const method = message.method;
1552
+ if (!method?.startsWith('notifications/'))
1553
+ return false;
1554
+ const shortMethod = method.slice('notifications/'.length);
1555
+ if (subscription.notifications[method] === true || subscription.notifications[shortMethod] === true)
1556
+ return true;
1557
+ const listChange = /^(.+)\/list_changed$/.exec(shortMethod);
1558
+ if (listChange) {
1559
+ const category = listChange[1]?.replace(/[-_/.](.)/g, (_whole, character) => character.toUpperCase());
1560
+ if (category && subscription.notifications[`${category}ListChanged`] === true)
1561
+ return true;
1562
+ }
1563
+ if (method !== 'notifications/resources/updated' || !isRecord(message.params))
1564
+ return false;
1565
+ const uris = subscription.notifications['resourceSubscriptions'];
1566
+ return Array.isArray(uris) && typeof message.params['uri'] === 'string'
1567
+ && uris.includes(message.params['uri']);
1568
+ }
1569
+ function closeSubscription(id) {
1570
+ const subscription = subscriptions.get(id);
1571
+ if (!subscription)
1572
+ return;
1573
+ clearInterval(subscription.keepAlive);
1574
+ subscriptions.delete(id);
1575
+ if (subscription.sessionId)
1576
+ sessions.get(subscription.sessionId)?.subscriptions.delete(id);
1577
+ void subscription.writer.end();
1578
+ }
1579
+ function publishChangeNotification(message) {
1580
+ // A legacy backend has no request correlation. Deliver notifications only
1581
+ // to explicit subscriptions whose standard or extension filter matches.
1582
+ if (hasRequestId(message)) {
1583
+ logger('debug', `Dropping server-initiated request ${message.method ?? '<unknown>'}`);
1584
+ return;
1585
+ }
1586
+ if (!message.method?.startsWith('notifications/')) {
1587
+ logger('debug', `Dropping uncorrelated server notification ${message.method ?? '<unknown>'}`);
1588
+ return;
1589
+ }
1590
+ for (const subscription of [...subscriptions.values()]) {
1591
+ if (!subscriptionMatches(subscription, message))
1592
+ continue;
1593
+ const params = isRecord(message.params) ? message.params : {};
1594
+ const tagged = {
1595
+ ...message,
1596
+ params: { ...params, _meta: {
1597
+ ...(isRecord(params['_meta']) ? params['_meta'] : {}),
1598
+ [SUBSCRIPTION_ID_META]: subscription.id,
1599
+ } },
1600
+ };
1601
+ if (!subscription.writer.writeMessage(tagged))
1602
+ closeSubscription(subscription.key);
1603
+ }
1604
+ }
1605
+ function openSubscription(res, params, sessionId, id, owner = sessionId ?? randomUUID()) {
1606
+ // A subscription may remain quiet for a long time. Flush the headers now
1607
+ // so the client has an established stream before its first notification.
1608
+ res.flushHeaders();
1609
+ const key = `${owner}:${typeof id}:${String(id)}`;
1610
+ const writer = createSseWriter(res);
1611
+ const subscription = {
1612
+ key,
1613
+ owner,
1614
+ id,
1615
+ notifications: requestedNotifications(params),
1616
+ response: res,
1617
+ writer,
1618
+ keepAlive: setInterval(() => {
1619
+ if (!writer.writeComment())
1620
+ closeSubscription(key);
1621
+ }, SSE_KEEP_ALIVE_MS),
1622
+ };
1623
+ if (sessionId)
1624
+ subscription.sessionId = sessionId;
1625
+ subscription.keepAlive.unref();
1626
+ // Reusing a wire id replaces only a stream owned by the same legacy
1627
+ // session. Modern request-scoped streams receive a unique owner instead.
1628
+ closeSubscription(key);
1629
+ subscriptions.set(key, subscription);
1630
+ if (sessionId)
1631
+ sessions.get(sessionId)?.subscriptions.add(key);
1632
+ // The acknowledgement establishes the subscription's correlation before
1633
+ // any change notification can be written to this stream.
1634
+ writer.writeMessage({
1635
+ jsonrpc: '2.0',
1636
+ method: 'notifications/subscriptions/acknowledged',
1637
+ params: { _meta: { [SUBSCRIPTION_ID_META]: id }, notifications: subscription.notifications },
1638
+ });
1639
+ res.on('close', () => { closeSubscription(key); });
1640
+ }
1641
+ async function negotiateBackend(candidate, requestCtx) {
1642
+ const discover = async (protocolVersion) => {
1643
+ const requestId = nextRequestId++;
1644
+ const response = await probeTimeout((signal) => candidate.sendRequest({
1645
+ jsonrpc: '2.0',
1646
+ id: requestId,
1647
+ method: 'server/discover',
1648
+ params: {
1649
+ _meta: {
1650
+ 'io.modelcontextprotocol/protocolVersion': protocolVersion,
1651
+ 'io.modelcontextprotocol/clientInfo': requestCtx?.clientInfo ?? GATEWAY_CLIENT_INFO,
1652
+ 'io.modelcontextprotocol/clientCapabilities': requestCtx?.clientCapabilities ?? {},
1653
+ },
1654
+ },
1655
+ }, {
1656
+ ...requestCtx,
1657
+ protocolVersion,
1658
+ mcpParamHeaders: requestCtx?.mcpParamHeaders ?? [],
1659
+ }, signal));
1660
+ return { requestId, response };
1661
+ };
1662
+ const initialVersion = SUPPORTED_MODERN_PROTOCOL_VERSIONS[0];
1663
+ if (initialVersion === undefined)
1664
+ return;
1665
+ const initial = await discover(initialVersion);
1666
+ const initialResponse = initial.response;
1667
+ const initialVersionSelection = modernDiscoveryVersion(initialResponse, initial.requestId);
1668
+ if (initialVersionSelection) {
1669
+ candidate.era = 'modern';
1670
+ candidate.protocolVersion = initialVersionSelection;
1671
+ return;
1672
+ }
1673
+ if (isModernMissingDiscoveryMethod(initialResponse, initial.requestId)) {
1674
+ candidate.era = 'modern';
1675
+ candidate.protocolVersion = initialVersion;
1676
+ return;
1677
+ }
1678
+ const retryVersion = modernErrorDiscoveryVersion(initialResponse, initial.requestId);
1679
+ if (retryVersion) {
1680
+ candidate.era = 'modern';
1681
+ candidate.protocolVersion = initialVersion;
1682
+ const retry = await discover(retryVersion);
1683
+ const retryVersionSelection = modernDiscoveryVersion(retry.response, retry.requestId);
1684
+ if (retryVersionSelection) {
1685
+ candidate.protocolVersion = retryVersionSelection;
1686
+ return;
1687
+ }
1688
+ // A recognized modern error must never make this backend look legacy.
1689
+ candidate.protocolVersion = retryVersion;
1690
+ logger('info', 'Modern backend rejected the selected discovery version');
1691
+ return;
1692
+ }
1693
+ if (isRecognizableModernProbeError(initialResponse, initial.requestId)) {
1694
+ candidate.era = 'modern';
1695
+ candidate.protocolVersion = initialVersion;
1696
+ logger('info', 'Modern backend rejected discovery without a compatible version');
1697
+ return;
1698
+ }
1699
+ candidate.era = 'legacy';
1700
+ const initializeRequestId = nextRequestId++;
1701
+ const initializeResponse = await candidate.sendRequest({
1702
+ jsonrpc: '2.0',
1703
+ id: initializeRequestId,
1704
+ method: 'initialize',
1705
+ params: {
1706
+ protocolVersion: LEGACY_PROTOCOL_VERSION,
1707
+ capabilities: {},
1708
+ clientInfo: GATEWAY_CLIENT_INFO,
1709
+ },
1710
+ }, {
1711
+ ...requestCtx,
1712
+ protocolVersion: LEGACY_PROTOCOL_VERSION,
1713
+ mcpParamHeaders: requestCtx?.mcpParamHeaders ?? [],
1714
+ });
1715
+ if (isLegacyInitializeResponse(initializeResponse, initializeRequestId)) {
1716
+ candidate.initializeResult = initializeResponse.result;
1717
+ candidate.sendNotification({
1718
+ jsonrpc: '2.0',
1719
+ method: 'notifications/initialized',
1720
+ params: {},
1721
+ }, {
1722
+ ...requestCtx,
1723
+ protocolVersion: LEGACY_PROTOCOL_VERSION,
1724
+ mcpParamHeaders: requestCtx?.mcpParamHeaders ?? [],
1725
+ });
1726
+ return;
1727
+ }
1728
+ logger('info', 'Legacy backend initialization failed');
1729
+ }
358
1730
  function startBackend() {
359
1731
  let b;
360
1732
  if (options.mode === 'stdio') {
361
- b = createStdioBackend(options.command, logger);
1733
+ b = createStdioBackend(options.command, options.args ?? [], logger);
362
1734
  }
363
1735
  else {
364
- b = createProxyBackend(options.url, options.headers ?? {}, proxyAuthMode, sharedOAuthClient, logger);
1736
+ b = createProxyBackend(options.url, options.transport ?? 'http', options.headers ?? {}, proxyAuthMode, sharedOAuthClient, logger);
365
1737
  }
366
1738
  b.onServerMessage = (msg) => {
367
- for (const session of sessions.values()) {
368
- if (session.liveSSE && sendSSE(session.liveSSE, msg))
369
- continue;
370
- if (session.liveSSE)
371
- session.liveSSE = null;
372
- if (session.notificationBuffer.length < MAX_NOTIFICATION_BUFFER) {
373
- session.notificationBuffer.push(msg);
374
- }
375
- }
1739
+ publishChangeNotification(msg);
376
1740
  };
377
1741
  b.onClose = () => {
378
1742
  logger('info', `Backend died, restarting in ${String(restartBackoff)}ms`);
1743
+ for (const subscriptionId of [...subscriptions.keys()])
1744
+ closeSubscription(subscriptionId);
379
1745
  sessions.clear();
380
1746
  restartTimer = setTimeout(() => {
381
1747
  restartTimer = null;
@@ -383,18 +1749,158 @@ export function createGateway(options) {
383
1749
  restartBackoff = Math.min(restartBackoff * 2, 30_000);
384
1750
  }, restartBackoff);
385
1751
  };
1752
+ let readiness;
1753
+ let readinessAuthorization;
1754
+ let hasReadinessAuthorization = false;
1755
+ b.beginReadiness = (ctx) => {
1756
+ // Legacy negotiation itself creates/uses the one shared upstream
1757
+ // session. Serialize its credential ownership before the probe reveals
1758
+ // the backend era, otherwise two concurrent callers could race an
1759
+ // initialize challenge with different Authorization values.
1760
+ if (options.mode === 'proxy' && proxyAuthMode === 'passthrough'
1761
+ && (readiness !== undefined || b.era === 'legacy')) {
1762
+ if (!hasReadinessAuthorization) {
1763
+ readinessAuthorization = ctx?.authorization;
1764
+ hasReadinessAuthorization = true;
1765
+ }
1766
+ else if (readinessAuthorization !== ctx?.authorization) {
1767
+ return Promise.reject(new CredentialAffinityError());
1768
+ }
1769
+ }
1770
+ if (!readiness) {
1771
+ const sharedReadiness = negotiateBackend(b, ctx).catch((err) => {
1772
+ if (err instanceof UnauthorizedError) {
1773
+ // The request that triggered the probe must receive the upstream
1774
+ // challenge. Do not classify the endpoint from an unauthenticated
1775
+ // probe; a later request may supply a valid credential.
1776
+ throw err;
1777
+ }
1778
+ const errMsg = err instanceof Error ? err.message : String(err);
1779
+ // Negotiation must not prevent a restarted gateway from serving a
1780
+ // backend that comes up later. Treat unexpected probe failures like the
1781
+ // documented legacy fallback, while retaining the diagnostic.
1782
+ b.era = 'legacy';
1783
+ logger('info', `Backend negotiation failed: ${errMsg}`);
1784
+ });
1785
+ readiness = sharedReadiness;
1786
+ void sharedReadiness.catch(() => {
1787
+ // Only clear the attempt that failed. Requests which joined it see
1788
+ // the same challenge, while the next request can negotiate again.
1789
+ if (readiness === sharedReadiness) {
1790
+ readiness = undefined;
1791
+ b.ready = Promise.resolve();
1792
+ // A 401 during probing has not created a usable upstream session.
1793
+ // Release its tentative owner so the client can answer the
1794
+ // challenge with a different credential on the next request.
1795
+ hasReadinessAuthorization = false;
1796
+ readinessAuthorization = undefined;
1797
+ }
1798
+ });
1799
+ void sharedReadiness.then(() => {
1800
+ // Modern HTTP has no shared server-side legacy session, so callers
1801
+ // may independently use their own passthrough credentials.
1802
+ if (b.era === 'modern') {
1803
+ hasReadinessAuthorization = false;
1804
+ readinessAuthorization = undefined;
1805
+ }
1806
+ }).catch(() => { });
1807
+ }
1808
+ b.ready = readiness;
1809
+ return readiness;
1810
+ };
1811
+ if (options.mode === 'stdio')
1812
+ void b.beginReadiness();
386
1813
  return b;
387
1814
  }
388
1815
  let backend = startBackend();
389
- async function sendRequest(msg, ctx) {
1816
+ function resetAbandonedLegacyProxyBackend(candidate) {
1817
+ if (options.mode !== 'proxy' || candidate !== backend || candidate.era !== 'legacy' || sessions.size !== 0)
1818
+ return;
1819
+ // The first readiness handshake may have created a credential-bound remote
1820
+ // session before its downstream initializer disconnected. Close that
1821
+ // orphan and replace the backend so a later caller is not affinity-blocked.
1822
+ candidate.close();
1823
+ backend = startBackend();
1824
+ }
1825
+ async function sendRequest(msg, ctx, clientEra = 'legacy', signal, onEvent, onResponseStart) {
1826
+ const currentBackend = backend;
1827
+ await currentBackend.beginReadiness(ctx);
1828
+ if (currentBackend.era === 'modern' && clientEra === 'legacy') {
1829
+ if (msg.method === 'ping')
1830
+ return { jsonrpc: '2.0', id: msg.id, result: {} };
1831
+ if (msg.method === 'logging/setLevel' || msg.method === 'notifications/roots/list_changed') {
1832
+ return { jsonrpc: '2.0', id: msg.id, result: {} };
1833
+ }
1834
+ }
390
1835
  const originalId = msg.id;
391
- const internalId = nextRequestId++;
392
- const response = await backend.sendRequest({ ...msg, id: internalId }, ctx);
1836
+ // Modern HTTP is request-scoped. Retaining its client request id keeps a
1837
+ // relayed subscriptions/listen stream transparent: the upstream
1838
+ // acknowledgement and all notifications use the caller's subscription id.
1839
+ const internalId = currentBackend.era === 'modern'
1840
+ ? originalId : nextRequestId++;
1841
+ const outgoing = currentBackend.era === 'legacy' && clientEra === 'modern'
1842
+ ? stripModernMeta(msg)
1843
+ : currentBackend.era === 'modern' && clientEra === 'legacy'
1844
+ ? injectModernMeta(msg, currentBackend.protocolVersion ?? CURRENT_MODERN_PROTOCOL_VERSION, ctx?.clientCapabilities ?? {}, ctx?.clientInfo ?? GATEWAY_CLIENT_INFO)
1845
+ : msg;
1846
+ const outgoingCtx = currentBackend.era === 'legacy' && clientEra === 'modern'
1847
+ ? { ...ctx, protocolVersion: LEGACY_PROTOCOL_VERSION, mcpParamHeaders: ctx?.mcpParamHeaders ?? [] }
1848
+ : currentBackend.era === 'modern' && clientEra === 'legacy'
1849
+ ? {
1850
+ ...ctx,
1851
+ protocolVersion: currentBackend.protocolVersion ?? CURRENT_MODERN_PROTOCOL_VERSION,
1852
+ mcpParamHeaders: ctx?.mcpParamHeaders ?? [],
1853
+ }
1854
+ : ctx;
1855
+ const response = await currentBackend.sendRequest({ ...outgoing, id: internalId }, outgoingCtx, signal, onEvent, onResponseStart);
393
1856
  restartBackoff = 1000;
394
- return { ...response, id: originalId };
1857
+ if (!isMatchingResponse(response, internalId)) {
1858
+ return {
1859
+ jsonrpc: '2.0',
1860
+ id: originalId,
1861
+ error: { code: -32603, message: 'Remote server returned a response with an unexpected id' },
1862
+ };
1863
+ }
1864
+ const transformedBase = { ...transformBackendResponse(response, msg.method, clientEra, currentBackend.era), id: originalId };
1865
+ const transformed = clientEra === 'modern' ? ensureModernCompleteResult(transformedBase) : transformedBase;
1866
+ // Cross-era bridges synthesize protocol responses. Same-era HTTP proxy
1867
+ // responses may retain the safe status and headers from upstream.
1868
+ const meta = responseMeta(response);
1869
+ if (meta && currentBackend.era === clientEra) {
1870
+ Object.defineProperty(transformed, HTTP_RESPONSE_META, { value: meta, enumerable: false });
1871
+ }
1872
+ return transformed;
395
1873
  }
396
- function sendNotification(msg, ctx) {
397
- backend.sendNotification(msg, ctx);
1874
+ function sendNotification(msg, ctx, clientEra = 'legacy', signal) {
1875
+ const currentBackend = backend;
1876
+ void currentBackend.beginReadiness(ctx).then(() => {
1877
+ if (currentBackend.era === 'modern' && clientEra === 'legacy' && (msg.method === 'ping'
1878
+ || msg.method === 'logging/setLevel'
1879
+ || msg.method === 'notifications/roots/list_changed'
1880
+ || msg.method === 'notifications/initialized'))
1881
+ return;
1882
+ const outgoing = currentBackend.era === 'legacy' && clientEra === 'modern'
1883
+ ? stripModernMeta(msg)
1884
+ : currentBackend.era === 'modern' && clientEra === 'legacy'
1885
+ ? injectModernMeta(msg, currentBackend.protocolVersion ?? CURRENT_MODERN_PROTOCOL_VERSION, ctx?.clientCapabilities ?? {}, ctx?.clientInfo ?? GATEWAY_CLIENT_INFO)
1886
+ : msg;
1887
+ const outgoingCtx = currentBackend.era === 'legacy' && clientEra === 'modern'
1888
+ ? { ...ctx, protocolVersion: LEGACY_PROTOCOL_VERSION, mcpParamHeaders: ctx?.mcpParamHeaders ?? [] }
1889
+ : currentBackend.era === 'modern' && clientEra === 'legacy'
1890
+ ? {
1891
+ ...ctx,
1892
+ protocolVersion: currentBackend.protocolVersion ?? CURRENT_MODERN_PROTOCOL_VERSION,
1893
+ mcpParamHeaders: ctx?.mcpParamHeaders ?? [],
1894
+ }
1895
+ : ctx;
1896
+ currentBackend.sendNotification(outgoing, outgoingCtx, signal);
1897
+ }).catch((err) => {
1898
+ // Notifications deliberately have no response channel. They must still
1899
+ // observe readiness/auth failures so a rejected probe never becomes an
1900
+ // unhandled rejection that terminates Node.
1901
+ const errMsg = err instanceof Error ? err.message : String(err);
1902
+ logger('debug', `Notification forward failed: ${errMsg}`);
1903
+ });
398
1904
  }
399
1905
  function writeUnauthorized(res, err, requestId) {
400
1906
  if (res.headersSent)
@@ -402,81 +1908,121 @@ export function createGateway(options) {
402
1908
  const respHeaders = { 'Content-Type': 'application/json' };
403
1909
  if (err.wwwAuthenticate)
404
1910
  respHeaders['WWW-Authenticate'] = err.wwwAuthenticate;
405
- res.writeHead(401, respHeaders);
1911
+ res.writeHead(err.status, respHeaders);
406
1912
  res.end(JSON.stringify({
407
1913
  jsonrpc: '2.0',
408
1914
  id: requestId,
409
- error: { code: -32001, message: 'Unauthorized' },
1915
+ error: { code: -32001, message: err.status === 403 ? 'Forbidden' : 'Unauthorized' },
410
1916
  }));
411
1917
  }
412
- async function handleRequestOverSSE(res, message, session, ctx) {
413
- // Passthrough mode delays SSE headers so a 401 from upstream can still
414
- // surface as an HTTP 401 to the client (re-auth requires this).
415
- if (proxyAuthMode === 'passthrough') {
416
- let response;
417
- try {
418
- response = await sendRequest(message, ctx);
1918
+ function writeCredentialConflict(res, requestId) {
1919
+ if (res.headersSent)
1920
+ return;
1921
+ res.writeHead(409, { 'Content-Type': 'application/json' });
1922
+ res.end(makeErrorResponse(requestId, -32000, 'The shared legacy upstream session belongs to another credential; close it before switching credentials'));
1923
+ }
1924
+ async function handleRequestOverSSE(res, message, session, ctx, clientEra) {
1925
+ const controller = new AbortController();
1926
+ const detach = () => { controller.abort(); };
1927
+ res.on('close', detach);
1928
+ let writer;
1929
+ let headersStarted = false;
1930
+ let preHeaderEvents = [];
1931
+ let preHeaderBytes = 0;
1932
+ const start = (meta) => {
1933
+ if (headersStarted || res.destroyed || res.writableEnded)
1934
+ return;
1935
+ const headers = {
1936
+ ...(meta?.headers ?? {}),
1937
+ 'Content-Type': 'text/event-stream',
1938
+ 'Cache-Control': meta?.headers['cache-control'] ?? 'no-cache',
1939
+ 'X-Accel-Buffering': 'no',
1940
+ };
1941
+ if (clientEra === 'legacy' && session)
1942
+ headers['Mcp-Session-Id'] = session.id;
1943
+ res.writeHead(meta?.status ?? 200, headers);
1944
+ writer = createSseWriter(res);
1945
+ headersStarted = true;
1946
+ for (const event of preHeaderEvents) {
1947
+ if (!writer.writeMessage(event))
1948
+ break;
419
1949
  }
420
- catch (err) {
421
- if (err instanceof UnauthorizedError) {
422
- writeUnauthorized(res, err, message.id);
423
- return;
424
- }
425
- throw err;
1950
+ preHeaderEvents = [];
1951
+ preHeaderBytes = 0;
1952
+ };
1953
+ const sink = (event) => {
1954
+ if (writer) {
1955
+ if (!writer.writeMessage(event))
1956
+ controller.abort(new Error('SSE downstream closed'));
1957
+ return;
426
1958
  }
427
- res.writeHead(200, {
428
- 'Content-Type': 'text/event-stream',
429
- 'Cache-Control': 'no-cache',
430
- 'Mcp-Session-Id': session.id,
431
- });
432
- const pending = session.notificationBuffer;
433
- session.notificationBuffer = [];
434
- for (const notif of pending)
435
- sendSSE(res, notif);
436
- sendSSE(res, response);
437
- res.end();
438
- return;
439
- }
440
- res.writeHead(200, {
441
- 'Content-Type': 'text/event-stream',
442
- 'Cache-Control': 'no-cache',
443
- 'Mcp-Session-Id': session.id,
444
- });
445
- const pending = session.notificationBuffer;
446
- session.notificationBuffer = [];
447
- for (const notif of pending)
448
- sendSSE(res, notif);
449
- session.liveSSE = res;
450
- const detachIfOwned = () => {
451
- if (session.liveSSE === res)
452
- session.liveSSE = null;
1959
+ const bytes = Buffer.byteLength(JSON.stringify(event));
1960
+ if (preHeaderBytes + bytes > MAX_PRE_HEADER_EVENT_BYTES) {
1961
+ controller.abort(new Error('SSE pre-header queue exceeded gateway limit'));
1962
+ res.destroy();
1963
+ return;
1964
+ }
1965
+ preHeaderEvents.push(event);
1966
+ preHeaderBytes += bytes;
453
1967
  };
454
- res.on('close', detachIfOwned);
455
1968
  try {
456
- const response = await sendRequest(message, ctx);
457
- sendSSE(res, response);
1969
+ const response = await sendRequest(message, ctx, clientEra, controller.signal, sink, start);
1970
+ const meta = responseMeta(response);
1971
+ start(meta);
1972
+ writer?.writeMessage(response);
458
1973
  }
459
1974
  catch (err) {
460
- const errMsg = err instanceof Error ? err.message : String(err);
461
- sendSSE(res, {
1975
+ if (err instanceof UnauthorizedError) {
1976
+ writeUnauthorized(res, err, message.id);
1977
+ return;
1978
+ }
1979
+ if (err instanceof CredentialAffinityError) {
1980
+ writeCredentialConflict(res, message.id);
1981
+ return;
1982
+ }
1983
+ logger('info', `Request failed before an SSE response could complete (${err instanceof Error ? err.name : 'Error'})`);
1984
+ start();
1985
+ writer?.writeMessage({
462
1986
  jsonrpc: '2.0',
463
1987
  id: message.id,
464
- error: { code: -32603, message: errMsg },
1988
+ error: { code: -32603, message: 'Gateway request failed' },
465
1989
  });
466
1990
  }
467
1991
  finally {
468
- res.removeListener('close', detachIfOwned);
469
- detachIfOwned();
470
- res.end();
1992
+ res.removeListener('close', detach);
1993
+ await writer?.end();
1994
+ if (!writer && !res.writableEnded && !res.destroyed)
1995
+ res.end();
471
1996
  }
472
1997
  }
473
1998
  function respondWithJson(res, response, sessionId) {
474
- res.writeHead(200, { 'Content-Type': 'application/json', 'Mcp-Session-Id': sessionId });
1999
+ const meta = responseMeta(response);
2000
+ const headers = { 'Content-Type': 'application/json', ...(meta?.headers ?? {}) };
2001
+ if (sessionId)
2002
+ headers['Mcp-Session-Id'] = sessionId;
2003
+ res.writeHead(meta?.status ?? 200, headers);
475
2004
  res.end(JSON.stringify(response));
476
2005
  }
477
2006
  // --- HTTP handlers ---
478
2007
  async function handlePost(req, res) {
479
- const body = await readBody(req);
2008
+ const requestController = new AbortController();
2009
+ const abortRequest = () => {
2010
+ // `close` also follows a normal res.end(). Only treat it as a client
2011
+ // disconnect while the gateway has not completed the downstream reply.
2012
+ if (!res.writableFinished)
2013
+ requestController.abort(new Error('Gateway client disconnected'));
2014
+ };
2015
+ req.once('aborted', abortRequest);
2016
+ res.once('close', abortRequest);
2017
+ let body;
2018
+ try {
2019
+ body = await readBody(req);
2020
+ }
2021
+ catch {
2022
+ res.writeHead(413, { 'Content-Type': 'application/json' });
2023
+ res.end(makeErrorResponse(null, -32600, 'Request body exceeds gateway limit'));
2024
+ return;
2025
+ }
480
2026
  let message;
481
2027
  try {
482
2028
  message = JSON.parse(body);
@@ -486,40 +2032,212 @@ export function createGateway(options) {
486
2032
  res.end(makeErrorResponse(null, -32700, 'Parse error'));
487
2033
  return;
488
2034
  }
489
- if (!isJsonRpcMessage(message)) {
2035
+ if (!isJsonRpcRequest(message)) {
490
2036
  res.writeHead(400, { 'Content-Type': 'application/json' });
491
2037
  res.end(makeErrorResponse(null, -32600, 'Invalid Request'));
492
2038
  return;
493
2039
  }
494
2040
  const authHeader = req.headers.authorization;
495
- const ctx = {};
2041
+ const protocolVersionHeader = req.headers['mcp-protocol-version'];
2042
+ const ctx = { mcpParamHeaders: [] };
496
2043
  if (typeof authHeader === 'string')
497
2044
  ctx.authorization = authHeader;
2045
+ if (typeof protocolVersionHeader === 'string')
2046
+ ctx.protocolVersion = protocolVersionHeader;
2047
+ if (typeof req.headers.accept === 'string')
2048
+ ctx.accept = req.headers.accept;
2049
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
2050
+ const name = req.rawHeaders[i];
2051
+ const value = req.rawHeaders[i + 1];
2052
+ if (name?.toLowerCase().startsWith('mcp-param-') && value !== undefined) {
2053
+ ctx.mcpParamHeaders.push([name, value]);
2054
+ }
2055
+ }
2056
+ const modernClient = isModernClientRequest(message, protocolVersionHeader);
2057
+ if (modernClient) {
2058
+ const methodHeader = req.headers['mcp-method'];
2059
+ const nameHeader = req.headers['mcp-name'];
2060
+ const method = typeof methodHeader === 'string' ? methodHeader : undefined;
2061
+ const encodedName = typeof nameHeader === 'string' ? nameHeader : undefined;
2062
+ const decodedName = encodedName === undefined ? undefined : decodedMcpNameHeader(encodedName);
2063
+ const protocolVersion = typeof protocolVersionHeader === 'string' ? protocolVersionHeader : undefined;
2064
+ const expectedName = requestName(message);
2065
+ if (!protocolVersion || !method || method !== message.method
2066
+ || (requiresMcpName(message.method)
2067
+ ? expectedName === undefined || encodedName === undefined || decodedName === undefined || decodedName !== expectedName
2068
+ : encodedName !== undefined)
2069
+ || !hasModernProtocolMeta(message, protocolVersion)
2070
+ || !hasValidModernClientMetadata(message)) {
2071
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2072
+ res.end(makeErrorResponse(message.id ?? null, -32020, 'MCP request headers do not match the JSON-RPC body'));
2073
+ return;
2074
+ }
2075
+ if (!hasRequiredModernClientCapabilities(message)) {
2076
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2077
+ res.end(makeErrorResponse(message.id ?? null, -32021, 'MissingRequiredClientCapability'));
2078
+ return;
2079
+ }
2080
+ if (!SUPPORTED_MODERN_PROTOCOL_VERSIONS.includes(protocolVersion)) {
2081
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2082
+ res.end(JSON.stringify({
2083
+ jsonrpc: '2.0',
2084
+ id: message.id ?? null,
2085
+ error: {
2086
+ code: -32022,
2087
+ message: 'Unsupported protocol version',
2088
+ data: { supported: SUPPORTED_MODERN_PROTOCOL_VERSIONS, requested: protocolVersion },
2089
+ },
2090
+ }));
2091
+ return;
2092
+ }
2093
+ if (message.method === 'subscriptions/listen') {
2094
+ if (!hasRequestId(message)) {
2095
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2096
+ res.end(makeErrorResponse(null, -32600, 'subscriptions/listen requires a request id'));
2097
+ return;
2098
+ }
2099
+ try {
2100
+ const currentBackend = backend;
2101
+ await currentBackend.beginReadiness(ctx);
2102
+ if (currentBackend.era === 'legacy') {
2103
+ res.writeHead(200, {
2104
+ 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'X-Accel-Buffering': 'no',
2105
+ });
2106
+ openSubscription(res, message.params, undefined, message.id);
2107
+ }
2108
+ else {
2109
+ await handleRequestOverSSE(res, message, undefined, ctx, 'modern');
2110
+ }
2111
+ }
2112
+ catch (err) {
2113
+ if (err instanceof UnauthorizedError)
2114
+ writeUnauthorized(res, err, message.id);
2115
+ else
2116
+ throw err;
2117
+ }
2118
+ return;
2119
+ }
2120
+ if (message.method === 'server/discover') {
2121
+ if (!hasRequestId(message)) {
2122
+ res.writeHead(202);
2123
+ res.end();
2124
+ return;
2125
+ }
2126
+ try {
2127
+ const currentBackend = backend;
2128
+ await currentBackend.beginReadiness(ctx);
2129
+ const response = currentBackend.era === 'legacy'
2130
+ ? { jsonrpc: '2.0', id: message.id, result: discoverFromInitialize(currentBackend.initializeResult) }
2131
+ : await sendRequest(message, ctx, 'modern', requestController.signal);
2132
+ respondWithJson(res, response);
2133
+ }
2134
+ catch (err) {
2135
+ if (err instanceof UnauthorizedError) {
2136
+ writeUnauthorized(res, err, message.id);
2137
+ return;
2138
+ }
2139
+ if (err instanceof CredentialAffinityError) {
2140
+ writeCredentialConflict(res, message.id);
2141
+ return;
2142
+ }
2143
+ throw err;
2144
+ }
2145
+ return;
2146
+ }
2147
+ // initialize was removed from the modern protocol. Do not accidentally
2148
+ // turn it into a legacy session just because the endpoint is shared.
2149
+ if (message.method === 'initialize') {
2150
+ res.writeHead(404, { 'Content-Type': 'application/json' });
2151
+ res.end(makeErrorResponse(message.id ?? null, -32601, 'Method not found'));
2152
+ return;
2153
+ }
2154
+ if (hasRequestId(message)) {
2155
+ const clientAcceptsSSE = (req.headers.accept ?? '').includes('text/event-stream');
2156
+ try {
2157
+ if (clientAcceptsSSE)
2158
+ await handleRequestOverSSE(res, message, undefined, ctx, 'modern');
2159
+ else
2160
+ respondWithJson(res, await sendRequest(message, ctx, 'modern', requestController.signal));
2161
+ }
2162
+ catch (err) {
2163
+ if (err instanceof UnauthorizedError) {
2164
+ writeUnauthorized(res, err, message.id);
2165
+ return;
2166
+ }
2167
+ if (err instanceof CredentialAffinityError) {
2168
+ writeCredentialConflict(res, message.id);
2169
+ return;
2170
+ }
2171
+ throw err;
2172
+ }
2173
+ }
2174
+ else {
2175
+ sendNotification(message, ctx, 'modern', requestController.signal);
2176
+ res.writeHead(202);
2177
+ res.end();
2178
+ }
2179
+ return;
2180
+ }
498
2181
  if (message.method === 'initialize') {
499
2182
  const session = {
500
2183
  id: randomUUID(),
501
- notificationBuffer: [],
502
- liveSSE: null,
2184
+ subscriptions: new Set(),
2185
+ clientCapabilities: isRecord(message.params) && isRecord(message.params['capabilities'])
2186
+ ? message.params['capabilities'] : {},
2187
+ clientInfo: isRecord(message.params) && isRecord(message.params['clientInfo'])
2188
+ ? message.params['clientInfo'] : GATEWAY_CLIENT_INFO,
503
2189
  };
504
2190
  sessions.set(session.id, session);
2191
+ ctx.clientCapabilities = session.clientCapabilities;
2192
+ ctx.clientInfo = session.clientInfo;
505
2193
  logger('info', `Session ${session.id.slice(0, 8)} created (${String(sessions.size)} active)`);
506
2194
  try {
507
2195
  if (hasRequestId(message)) {
508
- const response = await sendRequest(message, ctx);
2196
+ const currentBackend = backend;
2197
+ await currentBackend.beginReadiness(ctx);
2198
+ // The gateway owns the legacy backend handshake. A legacy client
2199
+ // still receives its normal initialize result, but its request must
2200
+ // not open a second backend session.
2201
+ const response = currentBackend.era === 'legacy' && currentBackend.initializeResult !== undefined
2202
+ ? { jsonrpc: '2.0', id: message.id, result: currentBackend.initializeResult }
2203
+ : currentBackend.era === 'modern'
2204
+ ? await (async () => {
2205
+ const discovery = await sendRequest({
2206
+ jsonrpc: '2.0', id: message.id, method: 'server/discover', params: {},
2207
+ }, ctx, 'legacy', requestController.signal);
2208
+ return isRecord(discovery.result)
2209
+ ? { jsonrpc: '2.0', id: message.id, result: initializeFromDiscover(discovery.result) }
2210
+ : discovery;
2211
+ })()
2212
+ : await sendRequest(message, ctx, 'legacy', requestController.signal);
2213
+ // The downstream transport may close while an abort-aware backend
2214
+ // resolves with a JSON-RPC cancellation error instead of throwing.
2215
+ // Never retain a session that was not successfully handed to it.
2216
+ if (requestController.signal.aborted || res.destroyed || res.writableEnded) {
2217
+ sessions.delete(session.id);
2218
+ resetAbandonedLegacyProxyBackend(currentBackend);
2219
+ return;
2220
+ }
509
2221
  respondWithJson(res, response, session.id);
510
2222
  }
511
2223
  else {
512
- sendNotification(message, ctx);
2224
+ sendNotification(message, ctx, 'legacy', requestController.signal);
513
2225
  res.writeHead(202, { 'Mcp-Session-Id': session.id });
514
2226
  res.end();
515
2227
  }
516
2228
  }
517
2229
  catch (err) {
518
2230
  sessions.delete(session.id);
2231
+ if (requestController.signal.aborted || res.destroyed)
2232
+ resetAbandonedLegacyProxyBackend(backend);
519
2233
  if (err instanceof UnauthorizedError) {
520
2234
  writeUnauthorized(res, err, message.id ?? null);
521
2235
  return;
522
2236
  }
2237
+ if (err instanceof CredentialAffinityError) {
2238
+ writeCredentialConflict(res, message.id ?? null);
2239
+ return;
2240
+ }
523
2241
  throw err;
524
2242
  }
525
2243
  return;
@@ -536,14 +2254,51 @@ export function createGateway(options) {
536
2254
  res.end(makeErrorResponse(message.id ?? null, -32000, 'Session not found or expired'));
537
2255
  return;
538
2256
  }
539
- if (hasRequestId(message) && message.method !== undefined) {
2257
+ ctx.clientCapabilities = session.clientCapabilities;
2258
+ ctx.clientInfo = session.clientInfo;
2259
+ if (message.method === 'subscriptions/listen') {
2260
+ if (!hasRequestId(message)) {
2261
+ res.writeHead(400, { 'Content-Type': 'application/json' });
2262
+ res.end(makeErrorResponse(null, -32600, 'subscriptions/listen requires a request id'));
2263
+ return;
2264
+ }
2265
+ try {
2266
+ const currentBackend = backend;
2267
+ await currentBackend.beginReadiness(ctx);
2268
+ if (currentBackend.era === 'modern') {
2269
+ await handleRequestOverSSE(res, message, session, ctx, 'legacy');
2270
+ }
2271
+ else {
2272
+ res.writeHead(200, {
2273
+ 'Content-Type': 'text/event-stream',
2274
+ 'Cache-Control': 'no-cache',
2275
+ 'X-Accel-Buffering': 'no',
2276
+ 'Mcp-Session-Id': session.id,
2277
+ });
2278
+ openSubscription(res, message.params, session.id, message.id);
2279
+ }
2280
+ }
2281
+ catch (err) {
2282
+ if (err instanceof UnauthorizedError) {
2283
+ writeUnauthorized(res, err, message.id);
2284
+ return;
2285
+ }
2286
+ if (err instanceof CredentialAffinityError) {
2287
+ writeCredentialConflict(res, message.id);
2288
+ return;
2289
+ }
2290
+ throw err;
2291
+ }
2292
+ return;
2293
+ }
2294
+ if (hasRequestId(message)) {
540
2295
  const clientAcceptsSSE = (req.headers.accept ?? '').includes('text/event-stream');
541
2296
  try {
542
2297
  if (clientAcceptsSSE) {
543
- await handleRequestOverSSE(res, message, session, ctx);
2298
+ await handleRequestOverSSE(res, message, session, ctx, 'legacy');
544
2299
  }
545
2300
  else {
546
- const response = await sendRequest(message, ctx);
2301
+ const response = await sendRequest(message, ctx, 'legacy', requestController.signal);
547
2302
  respondWithJson(res, response, session.id);
548
2303
  }
549
2304
  }
@@ -552,26 +2307,64 @@ export function createGateway(options) {
552
2307
  writeUnauthorized(res, err, message.id);
553
2308
  return;
554
2309
  }
2310
+ if (err instanceof CredentialAffinityError) {
2311
+ writeCredentialConflict(res, message.id);
2312
+ return;
2313
+ }
555
2314
  throw err;
556
2315
  }
557
2316
  return;
558
2317
  }
559
- sendNotification(message, ctx);
2318
+ if (message.method === 'notifications/initialized') {
2319
+ const currentBackend = backend;
2320
+ try {
2321
+ await currentBackend.beginReadiness(ctx);
2322
+ }
2323
+ catch (err) {
2324
+ if (err instanceof UnauthorizedError) {
2325
+ writeUnauthorized(res, err, null);
2326
+ return;
2327
+ }
2328
+ if (err instanceof CredentialAffinityError) {
2329
+ writeCredentialConflict(res, null);
2330
+ return;
2331
+ }
2332
+ throw err;
2333
+ }
2334
+ // Legacy backends were already initialized by the gateway during
2335
+ // readiness. Forwarding the client's lifecycle notification would make
2336
+ // the upstream observe the same transition twice.
2337
+ if (currentBackend.era === 'legacy') {
2338
+ res.writeHead(202);
2339
+ res.end();
2340
+ return;
2341
+ }
2342
+ }
2343
+ sendNotification(message, ctx, 'legacy', requestController.signal);
560
2344
  res.writeHead(202);
561
2345
  res.end();
562
2346
  }
563
2347
  function handleDelete(req, res) {
2348
+ if (isModernProtocolVersion(req.headers['mcp-protocol-version'])) {
2349
+ res.writeHead(405, { 'Content-Type': 'text/plain' });
2350
+ res.end('Method Not Allowed');
2351
+ return;
2352
+ }
564
2353
  const sessionId = req.headers['mcp-session-id'];
565
2354
  if (typeof sessionId !== 'string') {
566
2355
  res.writeHead(400, { 'Content-Type': 'text/plain' });
567
2356
  res.end('Missing Mcp-Session-Id header');
568
2357
  return;
569
2358
  }
570
- if (!sessions.delete(sessionId)) {
2359
+ const session = sessions.get(sessionId);
2360
+ if (!session) {
571
2361
  res.writeHead(404, { 'Content-Type': 'text/plain' });
572
2362
  res.end('Session not found');
573
2363
  return;
574
2364
  }
2365
+ for (const subscriptionId of session.subscriptions)
2366
+ closeSubscription(subscriptionId);
2367
+ sessions.delete(sessionId);
575
2368
  logger('info', `Session ${sessionId.slice(0, 8)} destroyed (${String(sessions.size)} active)`);
576
2369
  res.writeHead(200);
577
2370
  res.end();
@@ -586,15 +2379,37 @@ export function createGateway(options) {
586
2379
  res.end('Method Not Allowed');
587
2380
  return;
588
2381
  }
2382
+ const controller = new AbortController();
2383
+ const abortFromClient = () => {
2384
+ if (!res.writableFinished)
2385
+ controller.abort(new Error('Gateway client disconnected'));
2386
+ };
2387
+ req.once('aborted', abortFromClient);
2388
+ res.once('close', abortFromClient);
2389
+ const timeout = setTimeout(() => { controller.abort(new Error('Protected-resource request timeout')); }, REQUEST_TIMEOUT_MS);
2390
+ timeout.unref();
2391
+ const cleanup = () => {
2392
+ clearTimeout(timeout);
2393
+ req.removeListener('aborted', abortFromClient);
2394
+ res.removeListener('close', abortFromClient);
2395
+ };
589
2396
  let lastStatus = 404;
590
2397
  let lastBody = '';
591
- let lastContentType = 'application/json';
2398
+ let lastHeaders = { 'content-type': 'application/json' };
592
2399
  for (const target of upstreamProtectedResourceUrls) {
593
2400
  let upstream;
594
2401
  try {
2402
+ const headers = new Headers({
2403
+ ...(options.mode === 'proxy' ? options.headers ?? {} : {}),
2404
+ Accept: typeof req.headers.accept === 'string' ? req.headers.accept : 'application/json',
2405
+ });
2406
+ if (typeof req.headers.authorization === 'string')
2407
+ headers.set('Authorization', req.headers.authorization);
595
2408
  upstream = await fetch(target, {
596
2409
  method: 'GET',
597
- headers: { 'Accept': 'application/json' },
2410
+ headers,
2411
+ signal: controller.signal,
2412
+ redirect: 'error',
598
2413
  });
599
2414
  }
600
2415
  catch (err) {
@@ -602,25 +2417,51 @@ export function createGateway(options) {
602
2417
  logger('info', `Protected-resource fetch failed (${target}): ${errMsg}`);
603
2418
  continue;
604
2419
  }
605
- const text = await upstream.text();
2420
+ let text;
2421
+ try {
2422
+ text = await readBoundedResponseText(upstream, controller);
2423
+ }
2424
+ catch (err) {
2425
+ if (err instanceof UpstreamBodyTooLargeError) {
2426
+ cleanup();
2427
+ res.writeHead(502, { 'Content-Type': 'application/json' });
2428
+ res.end(makeErrorResponse(null, -32603, err.message));
2429
+ return;
2430
+ }
2431
+ throw err;
2432
+ }
2433
+ const forwardedHeaders = safeResponseHeaders(upstream.headers);
2434
+ forwardedHeaders['content-type'] ??= 'application/json';
606
2435
  if (upstream.ok) {
607
- const ct = upstream.headers.get('content-type') ?? 'application/json';
608
- res.writeHead(upstream.status, { 'Content-Type': ct });
2436
+ cleanup();
2437
+ res.writeHead(upstream.status, forwardedHeaders);
609
2438
  res.end(text);
610
2439
  return;
611
2440
  }
612
2441
  lastStatus = upstream.status;
613
2442
  lastBody = text;
614
- lastContentType = upstream.headers.get('content-type') ?? 'application/json';
2443
+ lastHeaders = forwardedHeaders;
615
2444
  }
616
- res.writeHead(lastStatus, { 'Content-Type': lastContentType });
2445
+ cleanup();
2446
+ res.writeHead(lastStatus, lastHeaders);
617
2447
  res.end(lastBody);
618
2448
  }
619
2449
  const httpServer = createHttpServer((req, res) => {
620
- res.setHeader('Access-Control-Allow-Origin', '*');
2450
+ const origin = req.headers.origin;
2451
+ if (typeof origin === 'string' && !isSafeLocalOrigin(origin, port)) {
2452
+ // Origin is optional for native MCP clients. When a browser supplies
2453
+ // one, only a literal loopback origin is safe for this local endpoint.
2454
+ res.writeHead(403, { 'Content-Type': 'application/json' });
2455
+ res.end(makeErrorResponse(null, -32600, 'Forbidden Origin'));
2456
+ return;
2457
+ }
2458
+ if (typeof origin === 'string')
2459
+ res.setHeader('Access-Control-Allow-Origin', origin);
621
2460
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
622
- res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version');
623
- res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, WWW-Authenticate');
2461
+ const requestedHeaders = req.headers['access-control-request-headers'];
2462
+ res.setHeader('Access-Control-Allow-Headers', corsAllowedHeaders(typeof requestedHeaders === 'string' ? requestedHeaders : undefined));
2463
+ res.setHeader('Vary', 'Origin, Access-Control-Request-Headers');
2464
+ res.setHeader('Access-Control-Expose-Headers', 'Mcp-Session-Id, WWW-Authenticate, MCP-Protocol-Version');
624
2465
  if (req.method === 'OPTIONS') {
625
2466
  res.writeHead(204);
626
2467
  res.end();
@@ -689,9 +2530,16 @@ export function createGateway(options) {
689
2530
  return new Promise((resolve) => {
690
2531
  if (restartTimer)
691
2532
  clearTimeout(restartTimer);
2533
+ for (const subscriptionId of [...subscriptions.keys()])
2534
+ closeSubscription(subscriptionId);
692
2535
  sessions.clear();
693
2536
  backend.onClose = null;
694
2537
  backend.close();
2538
+ // A client can leave a keep-alive connection open after its final
2539
+ // JSON-RPC response. Closing those sockets makes shutdown deterministic
2540
+ // without waiting for the HTTP server's idle timeout.
2541
+ httpServer.closeAllConnections();
2542
+ httpServer.unref();
695
2543
  httpServer.close(() => { resolve(); });
696
2544
  });
697
2545
  },