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