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.
- package/README.md +26 -4
- package/dist/bin/mcp-compose.js +75 -19
- package/dist/bin/mcp-compose.js.map +1 -1
- package/dist/package.json +14 -3
- package/dist/src/config.d.ts +4 -1
- package/dist/src/config.d.ts.map +1 -1
- package/dist/src/config.js +15 -2
- package/dist/src/config.js.map +1 -1
- package/dist/src/gateway.d.ts.map +1 -1
- package/dist/src/gateway.js +2059 -208
- package/dist/src/gateway.js.map +1 -1
- package/dist/src/oauth.d.ts +27 -3
- package/dist/src/oauth.d.ts.map +1 -1
- package/dist/src/oauth.js +557 -78
- package/dist/src/oauth.js.map +1 -1
- package/dist/src/sync.d.ts.map +1 -1
- package/dist/src/sync.js +70 -4
- package/dist/src/sync.js.map +1 -1
- package/dist/src/types.d.ts +9 -0
- package/dist/src/types.d.ts.map +1 -1
- package/package.json +14 -3
package/dist/src/gateway.js
CHANGED
|
@@ -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
|
-
|
|
9
|
-
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
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
|
|
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
|
-
|
|
29
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
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
|
-
|
|
592
|
+
let settled = false;
|
|
593
|
+
const settle = (response) => {
|
|
594
|
+
if (settled)
|
|
595
|
+
return;
|
|
596
|
+
settled = true;
|
|
77
597
|
pendingRequests.delete(msg.id);
|
|
78
|
-
|
|
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
|
-
|
|
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 (
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
if (
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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 =
|
|
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
|
-
|
|
170
|
-
for (const line of block.split(
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
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
|
-
|
|
177
|
-
|
|
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 (
|
|
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
|
-
|
|
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
|
-
|
|
787
|
+
throw new UpstreamBodyTooLargeError();
|
|
205
788
|
}
|
|
206
|
-
let boundary =
|
|
207
|
-
while (boundary !==
|
|
208
|
-
handleBlock(buffer.slice(0, boundary));
|
|
209
|
-
buffer = buffer.slice(boundary +
|
|
210
|
-
|
|
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
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
249
|
-
|
|
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
|
-
|
|
252
|
-
|
|
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
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
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
|
-
|
|
286
|
-
|
|
287
|
-
|
|
1289
|
+
catch (err) {
|
|
1290
|
+
sseConnect = undefined;
|
|
1291
|
+
sseEndpoint = undefined;
|
|
1292
|
+
throw err;
|
|
288
1293
|
}
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
1500
|
+
if (authMode === 'passthrough') {
|
|
1501
|
+
if (remoteSessionAuthorization)
|
|
1502
|
+
reqHeaders['Authorization'] = remoteSessionAuthorization;
|
|
1503
|
+
}
|
|
1504
|
+
else if (accessToken) {
|
|
325
1505
|
reqHeaders['Authorization'] = `Bearer ${accessToken}`;
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
389
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
|
|
425
|
-
|
|
426
|
-
'
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
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
|
-
|
|
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
|
-
|
|
458
|
-
|
|
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:
|
|
1988
|
+
error: { code: -32603, message: 'Gateway request failed' },
|
|
462
1989
|
});
|
|
463
1990
|
}
|
|
464
1991
|
finally {
|
|
465
|
-
res.removeListener('close',
|
|
466
|
-
|
|
467
|
-
res.
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
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
|
|
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
|
-
|
|
499
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
605
|
-
res.writeHead(upstream.status,
|
|
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
|
-
|
|
2443
|
+
lastHeaders = forwardedHeaders;
|
|
612
2444
|
}
|
|
613
|
-
|
|
2445
|
+
cleanup();
|
|
2446
|
+
res.writeHead(lastStatus, lastHeaders);
|
|
614
2447
|
res.end(lastBody);
|
|
615
2448
|
}
|
|
616
2449
|
const httpServer = createHttpServer((req, res) => {
|
|
617
|
-
|
|
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
|
-
|
|
620
|
-
res.setHeader('Access-Control-
|
|
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
|
},
|