mcp-compose 0.3.2 → 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.
@@ -5,36 +5,547 @@ import { OAuthClient } from './oauth.js';
5
5
  import { killProcessTree } from './process-tree.js';
6
6
  class UnauthorizedError extends Error {
7
7
  wwwAuthenticate;
8
- constructor(wwwAuthenticate) {
9
- super('Upstream returned 401 Unauthorized');
8
+ status;
9
+ constructor(wwwAuthenticate, status = 401) {
10
+ super(status === 403 ? 'Upstream returned 403 Forbidden' : 'Upstream returned 401 Unauthorized');
10
11
  this.wwwAuthenticate = wwwAuthenticate;
12
+ this.status = status;
11
13
  this.name = 'UnauthorizedError';
12
14
  }
13
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
+ }
14
24
  // --- Helpers ---
15
25
  const REQUEST_TIMEOUT_MS = 120_000;
16
- const MAX_NOTIFICATION_BUFFER = 1000;
26
+ const BACKEND_PROBE_TIMEOUT_MS = 5_000;
17
27
  const MAX_STDOUT_LINE_LENGTH = 10 * 1024 * 1024; // 10 MB
18
28
  const MAX_SSE_BUFFER = 10 * 1024 * 1024; // 10 MB
19
- function isJsonRpcMessage(value) {
20
- 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));
21
213
  }
22
214
  function hasRequestId(msg) {
23
- 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)}`;
24
386
  }
25
387
  function readBody(req) {
26
388
  return new Promise((resolve, reject) => {
27
389
  const chunks = [];
28
- req.on('data', (chunk) => { chunks.push(chunk); });
29
- 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
+ });
30
411
  req.on('error', reject);
31
412
  });
32
413
  }
33
- let sseEventId = 0;
34
- function sendSSE(res, msg) {
35
- 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();
36
484
  return false;
37
- 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
+ };
38
549
  }
39
550
  function makeErrorResponse(id, code, message) {
40
551
  return JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id });
@@ -67,17 +578,52 @@ function createStdioBackend(command, args, logger) {
67
578
  const childStderr = child.stderr;
68
579
  const childStdin = child.stdin;
69
580
  const pendingRequests = new Map();
581
+ const cancelledRequestIds = new Set();
582
+ const cancelledRequestTimers = new Map();
70
583
  let stdoutBuffer = '';
71
584
  const backend = {
585
+ era: 'legacy',
586
+ ready: Promise.resolve(),
587
+ beginReadiness() { return this.ready; },
72
588
  onServerMessage: null,
73
589
  onClose: null,
74
- sendRequest(msg, _ctx) {
590
+ sendRequest(msg, _ctx, signal) {
75
591
  return new Promise((resolve) => {
76
- const timer = setTimeout(() => {
592
+ let settled = false;
593
+ const settle = (response) => {
594
+ if (settled)
595
+ return;
596
+ settled = true;
77
597
  pendingRequests.delete(msg.id);
78
- 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' } });
79
604
  }, REQUEST_TIMEOUT_MS);
80
- 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 });
81
627
  childStdin.write(JSON.stringify(msg) + '\n');
82
628
  });
83
629
  },
@@ -95,8 +641,21 @@ function createStdioBackend(command, args, logger) {
95
641
  clearTimeout(pending.timer);
96
642
  }
97
643
  pendingRequests.clear();
644
+ for (const timer of cancelledRequestTimers.values())
645
+ clearTimeout(timer);
646
+ cancelledRequestTimers.clear();
647
+ cancelledRequestIds.clear();
98
648
  if (child.pid)
99
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?.();
657
+ }
658
+ child.unref();
100
659
  },
101
660
  };
102
661
  childStdout.on('data', (chunk) => {
@@ -113,18 +672,31 @@ function createStdioBackend(command, args, logger) {
113
672
  continue;
114
673
  try {
115
674
  const parsed = JSON.parse(line);
116
- if (!isJsonRpcMessage(parsed))
117
- continue;
118
- // Route response to pending request, or forward as server-initiated message
119
- if (hasRequestId(parsed) && pendingRequests.has(parsed.id)) {
120
- const pending = pendingRequests.get(parsed.id);
121
- if (pending) {
122
- pendingRequests.delete(parsed.id);
123
- clearTimeout(pending.timer);
124
- 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
+ }
125
697
  }
126
698
  }
127
- else {
699
+ else if (isJsonRpcRequest(parsed)) {
128
700
  backend.onServerMessage?.(parsed);
129
701
  }
130
702
  }
@@ -157,42 +729,52 @@ function createStdioBackend(command, args, logger) {
157
729
  * in real time so in-flight clients see progress without waiting for the
158
730
  * entire response. Returns the JSON-RPC response matching originalMsg.
159
731
  */
160
- async function parseSseResponse(res, originalMsg, backend, logger) {
732
+ async function parseSseResponse(res, originalMsg, backend, logger, onEvent, controller) {
161
733
  const reader = res.body?.getReader();
162
734
  if (!reader)
163
735
  return undefined;
164
- const requestId = hasRequestId(originalMsg) ? originalMsg.id : null;
736
+ const requestId = hasBackendRequestId(originalMsg) ? originalMsg.id : undefined;
165
737
  const decoder = new TextDecoder();
166
738
  let buffer = '';
167
739
  let response;
168
740
  const handleBlock = (block) => {
169
- let data;
170
- for (const line of block.split('\n')) {
171
- if (line.startsWith('data: '))
172
- data = line.slice(6);
173
- else if (line.startsWith('data:'))
174
- 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(/^ /, ''));
175
746
  }
176
- if (!data?.trim())
177
- return;
747
+ const data = dataLines.join('\n');
748
+ if (!data.trim())
749
+ return false;
178
750
  let parsed;
179
751
  try {
180
752
  parsed = JSON.parse(data);
181
753
  }
182
754
  catch {
183
- return;
755
+ return false;
184
756
  }
185
- if (!isJsonRpcMessage(parsed))
186
- return;
187
- if (requestId !== null && hasRequestId(parsed) && parsed.id === requestId) {
757
+ if (isJsonRpcResponse(parsed) && requestId !== undefined && parsed.id === requestId) {
188
758
  response = parsed;
759
+ return true;
189
760
  }
190
- else {
191
- 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
+ }
192
774
  }
775
+ return false;
193
776
  };
194
777
  try {
195
- let searchFrom = 0;
196
778
  for (;;) {
197
779
  const { done, value } = await reader.read();
198
780
  if (done)
@@ -200,17 +782,22 @@ async function parseSseResponse(res, originalMsg, backend, logger) {
200
782
  buffer += decoder.decode(value, { stream: true });
201
783
  if (buffer.length > MAX_SSE_BUFFER) {
202
784
  logger('info', `SSE event exceeded ${String(MAX_SSE_BUFFER)} bytes without terminator; aborting`);
785
+ controller?.abort(new UpstreamBodyTooLargeError());
203
786
  await reader.cancel().catch(() => { });
204
- return response;
787
+ throw new UpstreamBodyTooLargeError();
205
788
  }
206
- let boundary = buffer.indexOf('\n\n', searchFrom);
207
- while (boundary !== -1) {
208
- handleBlock(buffer.slice(0, boundary));
209
- buffer = buffer.slice(boundary + 2);
210
- 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);
211
798
  }
212
- searchFrom = Math.max(0, buffer.length - 1);
213
799
  }
800
+ buffer += decoder.decode();
214
801
  if (buffer.trim())
215
802
  handleBlock(buffer);
216
803
  }
@@ -223,107 +810,706 @@ async function parseSseResponse(res, originalMsg, backend, logger) {
223
810
  }
224
811
  return response;
225
812
  }
226
- function createProxyBackend(remoteUrl, headers, authMode, oauthClient, logger) {
813
+ function createProxyBackend(remoteUrl, transport, headers, authMode, oauthClient, logger) {
227
814
  let remoteSessionId;
815
+ let remoteSessionAuthorization;
816
+ let hasRemoteSessionAuthorization = false;
817
+ let negotiatedProtocolVersion;
228
818
  let accessToken;
229
- // Preload cached token (managed mode only) so the first request avoids
230
- // an unnecessary 401 roundtrip. Passthrough mode never holds tokens.
231
- const initialTokenLoad = oauthClient
232
- ? 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 } : {});
233
851
  if (token && !accessToken)
234
852
  accessToken = token;
235
- }).catch(() => { })
236
- : Promise.resolve();
237
- async function forwardToRemote(msg, ctx, retryCount = 0) {
238
- await initialTokenLoad;
239
- const reqHeaders = {
240
- 'Content-Type': 'application/json',
241
- 'Accept': 'application/json, text/event-stream',
242
- ...headers,
243
- };
244
- if (authMode === 'passthrough') {
245
- if (ctx?.authorization)
246
- reqHeaders['Authorization'] = ctx.authorization;
247
853
  }
248
- else if (accessToken) {
249
- 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.
250
859
  }
251
- if (remoteSessionId && msg.method !== 'initialize') {
252
- 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();
253
866
  }
254
- let res;
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;
879
+ }
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);
255
910
  try {
256
- res = await fetch(remoteUrl, {
257
- method: 'POST',
258
- headers: reqHeaders,
259
- 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,
260
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);
261
1074
  }
262
- catch (err) {
263
- const errMsg = err instanceof Error ? err.message : String(err);
264
- logger('info', `Remote fetch failed: ${errMsg}`);
265
- return {
266
- jsonrpc: '2.0',
267
- id: hasRequestId(msg) ? msg.id : null,
268
- error: { code: -32000, message: `Remote server unreachable: ${errMsg}` },
269
- };
1075
+ finally {
1076
+ clearTimeout(timeout);
1077
+ signal?.removeEventListener('abort', abortFromCaller);
1078
+ activeFetches.delete(controller);
270
1079
  }
271
- if (res.status === 401) {
272
- const wwwAuth = res.headers.get('www-authenticate') ?? undefined;
273
- if (authMode === 'passthrough') {
274
- // Drain body so the connection can be reused.
275
- await res.text().catch(() => { });
276
- 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);
277
1275
  }
278
- if (oauthClient && retryCount < 2) {
279
- logger('info', 'Received 401, starting OAuth flow...');
280
- accessToken = await oauthClient.handleUnauthorized(wwwAuth);
281
- logger('info', 'OAuth completed, retrying request...');
282
- 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
+ }
283
1284
  }
1285
+ })();
1286
+ try {
1287
+ await sseConnect;
284
1288
  }
285
- const sessionHeader = res.headers.get('mcp-session-id');
286
- if (sessionHeader) {
287
- remoteSessionId = sessionHeader;
1289
+ catch (err) {
1290
+ sseConnect = undefined;
1291
+ sseEndpoint = undefined;
1292
+ throw err;
288
1293
  }
289
- if (res.status === 202)
290
- return undefined;
291
- if (!res.ok) {
292
- const text = await res.text();
293
- logger('info', `Remote error: ${String(res.status)} ${text.slice(0, 200)}`);
294
- return {
295
- jsonrpc: '2.0',
296
- id: hasRequestId(msg) ? msg.id : null,
297
- 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' } });
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
+ } });
298
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
+ }
299
1456
  }
300
- const contentType = res.headers.get('content-type') ?? '';
301
- // SSE response: parse events and extract JSON-RPC messages
302
- if (contentType.includes('text/event-stream')) {
303
- return await parseSseResponse(res, msg, backend, logger);
1457
+ finally {
1458
+ clearTimeout(timeout);
1459
+ signal?.removeEventListener('abort', abortFromCaller);
1460
+ activeFetches.delete(controller);
304
1461
  }
305
- return await res.json();
306
1462
  }
307
1463
  const backend = {
1464
+ era: 'legacy',
1465
+ ready: Promise.resolve(),
1466
+ beginReadiness() { return this.ready; },
308
1467
  onServerMessage: null,
309
1468
  onClose: null,
310
- async sendRequest(msg, ctx) {
311
- 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);
312
1473
  return response ?? { jsonrpc: '2.0', id: msg.id, error: { code: -32000, message: 'No response from remote' } };
313
1474
  },
314
- sendNotification(msg, ctx) {
315
- 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) => {
316
1483
  const errMsg = err instanceof Error ? err.message : String(err);
317
1484
  logger('debug', `Notification forward failed: ${errMsg}`);
318
1485
  });
319
1486
  },
320
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();
321
1497
  // Send DELETE to close remote session
322
- if (remoteSessionId) {
1498
+ if (backend.era === 'legacy' && remoteSessionId) {
323
1499
  const reqHeaders = { ...headers, 'Mcp-Session-Id': remoteSessionId };
324
- if (accessToken)
1500
+ if (authMode === 'passthrough') {
1501
+ if (remoteSessionAuthorization)
1502
+ reqHeaders['Authorization'] = remoteSessionAuthorization;
1503
+ }
1504
+ else if (accessToken) {
325
1505
  reqHeaders['Authorization'] = `Bearer ${accessToken}`;
326
- 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(() => { });
327
1513
  }
328
1514
  },
329
1515
  };
@@ -334,6 +1520,7 @@ export function createGateway(options) {
334
1520
  const logLevel = options.logLevel ?? 'info';
335
1521
  const { port } = options;
336
1522
  const sessions = new Map();
1523
+ const subscriptions = new Map();
337
1524
  const proxyAuthMode = options.mode === 'proxy'
338
1525
  ? options.authMode ?? 'managed'
339
1526
  : 'managed';
@@ -352,27 +1539,209 @@ export function createGateway(options) {
352
1539
  let nextRequestId = 1;
353
1540
  let restartBackoff = 1000;
354
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
+ }
355
1730
  function startBackend() {
356
1731
  let b;
357
1732
  if (options.mode === 'stdio') {
358
1733
  b = createStdioBackend(options.command, options.args ?? [], logger);
359
1734
  }
360
1735
  else {
361
- b = createProxyBackend(options.url, options.headers ?? {}, proxyAuthMode, sharedOAuthClient, logger);
1736
+ b = createProxyBackend(options.url, options.transport ?? 'http', options.headers ?? {}, proxyAuthMode, sharedOAuthClient, logger);
362
1737
  }
363
1738
  b.onServerMessage = (msg) => {
364
- for (const session of sessions.values()) {
365
- if (session.liveSSE && sendSSE(session.liveSSE, msg))
366
- continue;
367
- if (session.liveSSE)
368
- session.liveSSE = null;
369
- if (session.notificationBuffer.length < MAX_NOTIFICATION_BUFFER) {
370
- session.notificationBuffer.push(msg);
371
- }
372
- }
1739
+ publishChangeNotification(msg);
373
1740
  };
374
1741
  b.onClose = () => {
375
1742
  logger('info', `Backend died, restarting in ${String(restartBackoff)}ms`);
1743
+ for (const subscriptionId of [...subscriptions.keys()])
1744
+ closeSubscription(subscriptionId);
376
1745
  sessions.clear();
377
1746
  restartTimer = setTimeout(() => {
378
1747
  restartTimer = null;
@@ -380,18 +1749,158 @@ export function createGateway(options) {
380
1749
  restartBackoff = Math.min(restartBackoff * 2, 30_000);
381
1750
  }, restartBackoff);
382
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();
383
1813
  return b;
384
1814
  }
385
1815
  let backend = startBackend();
386
- 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
+ }
387
1835
  const originalId = msg.id;
388
- const internalId = nextRequestId++;
389
- 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);
390
1856
  restartBackoff = 1000;
391
- 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;
392
1873
  }
393
- function sendNotification(msg, ctx) {
394
- 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
+ });
395
1904
  }
396
1905
  function writeUnauthorized(res, err, requestId) {
397
1906
  if (res.headersSent)
@@ -399,81 +1908,121 @@ export function createGateway(options) {
399
1908
  const respHeaders = { 'Content-Type': 'application/json' };
400
1909
  if (err.wwwAuthenticate)
401
1910
  respHeaders['WWW-Authenticate'] = err.wwwAuthenticate;
402
- res.writeHead(401, respHeaders);
1911
+ res.writeHead(err.status, respHeaders);
403
1912
  res.end(JSON.stringify({
404
1913
  jsonrpc: '2.0',
405
1914
  id: requestId,
406
- error: { code: -32001, message: 'Unauthorized' },
1915
+ error: { code: -32001, message: err.status === 403 ? 'Forbidden' : 'Unauthorized' },
407
1916
  }));
408
1917
  }
409
- async function handleRequestOverSSE(res, message, session, ctx) {
410
- // Passthrough mode delays SSE headers so a 401 from upstream can still
411
- // surface as an HTTP 401 to the client (re-auth requires this).
412
- if (proxyAuthMode === 'passthrough') {
413
- let response;
414
- try {
415
- 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;
416
1949
  }
417
- catch (err) {
418
- if (err instanceof UnauthorizedError) {
419
- writeUnauthorized(res, err, message.id);
420
- return;
421
- }
422
- 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;
423
1958
  }
424
- res.writeHead(200, {
425
- 'Content-Type': 'text/event-stream',
426
- 'Cache-Control': 'no-cache',
427
- 'Mcp-Session-Id': session.id,
428
- });
429
- const pending = session.notificationBuffer;
430
- session.notificationBuffer = [];
431
- for (const notif of pending)
432
- sendSSE(res, notif);
433
- sendSSE(res, response);
434
- res.end();
435
- return;
436
- }
437
- res.writeHead(200, {
438
- 'Content-Type': 'text/event-stream',
439
- 'Cache-Control': 'no-cache',
440
- 'Mcp-Session-Id': session.id,
441
- });
442
- const pending = session.notificationBuffer;
443
- session.notificationBuffer = [];
444
- for (const notif of pending)
445
- sendSSE(res, notif);
446
- session.liveSSE = res;
447
- const detachIfOwned = () => {
448
- if (session.liveSSE === res)
449
- 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;
450
1967
  };
451
- res.on('close', detachIfOwned);
452
1968
  try {
453
- const response = await sendRequest(message, ctx);
454
- 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);
455
1973
  }
456
1974
  catch (err) {
457
- const errMsg = err instanceof Error ? err.message : String(err);
458
- 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({
459
1986
  jsonrpc: '2.0',
460
1987
  id: message.id,
461
- error: { code: -32603, message: errMsg },
1988
+ error: { code: -32603, message: 'Gateway request failed' },
462
1989
  });
463
1990
  }
464
1991
  finally {
465
- res.removeListener('close', detachIfOwned);
466
- detachIfOwned();
467
- res.end();
1992
+ res.removeListener('close', detach);
1993
+ await writer?.end();
1994
+ if (!writer && !res.writableEnded && !res.destroyed)
1995
+ res.end();
468
1996
  }
469
1997
  }
470
1998
  function respondWithJson(res, response, sessionId) {
471
- 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);
472
2004
  res.end(JSON.stringify(response));
473
2005
  }
474
2006
  // --- HTTP handlers ---
475
2007
  async function handlePost(req, res) {
476
- 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
+ }
477
2026
  let message;
478
2027
  try {
479
2028
  message = JSON.parse(body);
@@ -483,40 +2032,212 @@ export function createGateway(options) {
483
2032
  res.end(makeErrorResponse(null, -32700, 'Parse error'));
484
2033
  return;
485
2034
  }
486
- if (!isJsonRpcMessage(message)) {
2035
+ if (!isJsonRpcRequest(message)) {
487
2036
  res.writeHead(400, { 'Content-Type': 'application/json' });
488
2037
  res.end(makeErrorResponse(null, -32600, 'Invalid Request'));
489
2038
  return;
490
2039
  }
491
2040
  const authHeader = req.headers.authorization;
492
- const ctx = {};
2041
+ const protocolVersionHeader = req.headers['mcp-protocol-version'];
2042
+ const ctx = { mcpParamHeaders: [] };
493
2043
  if (typeof authHeader === 'string')
494
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
+ }
495
2181
  if (message.method === 'initialize') {
496
2182
  const session = {
497
2183
  id: randomUUID(),
498
- notificationBuffer: [],
499
- 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,
500
2189
  };
501
2190
  sessions.set(session.id, session);
2191
+ ctx.clientCapabilities = session.clientCapabilities;
2192
+ ctx.clientInfo = session.clientInfo;
502
2193
  logger('info', `Session ${session.id.slice(0, 8)} created (${String(sessions.size)} active)`);
503
2194
  try {
504
2195
  if (hasRequestId(message)) {
505
- 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
+ }
506
2221
  respondWithJson(res, response, session.id);
507
2222
  }
508
2223
  else {
509
- sendNotification(message, ctx);
2224
+ sendNotification(message, ctx, 'legacy', requestController.signal);
510
2225
  res.writeHead(202, { 'Mcp-Session-Id': session.id });
511
2226
  res.end();
512
2227
  }
513
2228
  }
514
2229
  catch (err) {
515
2230
  sessions.delete(session.id);
2231
+ if (requestController.signal.aborted || res.destroyed)
2232
+ resetAbandonedLegacyProxyBackend(backend);
516
2233
  if (err instanceof UnauthorizedError) {
517
2234
  writeUnauthorized(res, err, message.id ?? null);
518
2235
  return;
519
2236
  }
2237
+ if (err instanceof CredentialAffinityError) {
2238
+ writeCredentialConflict(res, message.id ?? null);
2239
+ return;
2240
+ }
520
2241
  throw err;
521
2242
  }
522
2243
  return;
@@ -533,14 +2254,51 @@ export function createGateway(options) {
533
2254
  res.end(makeErrorResponse(message.id ?? null, -32000, 'Session not found or expired'));
534
2255
  return;
535
2256
  }
536
- 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)) {
537
2295
  const clientAcceptsSSE = (req.headers.accept ?? '').includes('text/event-stream');
538
2296
  try {
539
2297
  if (clientAcceptsSSE) {
540
- await handleRequestOverSSE(res, message, session, ctx);
2298
+ await handleRequestOverSSE(res, message, session, ctx, 'legacy');
541
2299
  }
542
2300
  else {
543
- const response = await sendRequest(message, ctx);
2301
+ const response = await sendRequest(message, ctx, 'legacy', requestController.signal);
544
2302
  respondWithJson(res, response, session.id);
545
2303
  }
546
2304
  }
@@ -549,26 +2307,64 @@ export function createGateway(options) {
549
2307
  writeUnauthorized(res, err, message.id);
550
2308
  return;
551
2309
  }
2310
+ if (err instanceof CredentialAffinityError) {
2311
+ writeCredentialConflict(res, message.id);
2312
+ return;
2313
+ }
552
2314
  throw err;
553
2315
  }
554
2316
  return;
555
2317
  }
556
- 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);
557
2344
  res.writeHead(202);
558
2345
  res.end();
559
2346
  }
560
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
+ }
561
2353
  const sessionId = req.headers['mcp-session-id'];
562
2354
  if (typeof sessionId !== 'string') {
563
2355
  res.writeHead(400, { 'Content-Type': 'text/plain' });
564
2356
  res.end('Missing Mcp-Session-Id header');
565
2357
  return;
566
2358
  }
567
- if (!sessions.delete(sessionId)) {
2359
+ const session = sessions.get(sessionId);
2360
+ if (!session) {
568
2361
  res.writeHead(404, { 'Content-Type': 'text/plain' });
569
2362
  res.end('Session not found');
570
2363
  return;
571
2364
  }
2365
+ for (const subscriptionId of session.subscriptions)
2366
+ closeSubscription(subscriptionId);
2367
+ sessions.delete(sessionId);
572
2368
  logger('info', `Session ${sessionId.slice(0, 8)} destroyed (${String(sessions.size)} active)`);
573
2369
  res.writeHead(200);
574
2370
  res.end();
@@ -583,15 +2379,37 @@ export function createGateway(options) {
583
2379
  res.end('Method Not Allowed');
584
2380
  return;
585
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
+ };
586
2396
  let lastStatus = 404;
587
2397
  let lastBody = '';
588
- let lastContentType = 'application/json';
2398
+ let lastHeaders = { 'content-type': 'application/json' };
589
2399
  for (const target of upstreamProtectedResourceUrls) {
590
2400
  let upstream;
591
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);
592
2408
  upstream = await fetch(target, {
593
2409
  method: 'GET',
594
- headers: { 'Accept': 'application/json' },
2410
+ headers,
2411
+ signal: controller.signal,
2412
+ redirect: 'error',
595
2413
  });
596
2414
  }
597
2415
  catch (err) {
@@ -599,25 +2417,51 @@ export function createGateway(options) {
599
2417
  logger('info', `Protected-resource fetch failed (${target}): ${errMsg}`);
600
2418
  continue;
601
2419
  }
602
- 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';
603
2435
  if (upstream.ok) {
604
- const ct = upstream.headers.get('content-type') ?? 'application/json';
605
- res.writeHead(upstream.status, { 'Content-Type': ct });
2436
+ cleanup();
2437
+ res.writeHead(upstream.status, forwardedHeaders);
606
2438
  res.end(text);
607
2439
  return;
608
2440
  }
609
2441
  lastStatus = upstream.status;
610
2442
  lastBody = text;
611
- lastContentType = upstream.headers.get('content-type') ?? 'application/json';
2443
+ lastHeaders = forwardedHeaders;
612
2444
  }
613
- res.writeHead(lastStatus, { 'Content-Type': lastContentType });
2445
+ cleanup();
2446
+ res.writeHead(lastStatus, lastHeaders);
614
2447
  res.end(lastBody);
615
2448
  }
616
2449
  const httpServer = createHttpServer((req, res) => {
617
- 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);
618
2460
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
619
- res.setHeader('Access-Control-Allow-Headers', 'Authorization, Content-Type, Accept, Mcp-Session-Id, MCP-Protocol-Version');
620
- 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');
621
2465
  if (req.method === 'OPTIONS') {
622
2466
  res.writeHead(204);
623
2467
  res.end();
@@ -686,9 +2530,16 @@ export function createGateway(options) {
686
2530
  return new Promise((resolve) => {
687
2531
  if (restartTimer)
688
2532
  clearTimeout(restartTimer);
2533
+ for (const subscriptionId of [...subscriptions.keys()])
2534
+ closeSubscription(subscriptionId);
689
2535
  sessions.clear();
690
2536
  backend.onClose = null;
691
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();
692
2543
  httpServer.close(() => { resolve(); });
693
2544
  });
694
2545
  },