@j0hanz/superfetch 2.5.3 → 2.7.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 +350 -226
- package/dist/assets/logo.svg +24837 -24835
- package/dist/cache.d.ts +28 -20
- package/dist/cache.js +292 -512
- package/dist/config.d.ts +41 -7
- package/dist/config.js +316 -154
- package/dist/crypto.js +25 -12
- package/dist/dom-noise-removal.js +382 -421
- package/dist/errors.d.ts +2 -2
- package/dist/errors.js +25 -8
- package/dist/fetch.d.ts +19 -16
- package/dist/fetch.js +1207 -538
- package/dist/host-normalization.js +40 -10
- package/dist/http-native.js +641 -283
- package/dist/index.js +67 -7
- package/dist/instructions.md +44 -31
- package/dist/ip-blocklist.d.ts +8 -0
- package/dist/ip-blocklist.js +65 -0
- package/dist/json.js +14 -9
- package/dist/language-detection.d.ts +2 -11
- package/dist/language-detection.js +313 -280
- package/dist/markdown-cleanup.d.ts +0 -1
- package/dist/markdown-cleanup.js +391 -429
- package/dist/mcp-validator.js +4 -2
- package/dist/mcp.js +191 -136
- package/dist/observability.js +89 -21
- package/dist/resources.js +16 -6
- package/dist/server-tuning.d.ts +2 -0
- package/dist/server-tuning.js +25 -23
- package/dist/session.d.ts +1 -0
- package/dist/session.js +41 -33
- package/dist/tasks.d.ts +3 -0
- package/dist/tasks.js +167 -51
- package/dist/timer-utils.d.ts +5 -0
- package/dist/timer-utils.js +20 -0
- package/dist/tools.d.ts +30 -5
- package/dist/tools.js +319 -184
- package/dist/transform-types.d.ts +6 -1
- package/dist/transform.d.ts +3 -2
- package/dist/transform.js +1243 -421
- package/dist/type-guards.d.ts +1 -0
- package/dist/type-guards.js +7 -0
- package/dist/workers/transform-child.d.ts +1 -0
- package/dist/workers/transform-child.js +118 -0
- package/dist/workers/transform-worker.js +87 -78
- package/package.json +14 -6
package/dist/http-native.js
CHANGED
|
@@ -1,28 +1,33 @@
|
|
|
1
|
+
import { Buffer } from 'node:buffer';
|
|
1
2
|
import { randomUUID } from 'node:crypto';
|
|
2
3
|
import { createServer, } from 'node:http';
|
|
4
|
+
import { isIP } from 'node:net';
|
|
5
|
+
import { freemem, hostname, totalmem } from 'node:os';
|
|
6
|
+
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
|
7
|
+
import process from 'node:process';
|
|
8
|
+
import { Writable } from 'node:stream';
|
|
9
|
+
import { pipeline } from 'node:stream/promises';
|
|
3
10
|
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
4
|
-
import { URL, URLSearchParams } from 'node:url';
|
|
5
11
|
import { InvalidTokenError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
6
12
|
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
7
13
|
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
8
14
|
import { keys as cacheKeys, handleDownload } from './cache.js';
|
|
9
15
|
import { config, enableHttpMode, serverVersion } from './config.js';
|
|
10
|
-
import { timingSafeEqualUtf8 } from './crypto.js';
|
|
16
|
+
import { sha256Hex, timingSafeEqualUtf8 } from './crypto.js';
|
|
11
17
|
import { normalizeHost } from './host-normalization.js';
|
|
18
|
+
import { createDefaultBlockList, normalizeIpForBlockList, } from './ip-blocklist.js';
|
|
12
19
|
import { acceptsEventStream, isJsonRpcBatchRequest, isMcpRequestBody, } from './mcp-validator.js';
|
|
13
20
|
import { createMcpServer } from './mcp.js';
|
|
14
|
-
import { logError, logInfo, logWarn } from './observability.js';
|
|
21
|
+
import { logError, logInfo, logWarn, runWithRequestContext, } from './observability.js';
|
|
15
22
|
import { applyHttpServerTuning, drainConnectionsOnShutdown, } from './server-tuning.js';
|
|
16
23
|
import { composeCloseHandlers, createSessionStore, createSlotTracker, ensureSessionCapacity, reserveSessionSlot, startSessionCleanupLoop, } from './session.js';
|
|
17
24
|
import { getTransformPoolStats } from './transform.js';
|
|
18
25
|
import { isObject } from './type-guards.js';
|
|
19
|
-
/* -------------------------------------------------------------------------------------------------
|
|
20
|
-
* Transport adaptation
|
|
21
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
22
26
|
function createTransportAdapter(transportImpl) {
|
|
23
27
|
const noopOnClose = () => { };
|
|
24
28
|
const noopOnError = () => { };
|
|
25
29
|
const noopOnMessage = () => { };
|
|
30
|
+
const baseOnClose = transportImpl.onclose;
|
|
26
31
|
let oncloseHandler = noopOnClose;
|
|
27
32
|
let onerrorHandler = noopOnError;
|
|
28
33
|
let onmessageHandler = noopOnMessage;
|
|
@@ -35,7 +40,7 @@ function createTransportAdapter(transportImpl) {
|
|
|
35
40
|
},
|
|
36
41
|
set onclose(handler) {
|
|
37
42
|
oncloseHandler = handler;
|
|
38
|
-
transportImpl.onclose = handler;
|
|
43
|
+
transportImpl.onclose = composeCloseHandlers(baseOnClose, handler);
|
|
39
44
|
},
|
|
40
45
|
get onerror() {
|
|
41
46
|
return onerrorHandler;
|
|
@@ -53,111 +58,313 @@ function createTransportAdapter(transportImpl) {
|
|
|
53
58
|
},
|
|
54
59
|
};
|
|
55
60
|
}
|
|
56
|
-
function
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
function sendJson(res, status, body) {
|
|
62
|
+
res.statusCode = status;
|
|
63
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
64
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
65
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
66
|
+
res.end(JSON.stringify(body));
|
|
67
|
+
}
|
|
68
|
+
function sendText(res, status, body) {
|
|
69
|
+
res.statusCode = status;
|
|
70
|
+
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
71
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
72
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
73
|
+
res.end(body);
|
|
74
|
+
}
|
|
75
|
+
function sendEmpty(res, status) {
|
|
76
|
+
res.statusCode = status;
|
|
77
|
+
res.setHeader('Content-Length', '0');
|
|
78
|
+
res.end();
|
|
79
|
+
}
|
|
80
|
+
function drainRequest(req) {
|
|
81
|
+
if (req.readableEnded)
|
|
82
|
+
return;
|
|
83
|
+
try {
|
|
84
|
+
req.resume();
|
|
85
|
+
}
|
|
86
|
+
catch {
|
|
87
|
+
// Best-effort only.
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
function createRequestAbortSignal(req) {
|
|
91
|
+
const controller = new AbortController();
|
|
92
|
+
let cleanedUp = false;
|
|
93
|
+
const abortRequest = () => {
|
|
94
|
+
if (cleanedUp)
|
|
95
|
+
return;
|
|
96
|
+
if (!controller.signal.aborted)
|
|
97
|
+
controller.abort();
|
|
61
98
|
};
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
99
|
+
if (req.destroyed) {
|
|
100
|
+
abortRequest();
|
|
101
|
+
return {
|
|
102
|
+
signal: controller.signal,
|
|
103
|
+
cleanup: () => {
|
|
104
|
+
cleanedUp = true;
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
const onAborted = () => {
|
|
109
|
+
abortRequest();
|
|
66
110
|
};
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
return this;
|
|
111
|
+
const onClose = () => {
|
|
112
|
+
abortRequest();
|
|
70
113
|
};
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
this.end();
|
|
74
|
-
return this;
|
|
114
|
+
const onError = () => {
|
|
115
|
+
abortRequest();
|
|
75
116
|
};
|
|
76
|
-
|
|
117
|
+
req.once('aborted', onAborted);
|
|
118
|
+
req.once('close', onClose);
|
|
119
|
+
req.once('error', onError);
|
|
120
|
+
return {
|
|
121
|
+
signal: controller.signal,
|
|
122
|
+
cleanup: () => {
|
|
123
|
+
cleanedUp = true;
|
|
124
|
+
req.removeListener('aborted', onAborted);
|
|
125
|
+
req.removeListener('close', onClose);
|
|
126
|
+
req.removeListener('error', onError);
|
|
127
|
+
},
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function normalizeRemoteAddress(address) {
|
|
131
|
+
if (!address)
|
|
132
|
+
return null;
|
|
133
|
+
const trimmed = address.trim();
|
|
134
|
+
if (!trimmed)
|
|
135
|
+
return null;
|
|
136
|
+
const zoneIndex = trimmed.indexOf('%');
|
|
137
|
+
const withoutZone = zoneIndex > 0 ? trimmed.slice(0, zoneIndex) : trimmed;
|
|
138
|
+
const normalized = withoutZone.toLowerCase();
|
|
139
|
+
if (normalized.startsWith('::ffff:')) {
|
|
140
|
+
const mapped = normalized.slice('::ffff:'.length);
|
|
141
|
+
if (isIP(mapped) === 4)
|
|
142
|
+
return mapped;
|
|
143
|
+
}
|
|
144
|
+
if (isIP(normalized))
|
|
145
|
+
return normalized;
|
|
146
|
+
return trimmed;
|
|
77
147
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
148
|
+
function registerInboundBlockList(server) {
|
|
149
|
+
if (!config.server.http.blockPrivateConnections)
|
|
150
|
+
return;
|
|
151
|
+
const blockList = createDefaultBlockList();
|
|
152
|
+
server.on('connection', (socket) => {
|
|
153
|
+
const remoteAddress = normalizeRemoteAddress(socket.remoteAddress);
|
|
154
|
+
if (!remoteAddress)
|
|
155
|
+
return;
|
|
156
|
+
const normalized = normalizeIpForBlockList(remoteAddress);
|
|
157
|
+
if (!normalized)
|
|
158
|
+
return;
|
|
159
|
+
if (blockList.check(normalized.ip, normalized.family)) {
|
|
160
|
+
logWarn('Blocked inbound connection', {
|
|
161
|
+
remoteAddress: normalized.ip,
|
|
162
|
+
family: normalized.family,
|
|
163
|
+
});
|
|
164
|
+
socket.destroy();
|
|
165
|
+
}
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
function getHeaderValue(req, name) {
|
|
169
|
+
const val = req.headers[name];
|
|
170
|
+
if (!val)
|
|
171
|
+
return null;
|
|
172
|
+
if (Array.isArray(val))
|
|
173
|
+
return val[0] ?? null;
|
|
174
|
+
return val;
|
|
175
|
+
}
|
|
176
|
+
function getMcpSessionId(req) {
|
|
177
|
+
return (getHeaderValue(req, 'mcp-session-id') ??
|
|
178
|
+
getHeaderValue(req, 'x-mcp-session-id'));
|
|
179
|
+
}
|
|
180
|
+
function buildRequestContext(req, res, signal) {
|
|
181
|
+
let url;
|
|
182
|
+
try {
|
|
183
|
+
url = new URL(req.url ?? '', 'http://localhost');
|
|
184
|
+
}
|
|
185
|
+
catch {
|
|
186
|
+
sendJson(res, 400, { error: 'Invalid request URL' });
|
|
187
|
+
return null;
|
|
188
|
+
}
|
|
189
|
+
return {
|
|
190
|
+
req,
|
|
191
|
+
res,
|
|
192
|
+
url,
|
|
193
|
+
method: req.method,
|
|
194
|
+
ip: normalizeRemoteAddress(req.socket.remoteAddress),
|
|
195
|
+
body: undefined,
|
|
196
|
+
...(signal ? { signal } : {}),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
async function closeTransportBestEffort(transport, context) {
|
|
200
|
+
try {
|
|
201
|
+
await transport.close();
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
logWarn('Transport close failed', { context, error });
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
class JsonBodyError extends Error {
|
|
208
|
+
kind;
|
|
209
|
+
constructor(kind, message) {
|
|
210
|
+
super(message);
|
|
211
|
+
this.name = 'JsonBodyError';
|
|
212
|
+
this.kind = kind;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
81
216
|
class JsonBodyReader {
|
|
82
|
-
async read(req, limit =
|
|
83
|
-
const contentType = req
|
|
217
|
+
async read(req, limit = DEFAULT_BODY_LIMIT_BYTES, signal) {
|
|
218
|
+
const contentType = getHeaderValue(req, 'content-type');
|
|
84
219
|
if (!contentType?.includes('application/json'))
|
|
85
220
|
return undefined;
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
const
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (size > limit) {
|
|
221
|
+
const contentLengthHeader = getHeaderValue(req, 'content-length');
|
|
222
|
+
if (contentLengthHeader) {
|
|
223
|
+
const contentLength = Number.parseInt(contentLengthHeader, 10);
|
|
224
|
+
if (Number.isFinite(contentLength) && contentLength > limit) {
|
|
225
|
+
try {
|
|
92
226
|
req.destroy();
|
|
93
|
-
reject(new Error('Payload too large'));
|
|
94
|
-
return;
|
|
95
227
|
}
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
228
|
+
catch {
|
|
229
|
+
// Best-effort only.
|
|
230
|
+
}
|
|
231
|
+
throw new JsonBodyError('payload-too-large', 'Payload too large');
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
if (signal?.aborted || req.destroyed) {
|
|
235
|
+
throw new JsonBodyError('read-failed', 'Request aborted');
|
|
236
|
+
}
|
|
237
|
+
const body = await this.readBody(req, limit, signal);
|
|
238
|
+
if (!body)
|
|
239
|
+
return undefined;
|
|
240
|
+
try {
|
|
241
|
+
return JSON.parse(body);
|
|
242
|
+
}
|
|
243
|
+
catch (err) {
|
|
244
|
+
throw new JsonBodyError('invalid-json', err instanceof Error ? err.message : String(err));
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
async readBody(req, limit, signal) {
|
|
248
|
+
const abortListener = this.attachAbortListener(req, signal);
|
|
249
|
+
try {
|
|
250
|
+
const { chunks, size } = await this.collectChunks(req, limit, signal);
|
|
251
|
+
if (chunks.length === 0)
|
|
252
|
+
return undefined;
|
|
253
|
+
return Buffer.concat(chunks, size).toString('utf8');
|
|
254
|
+
}
|
|
255
|
+
finally {
|
|
256
|
+
this.detachAbortListener(signal, abortListener);
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
attachAbortListener(req, signal) {
|
|
260
|
+
if (!signal)
|
|
261
|
+
return null;
|
|
262
|
+
const listener = () => {
|
|
263
|
+
try {
|
|
264
|
+
req.destroy();
|
|
265
|
+
}
|
|
266
|
+
catch {
|
|
267
|
+
// Best-effort only.
|
|
268
|
+
}
|
|
269
|
+
};
|
|
270
|
+
if (signal.aborted) {
|
|
271
|
+
listener();
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
signal.addEventListener('abort', listener, { once: true });
|
|
275
|
+
}
|
|
276
|
+
return listener;
|
|
277
|
+
}
|
|
278
|
+
detachAbortListener(signal, listener) {
|
|
279
|
+
if (!signal || !listener)
|
|
280
|
+
return;
|
|
281
|
+
try {
|
|
282
|
+
signal.removeEventListener('abort', listener);
|
|
283
|
+
}
|
|
284
|
+
catch {
|
|
285
|
+
// Best-effort cleanup.
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
async collectChunks(req, limit, signal) {
|
|
289
|
+
let size = 0;
|
|
290
|
+
const chunks = [];
|
|
291
|
+
const sink = new Writable({
|
|
292
|
+
write: (chunk, _encoding, callback) => {
|
|
99
293
|
try {
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
294
|
+
if (signal?.aborted || req.destroyed) {
|
|
295
|
+
callback(new JsonBodyError('read-failed', 'Request aborted'));
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
const buf = this.normalizeChunk(chunk);
|
|
299
|
+
size += buf.length;
|
|
300
|
+
if (size > limit) {
|
|
301
|
+
req.destroy();
|
|
302
|
+
callback(new JsonBodyError('payload-too-large', 'Payload too large'));
|
|
103
303
|
return;
|
|
104
304
|
}
|
|
105
|
-
|
|
305
|
+
chunks.push(buf);
|
|
306
|
+
callback();
|
|
106
307
|
}
|
|
107
308
|
catch (err) {
|
|
108
|
-
|
|
309
|
+
callback(err instanceof Error ? err : new Error(String(err)));
|
|
109
310
|
}
|
|
110
|
-
}
|
|
111
|
-
req.on('error', (err) => {
|
|
112
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
113
|
-
});
|
|
311
|
+
},
|
|
114
312
|
});
|
|
313
|
+
try {
|
|
314
|
+
if (signal?.aborted || req.destroyed) {
|
|
315
|
+
throw new JsonBodyError('read-failed', 'Request aborted');
|
|
316
|
+
}
|
|
317
|
+
await pipeline(req, sink, signal ? { signal } : undefined);
|
|
318
|
+
return { chunks, size };
|
|
319
|
+
}
|
|
320
|
+
catch (err) {
|
|
321
|
+
if (err instanceof JsonBodyError)
|
|
322
|
+
throw err;
|
|
323
|
+
if (signal?.aborted || req.destroyed) {
|
|
324
|
+
throw new JsonBodyError('read-failed', 'Request aborted');
|
|
325
|
+
}
|
|
326
|
+
throw new JsonBodyError('read-failed', err instanceof Error ? err.message : String(err));
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
normalizeChunk(chunk) {
|
|
330
|
+
if (Buffer.isBuffer(chunk))
|
|
331
|
+
return chunk;
|
|
332
|
+
if (typeof chunk === 'string')
|
|
333
|
+
return Buffer.from(chunk, 'utf8');
|
|
334
|
+
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
115
335
|
}
|
|
116
336
|
}
|
|
117
337
|
const jsonBodyReader = new JsonBodyReader();
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
const
|
|
122
|
-
if (
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
else
|
|
126
|
-
query[key] = [existing, value];
|
|
338
|
+
class CorsPolicy {
|
|
339
|
+
handle(ctx) {
|
|
340
|
+
const { req, res } = ctx;
|
|
341
|
+
const origin = getHeaderValue(req, 'origin');
|
|
342
|
+
if (origin) {
|
|
343
|
+
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
344
|
+
res.setHeader('Vary', 'Origin');
|
|
127
345
|
}
|
|
128
346
|
else {
|
|
129
|
-
|
|
347
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
130
348
|
}
|
|
131
|
-
}
|
|
132
|
-
return query;
|
|
133
|
-
}
|
|
134
|
-
function getHeaderValue(req, name) {
|
|
135
|
-
const val = req.headers[name.toLowerCase()];
|
|
136
|
-
if (!val)
|
|
137
|
-
return null;
|
|
138
|
-
if (Array.isArray(val))
|
|
139
|
-
return val[0] ?? null;
|
|
140
|
-
return val;
|
|
141
|
-
}
|
|
142
|
-
/* -------------------------------------------------------------------------------------------------
|
|
143
|
-
* CORS & Host/Origin policy
|
|
144
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
145
|
-
class CorsPolicy {
|
|
146
|
-
handle(req, res) {
|
|
147
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
148
349
|
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
|
|
149
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, MCP-Protocol-Version, X-MCP-Session-ID');
|
|
150
|
-
if (req.method
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
}
|
|
155
|
-
return false;
|
|
350
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, MCP-Protocol-Version, MCP-Session-ID, X-MCP-Session-ID, Last-Event-ID');
|
|
351
|
+
if (req.method !== 'OPTIONS')
|
|
352
|
+
return false;
|
|
353
|
+
sendEmpty(res, 204);
|
|
354
|
+
return true;
|
|
156
355
|
}
|
|
157
356
|
}
|
|
158
357
|
const corsPolicy = new CorsPolicy();
|
|
159
358
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
160
359
|
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
|
|
360
|
+
function hasConstantTimeMatch(candidates, input) {
|
|
361
|
+
// Avoid leaking match index via early-return.
|
|
362
|
+
let matched = 0;
|
|
363
|
+
for (const candidate of candidates) {
|
|
364
|
+
matched |= timingSafeEqualUtf8(candidate, input) ? 1 : 0;
|
|
365
|
+
}
|
|
366
|
+
return matched === 1;
|
|
367
|
+
}
|
|
161
368
|
function isWildcardHost(host) {
|
|
162
369
|
return WILDCARD_HOSTS.has(host);
|
|
163
370
|
}
|
|
@@ -176,20 +383,21 @@ function buildAllowedHosts() {
|
|
|
176
383
|
}
|
|
177
384
|
const ALLOWED_HOSTS = buildAllowedHosts();
|
|
178
385
|
class HostOriginPolicy {
|
|
179
|
-
validate(
|
|
386
|
+
validate(ctx) {
|
|
387
|
+
const { req, res } = ctx;
|
|
180
388
|
const host = this.resolveHostHeader(req);
|
|
181
389
|
if (!host)
|
|
182
390
|
return this.reject(res, 400, 'Missing or invalid Host header');
|
|
183
391
|
if (!ALLOWED_HOSTS.has(host))
|
|
184
392
|
return this.reject(res, 403, 'Host not allowed');
|
|
185
393
|
const originHeader = getHeaderValue(req, 'origin');
|
|
186
|
-
if (originHeader)
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
394
|
+
if (!originHeader)
|
|
395
|
+
return true;
|
|
396
|
+
const originHost = this.resolveOriginHost(originHeader);
|
|
397
|
+
if (!originHost)
|
|
398
|
+
return this.reject(res, 403, 'Invalid Origin header');
|
|
399
|
+
if (!ALLOWED_HOSTS.has(originHost))
|
|
400
|
+
return this.reject(res, 403, 'Origin not allowed');
|
|
193
401
|
return true;
|
|
194
402
|
}
|
|
195
403
|
resolveHostHeader(req) {
|
|
@@ -210,14 +418,11 @@ class HostOriginPolicy {
|
|
|
210
418
|
}
|
|
211
419
|
}
|
|
212
420
|
reject(res, status, message) {
|
|
213
|
-
res
|
|
421
|
+
sendJson(res, status, { error: message });
|
|
214
422
|
return false;
|
|
215
423
|
}
|
|
216
424
|
}
|
|
217
425
|
const hostOriginPolicy = new HostOriginPolicy();
|
|
218
|
-
/* -------------------------------------------------------------------------------------------------
|
|
219
|
-
* HTTP mode configuration assertions
|
|
220
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
221
426
|
function assertHttpModeConfiguration() {
|
|
222
427
|
const configuredHost = normalizeHost(config.server.host);
|
|
223
428
|
const isLoopback = configuredHost !== null && LOOPBACK_HOSTS.has(configuredHost);
|
|
@@ -248,12 +453,7 @@ class RateLimiter {
|
|
|
248
453
|
void (async () => {
|
|
249
454
|
try {
|
|
250
455
|
for await (const getNow of interval) {
|
|
251
|
-
|
|
252
|
-
for (const [key, entry] of this.store.entries()) {
|
|
253
|
-
if (now - entry.lastAccessed > this.options.windowMs * 2) {
|
|
254
|
-
this.store.delete(key);
|
|
255
|
-
}
|
|
256
|
-
}
|
|
456
|
+
this.cleanupEntries(getNow());
|
|
257
457
|
}
|
|
258
458
|
}
|
|
259
459
|
catch (err) {
|
|
@@ -263,13 +463,32 @@ class RateLimiter {
|
|
|
263
463
|
}
|
|
264
464
|
})();
|
|
265
465
|
}
|
|
266
|
-
|
|
267
|
-
|
|
466
|
+
cleanupEntries(now) {
|
|
467
|
+
const maxIdle = this.options.windowMs * 2;
|
|
468
|
+
for (const [key, entry] of this.store.entries()) {
|
|
469
|
+
if (now - entry.lastAccessed > maxIdle) {
|
|
470
|
+
this.store.delete(key);
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
check(ctx) {
|
|
475
|
+
if (!this.options.enabled || ctx.method === 'OPTIONS')
|
|
268
476
|
return true;
|
|
269
|
-
const key =
|
|
477
|
+
const key = ctx.ip ?? 'unknown';
|
|
270
478
|
const now = Date.now();
|
|
271
479
|
let entry = this.store.get(key);
|
|
272
|
-
if (
|
|
480
|
+
if (entry) {
|
|
481
|
+
if (now > entry.resetTime) {
|
|
482
|
+
entry.count = 1;
|
|
483
|
+
entry.resetTime = now + this.options.windowMs;
|
|
484
|
+
entry.lastAccessed = now;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
entry.count += 1;
|
|
488
|
+
entry.lastAccessed = now;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
else {
|
|
273
492
|
entry = {
|
|
274
493
|
count: 1,
|
|
275
494
|
resetTime: now + this.options.windowMs,
|
|
@@ -277,14 +496,10 @@ class RateLimiter {
|
|
|
277
496
|
};
|
|
278
497
|
this.store.set(key, entry);
|
|
279
498
|
}
|
|
280
|
-
else {
|
|
281
|
-
entry.count += 1;
|
|
282
|
-
entry.lastAccessed = now;
|
|
283
|
-
}
|
|
284
499
|
if (entry.count > this.options.maxRequests) {
|
|
285
500
|
const retryAfter = Math.max(1, Math.ceil((entry.resetTime - now) / 1000));
|
|
286
|
-
res.setHeader('Retry-After', String(retryAfter));
|
|
287
|
-
res
|
|
501
|
+
ctx.res.setHeader('Retry-After', String(retryAfter));
|
|
502
|
+
sendJson(ctx.res, 429, { error: 'Rate limit exceeded', retryAfter });
|
|
288
503
|
return false;
|
|
289
504
|
}
|
|
290
505
|
return true;
|
|
@@ -296,22 +511,20 @@ class RateLimiter {
|
|
|
296
511
|
function createRateLimitManagerImpl(options) {
|
|
297
512
|
return new RateLimiter(options);
|
|
298
513
|
}
|
|
299
|
-
/* -------------------------------------------------------------------------------------------------
|
|
300
|
-
* Auth (static + OAuth introspection)
|
|
301
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
302
514
|
const STATIC_TOKEN_TTL_SECONDS = 60 * 60 * 24;
|
|
303
515
|
class AuthService {
|
|
304
|
-
|
|
305
|
-
|
|
516
|
+
staticTokenDigests = config.auth.staticTokens.map((token) => sha256Hex(token));
|
|
517
|
+
async authenticate(req, signal) {
|
|
518
|
+
const authHeader = getHeaderValue(req, 'authorization');
|
|
306
519
|
if (!authHeader) {
|
|
307
520
|
return this.authenticateWithApiKey(req);
|
|
308
521
|
}
|
|
309
522
|
const token = this.resolveBearerToken(authHeader);
|
|
310
|
-
return this.authenticateWithToken(token);
|
|
523
|
+
return this.authenticateWithToken(token, signal);
|
|
311
524
|
}
|
|
312
|
-
authenticateWithToken(token) {
|
|
525
|
+
authenticateWithToken(token, signal) {
|
|
313
526
|
return config.auth.mode === 'oauth'
|
|
314
|
-
? this.verifyWithIntrospection(token)
|
|
527
|
+
? this.verifyWithIntrospection(token, signal)
|
|
315
528
|
: Promise.resolve(this.verifyStaticToken(token));
|
|
316
529
|
}
|
|
317
530
|
authenticateWithApiKey(req) {
|
|
@@ -325,8 +538,11 @@ class AuthService {
|
|
|
325
538
|
throw new InvalidTokenError('Missing Authorization header');
|
|
326
539
|
}
|
|
327
540
|
resolveBearerToken(authHeader) {
|
|
328
|
-
|
|
329
|
-
|
|
541
|
+
if (!authHeader.startsWith('Bearer ')) {
|
|
542
|
+
throw new InvalidTokenError('Invalid Authorization header format');
|
|
543
|
+
}
|
|
544
|
+
const token = authHeader.substring(7);
|
|
545
|
+
if (!token) {
|
|
330
546
|
throw new InvalidTokenError('Invalid Authorization header format');
|
|
331
547
|
}
|
|
332
548
|
return token;
|
|
@@ -341,10 +557,11 @@ class AuthService {
|
|
|
341
557
|
};
|
|
342
558
|
}
|
|
343
559
|
verifyStaticToken(token) {
|
|
344
|
-
if (
|
|
560
|
+
if (this.staticTokenDigests.length === 0) {
|
|
345
561
|
throw new InvalidTokenError('No static tokens configured');
|
|
346
562
|
}
|
|
347
|
-
const
|
|
563
|
+
const tokenDigest = sha256Hex(token);
|
|
564
|
+
const matched = hasConstantTimeMatch(this.staticTokenDigests, tokenDigest);
|
|
348
565
|
if (!matched)
|
|
349
566
|
throw new InvalidTokenError('Invalid token');
|
|
350
567
|
return this.buildStaticAuthInfo(token);
|
|
@@ -355,8 +572,9 @@ class AuthService {
|
|
|
355
572
|
return clean.href;
|
|
356
573
|
}
|
|
357
574
|
buildBasicAuthHeader(clientId, clientSecret) {
|
|
575
|
+
// Base64 is only an encoding for header transport; it is NOT encryption.
|
|
358
576
|
const credentials = `${clientId}:${clientSecret ?? ''}`;
|
|
359
|
-
return `Basic ${Buffer.from(credentials).toString('base64')}`;
|
|
577
|
+
return `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`;
|
|
360
578
|
}
|
|
361
579
|
buildIntrospectionRequest(token, resourceUrl, clientId, clientSecret) {
|
|
362
580
|
const body = new URLSearchParams({
|
|
@@ -367,19 +585,26 @@ class AuthService {
|
|
|
367
585
|
const headers = {
|
|
368
586
|
'content-type': 'application/x-www-form-urlencoded',
|
|
369
587
|
};
|
|
370
|
-
if (clientId)
|
|
588
|
+
if (clientId) {
|
|
371
589
|
headers.authorization = this.buildBasicAuthHeader(clientId, clientSecret);
|
|
590
|
+
}
|
|
372
591
|
return { body, headers };
|
|
373
592
|
}
|
|
374
|
-
async requestIntrospection(url, request, timeoutMs) {
|
|
593
|
+
async requestIntrospection(url, request, timeoutMs, signal) {
|
|
594
|
+
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
595
|
+
const combinedSignal = signal
|
|
596
|
+
? AbortSignal.any([signal, timeoutSignal])
|
|
597
|
+
: timeoutSignal;
|
|
375
598
|
const response = await fetch(url, {
|
|
376
599
|
method: 'POST',
|
|
377
600
|
headers: request.headers,
|
|
378
601
|
body: request.body,
|
|
379
|
-
signal:
|
|
602
|
+
signal: combinedSignal,
|
|
380
603
|
});
|
|
381
604
|
if (!response.ok) {
|
|
382
|
-
|
|
605
|
+
if (response.body) {
|
|
606
|
+
await response.body.cancel();
|
|
607
|
+
}
|
|
383
608
|
throw new ServerError(`Token introspection failed: ${response.status}`);
|
|
384
609
|
}
|
|
385
610
|
return response.json();
|
|
@@ -397,12 +622,12 @@ class AuthService {
|
|
|
397
622
|
info.expiresAt = expiresAt;
|
|
398
623
|
return info;
|
|
399
624
|
}
|
|
400
|
-
async verifyWithIntrospection(token) {
|
|
625
|
+
async verifyWithIntrospection(token, signal) {
|
|
401
626
|
if (!config.auth.introspectionUrl) {
|
|
402
627
|
throw new ServerError('Introspection not configured');
|
|
403
628
|
}
|
|
404
629
|
const req = this.buildIntrospectionRequest(token, config.auth.resourceUrl, config.auth.clientId, config.auth.clientSecret);
|
|
405
|
-
const payload = await this.requestIntrospection(config.auth.introspectionUrl, req, config.auth.introspectionTimeoutMs);
|
|
630
|
+
const payload = await this.requestIntrospection(config.auth.introspectionUrl, req, config.auth.introspectionTimeoutMs, signal);
|
|
406
631
|
if (!isObject(payload) || payload.active !== true) {
|
|
407
632
|
throw new InvalidTokenError('Token is inactive');
|
|
408
633
|
}
|
|
@@ -410,11 +635,47 @@ class AuthService {
|
|
|
410
635
|
}
|
|
411
636
|
}
|
|
412
637
|
const authService = new AuthService();
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
638
|
+
const EVENT_LOOP_DELAY_RESOLUTION_MS = 20;
|
|
639
|
+
const eventLoopDelay = monitorEventLoopDelay({
|
|
640
|
+
resolution: EVENT_LOOP_DELAY_RESOLUTION_MS,
|
|
641
|
+
});
|
|
642
|
+
let lastEventLoopUtilization = performance.eventLoopUtilization();
|
|
643
|
+
function roundTo(value, precision) {
|
|
644
|
+
const factor = 10 ** precision;
|
|
645
|
+
return Math.round(value * factor) / factor;
|
|
646
|
+
}
|
|
647
|
+
function formatEventLoopUtilization(snapshot) {
|
|
648
|
+
return {
|
|
649
|
+
utilization: roundTo(snapshot.utilization, 4),
|
|
650
|
+
activeMs: Math.round(snapshot.active),
|
|
651
|
+
idleMs: Math.round(snapshot.idle),
|
|
652
|
+
};
|
|
653
|
+
}
|
|
654
|
+
function toMs(valueNs) {
|
|
655
|
+
return roundTo(valueNs / 1_000_000, 3);
|
|
656
|
+
}
|
|
657
|
+
function getEventLoopStats() {
|
|
658
|
+
const current = performance.eventLoopUtilization();
|
|
659
|
+
const delta = performance.eventLoopUtilization(current, lastEventLoopUtilization);
|
|
660
|
+
lastEventLoopUtilization = current;
|
|
661
|
+
return {
|
|
662
|
+
utilization: {
|
|
663
|
+
total: formatEventLoopUtilization(current),
|
|
664
|
+
sinceLast: formatEventLoopUtilization(delta),
|
|
665
|
+
},
|
|
666
|
+
delay: {
|
|
667
|
+
minMs: toMs(eventLoopDelay.min),
|
|
668
|
+
maxMs: toMs(eventLoopDelay.max),
|
|
669
|
+
meanMs: toMs(eventLoopDelay.mean),
|
|
670
|
+
stddevMs: toMs(eventLoopDelay.stddev),
|
|
671
|
+
p50Ms: toMs(eventLoopDelay.percentile(50)),
|
|
672
|
+
p95Ms: toMs(eventLoopDelay.percentile(95)),
|
|
673
|
+
p99Ms: toMs(eventLoopDelay.percentile(99)),
|
|
674
|
+
},
|
|
675
|
+
};
|
|
676
|
+
}
|
|
416
677
|
function sendError(res, code, message, status = 400, id = null) {
|
|
417
|
-
res
|
|
678
|
+
sendJson(res, status, {
|
|
418
679
|
jsonrpc: '2.0',
|
|
419
680
|
error: { code, message },
|
|
420
681
|
id,
|
|
@@ -433,6 +694,15 @@ function ensureMcpProtocolVersion(req, res) {
|
|
|
433
694
|
}
|
|
434
695
|
return true;
|
|
435
696
|
}
|
|
697
|
+
function buildAuthFingerprint(auth) {
|
|
698
|
+
if (!auth)
|
|
699
|
+
return null;
|
|
700
|
+
const safeClientId = typeof auth.clientId === 'string' ? auth.clientId : '';
|
|
701
|
+
const safeToken = typeof auth.token === 'string' ? auth.token : '';
|
|
702
|
+
if (!safeClientId && !safeToken)
|
|
703
|
+
return null;
|
|
704
|
+
return sha256Hex(`${safeClientId}:${safeToken}`);
|
|
705
|
+
}
|
|
436
706
|
class McpSessionGateway {
|
|
437
707
|
store;
|
|
438
708
|
mcpServer;
|
|
@@ -440,56 +710,56 @@ class McpSessionGateway {
|
|
|
440
710
|
this.store = store;
|
|
441
711
|
this.mcpServer = mcpServer;
|
|
442
712
|
}
|
|
443
|
-
async handlePost(
|
|
444
|
-
if (!ensureMcpProtocolVersion(req, res))
|
|
713
|
+
async handlePost(ctx) {
|
|
714
|
+
if (!ensureMcpProtocolVersion(ctx.req, ctx.res))
|
|
445
715
|
return;
|
|
446
|
-
const { body } =
|
|
716
|
+
const { body } = ctx;
|
|
447
717
|
if (isJsonRpcBatchRequest(body)) {
|
|
448
|
-
sendError(res, -32600, 'Batch requests not supported');
|
|
718
|
+
sendError(ctx.res, -32600, 'Batch requests not supported');
|
|
449
719
|
return;
|
|
450
720
|
}
|
|
451
721
|
if (!isMcpRequestBody(body)) {
|
|
452
|
-
sendError(res, -32600, 'Invalid request body');
|
|
722
|
+
sendError(ctx.res, -32600, 'Invalid request body');
|
|
453
723
|
return;
|
|
454
724
|
}
|
|
455
725
|
const requestId = body.id ?? null;
|
|
456
726
|
logInfo('[MCP POST]', {
|
|
457
727
|
method: body.method,
|
|
458
728
|
id: body.id,
|
|
459
|
-
sessionId:
|
|
729
|
+
sessionId: getMcpSessionId(ctx.req),
|
|
460
730
|
});
|
|
461
|
-
const transport = await this.getOrCreateTransport(
|
|
731
|
+
const transport = await this.getOrCreateTransport(ctx, requestId);
|
|
462
732
|
if (!transport)
|
|
463
733
|
return;
|
|
464
|
-
await transport.handleRequest(req, res, body);
|
|
734
|
+
await transport.handleRequest(ctx.req, ctx.res, body);
|
|
465
735
|
}
|
|
466
|
-
async handleGet(
|
|
467
|
-
if (!ensureMcpProtocolVersion(req, res))
|
|
736
|
+
async handleGet(ctx) {
|
|
737
|
+
if (!ensureMcpProtocolVersion(ctx.req, ctx.res))
|
|
468
738
|
return;
|
|
469
|
-
const sessionId =
|
|
739
|
+
const sessionId = getMcpSessionId(ctx.req);
|
|
470
740
|
if (!sessionId) {
|
|
471
|
-
sendError(res, -32600, 'Missing session ID');
|
|
741
|
+
sendError(ctx.res, -32600, 'Missing session ID');
|
|
472
742
|
return;
|
|
473
743
|
}
|
|
474
744
|
const session = this.store.get(sessionId);
|
|
475
745
|
if (!session) {
|
|
476
|
-
sendError(res, -32600, 'Session not found', 404);
|
|
746
|
+
sendError(ctx.res, -32600, 'Session not found', 404);
|
|
477
747
|
return;
|
|
478
748
|
}
|
|
479
|
-
const acceptHeader = getHeaderValue(req, 'accept');
|
|
749
|
+
const acceptHeader = getHeaderValue(ctx.req, 'accept');
|
|
480
750
|
if (!acceptsEventStream(acceptHeader)) {
|
|
481
|
-
res
|
|
751
|
+
sendJson(ctx.res, 405, { error: 'Method Not Allowed' });
|
|
482
752
|
return;
|
|
483
753
|
}
|
|
484
754
|
this.store.touch(sessionId);
|
|
485
|
-
await session.transport.handleRequest(req, res);
|
|
755
|
+
await session.transport.handleRequest(ctx.req, ctx.res);
|
|
486
756
|
}
|
|
487
|
-
async handleDelete(
|
|
488
|
-
if (!ensureMcpProtocolVersion(req, res))
|
|
757
|
+
async handleDelete(ctx) {
|
|
758
|
+
if (!ensureMcpProtocolVersion(ctx.req, ctx.res))
|
|
489
759
|
return;
|
|
490
|
-
const sessionId =
|
|
760
|
+
const sessionId = getMcpSessionId(ctx.req);
|
|
491
761
|
if (!sessionId) {
|
|
492
|
-
sendError(res, -32600, 'Missing session ID');
|
|
762
|
+
sendError(ctx.res, -32600, 'Missing session ID');
|
|
493
763
|
return;
|
|
494
764
|
}
|
|
495
765
|
const session = this.store.get(sessionId);
|
|
@@ -497,46 +767,41 @@ class McpSessionGateway {
|
|
|
497
767
|
await session.transport.close();
|
|
498
768
|
this.store.remove(sessionId);
|
|
499
769
|
}
|
|
500
|
-
res
|
|
770
|
+
sendText(ctx.res, 200, 'Session closed');
|
|
501
771
|
}
|
|
502
|
-
async getOrCreateTransport(
|
|
503
|
-
const sessionId =
|
|
772
|
+
async getOrCreateTransport(ctx, requestId) {
|
|
773
|
+
const sessionId = getMcpSessionId(ctx.req);
|
|
504
774
|
if (sessionId) {
|
|
505
|
-
const
|
|
506
|
-
|
|
507
|
-
sendError(res, -32600, 'Session not found', 404, requestId);
|
|
508
|
-
return null;
|
|
509
|
-
}
|
|
510
|
-
this.store.touch(sessionId);
|
|
511
|
-
return session.transport;
|
|
775
|
+
const fingerprint = buildAuthFingerprint(ctx.auth);
|
|
776
|
+
return this.getExistingTransport(sessionId, fingerprint, ctx.res, requestId);
|
|
512
777
|
}
|
|
513
|
-
if (!isInitializeRequest(
|
|
514
|
-
sendError(res, -32600, 'Missing session ID', 400, requestId);
|
|
778
|
+
if (!isInitializeRequest(ctx.body)) {
|
|
779
|
+
sendError(ctx.res, -32600, 'Missing session ID', 400, requestId);
|
|
515
780
|
return null;
|
|
516
781
|
}
|
|
517
|
-
return this.createNewSession(
|
|
782
|
+
return this.createNewSession(ctx, requestId);
|
|
518
783
|
}
|
|
519
|
-
|
|
520
|
-
const
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
evictOldest: (s) => {
|
|
524
|
-
const evicted = s.evictOldest();
|
|
525
|
-
if (evicted) {
|
|
526
|
-
void evicted.transport.close().catch(() => { });
|
|
527
|
-
return true;
|
|
528
|
-
}
|
|
529
|
-
return false;
|
|
530
|
-
},
|
|
531
|
-
});
|
|
532
|
-
if (!allowed) {
|
|
533
|
-
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
784
|
+
getExistingTransport(sessionId, authFingerprint, res, requestId) {
|
|
785
|
+
const session = this.store.get(sessionId);
|
|
786
|
+
if (!session) {
|
|
787
|
+
sendError(res, -32600, 'Session not found', 404, requestId);
|
|
534
788
|
return null;
|
|
535
789
|
}
|
|
536
|
-
if (!
|
|
537
|
-
sendError(res, -
|
|
790
|
+
if (!authFingerprint || session.authFingerprint !== authFingerprint) {
|
|
791
|
+
sendError(res, -32600, 'Session not found', 404, requestId);
|
|
538
792
|
return null;
|
|
539
793
|
}
|
|
794
|
+
this.store.touch(sessionId);
|
|
795
|
+
return session.transport;
|
|
796
|
+
}
|
|
797
|
+
async createNewSession(ctx, requestId) {
|
|
798
|
+
const authFingerprint = buildAuthFingerprint(ctx.auth);
|
|
799
|
+
if (!authFingerprint) {
|
|
800
|
+
sendError(ctx.res, -32603, 'Missing auth context', 500, requestId);
|
|
801
|
+
return null;
|
|
802
|
+
}
|
|
803
|
+
if (!this.reserveCapacity(ctx.res, requestId))
|
|
804
|
+
return null;
|
|
540
805
|
const tracker = createSlotTracker(this.store);
|
|
541
806
|
const transportImpl = new StreamableHTTPServerTransport({
|
|
542
807
|
sessionIdGenerator: () => randomUUID(),
|
|
@@ -544,9 +809,10 @@ class McpSessionGateway {
|
|
|
544
809
|
const initTimeout = setTimeout(() => {
|
|
545
810
|
if (!tracker.isInitialized()) {
|
|
546
811
|
tracker.releaseSlot();
|
|
547
|
-
void transportImpl
|
|
812
|
+
void closeTransportBestEffort(transportImpl, 'session-init-timeout');
|
|
548
813
|
}
|
|
549
814
|
}, config.server.sessionInitTimeoutMs);
|
|
815
|
+
initTimeout.unref();
|
|
550
816
|
transportImpl.onclose = () => {
|
|
551
817
|
clearTimeout(initTimeout);
|
|
552
818
|
if (!tracker.isInitialized())
|
|
@@ -559,7 +825,7 @@ class McpSessionGateway {
|
|
|
559
825
|
catch (err) {
|
|
560
826
|
clearTimeout(initTimeout);
|
|
561
827
|
tracker.releaseSlot();
|
|
562
|
-
void transportImpl
|
|
828
|
+
void closeTransportBestEffort(transportImpl, 'session-connect-failed');
|
|
563
829
|
throw err;
|
|
564
830
|
}
|
|
565
831
|
const newSessionId = transportImpl.sessionId;
|
|
@@ -573,16 +839,37 @@ class McpSessionGateway {
|
|
|
573
839
|
createdAt: Date.now(),
|
|
574
840
|
lastSeen: Date.now(),
|
|
575
841
|
protocolInitialized: false,
|
|
842
|
+
authFingerprint,
|
|
576
843
|
});
|
|
577
844
|
transportImpl.onclose = composeCloseHandlers(transportImpl.onclose, () => {
|
|
578
845
|
this.store.remove(newSessionId);
|
|
579
846
|
});
|
|
580
847
|
return transportImpl;
|
|
581
848
|
}
|
|
849
|
+
reserveCapacity(res, requestId) {
|
|
850
|
+
const allowed = ensureSessionCapacity({
|
|
851
|
+
store: this.store,
|
|
852
|
+
maxSessions: config.server.maxSessions,
|
|
853
|
+
evictOldest: (store) => {
|
|
854
|
+
const evicted = store.evictOldest();
|
|
855
|
+
if (evicted) {
|
|
856
|
+
void closeTransportBestEffort(evicted.transport, 'session-eviction');
|
|
857
|
+
return true;
|
|
858
|
+
}
|
|
859
|
+
return false;
|
|
860
|
+
},
|
|
861
|
+
});
|
|
862
|
+
if (!allowed) {
|
|
863
|
+
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
864
|
+
return false;
|
|
865
|
+
}
|
|
866
|
+
if (!reserveSessionSlot(this.store, config.server.maxSessions)) {
|
|
867
|
+
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
868
|
+
return false;
|
|
869
|
+
}
|
|
870
|
+
return true;
|
|
871
|
+
}
|
|
582
872
|
}
|
|
583
|
-
/* -------------------------------------------------------------------------------------------------
|
|
584
|
-
* Downloads + dispatcher
|
|
585
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
586
873
|
function checkDownloadRoute(path) {
|
|
587
874
|
const downloadMatch = /^\/mcp\/downloads\/([^/]+)\/([^/]+)$/.exec(path);
|
|
588
875
|
if (!downloadMatch)
|
|
@@ -600,48 +887,61 @@ class HttpDispatcher {
|
|
|
600
887
|
this.store = store;
|
|
601
888
|
this.mcpGateway = mcpGateway;
|
|
602
889
|
}
|
|
603
|
-
async dispatch(
|
|
604
|
-
const { pathname: path } = url;
|
|
605
|
-
const { method } = req;
|
|
890
|
+
async dispatch(ctx) {
|
|
606
891
|
try {
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
this.handleHealthCheck(res);
|
|
892
|
+
if (ctx.method === 'GET' && ctx.url.pathname === '/health') {
|
|
893
|
+
this.handleHealthCheck(ctx.res);
|
|
610
894
|
return;
|
|
611
895
|
}
|
|
612
|
-
|
|
613
|
-
if (!
|
|
896
|
+
const auth = await this.authenticateRequest(ctx);
|
|
897
|
+
if (!auth)
|
|
614
898
|
return;
|
|
615
|
-
|
|
616
|
-
if (method === 'GET') {
|
|
617
|
-
const download = checkDownloadRoute(
|
|
899
|
+
const authCtx = { ...ctx, auth };
|
|
900
|
+
if (ctx.method === 'GET') {
|
|
901
|
+
const download = checkDownloadRoute(ctx.url.pathname);
|
|
618
902
|
if (download) {
|
|
619
|
-
handleDownload(res, download.namespace, download.hash);
|
|
903
|
+
handleDownload(ctx.res, download.namespace, download.hash);
|
|
620
904
|
return;
|
|
621
905
|
}
|
|
622
906
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
if (
|
|
907
|
+
if (ctx.url.pathname === '/mcp') {
|
|
908
|
+
const handled = await this.handleMcpRoutes(authCtx);
|
|
909
|
+
if (handled)
|
|
626
910
|
return;
|
|
627
|
-
}
|
|
628
911
|
}
|
|
629
|
-
res
|
|
912
|
+
sendJson(ctx.res, 404, { error: 'Not Found' });
|
|
630
913
|
}
|
|
631
914
|
catch (err) {
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
915
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
916
|
+
logError('Request failed', error);
|
|
917
|
+
if (!ctx.res.writableEnded) {
|
|
918
|
+
sendJson(ctx.res, 500, { error: 'Internal Server Error' });
|
|
635
919
|
}
|
|
636
920
|
}
|
|
637
921
|
}
|
|
638
922
|
handleHealthCheck(res) {
|
|
639
923
|
const poolStats = getTransformPoolStats();
|
|
640
|
-
res.
|
|
924
|
+
res.setHeader('Cache-Control', 'no-store');
|
|
925
|
+
sendJson(res, 200, {
|
|
641
926
|
status: 'ok',
|
|
642
927
|
version: serverVersion,
|
|
643
928
|
uptime: Math.floor(process.uptime()),
|
|
644
929
|
timestamp: new Date().toISOString(),
|
|
930
|
+
os: {
|
|
931
|
+
hostname: hostname(),
|
|
932
|
+
platform: process.platform,
|
|
933
|
+
arch: process.arch,
|
|
934
|
+
memoryFree: freemem(),
|
|
935
|
+
memoryTotal: totalmem(),
|
|
936
|
+
},
|
|
937
|
+
process: {
|
|
938
|
+
pid: process.pid,
|
|
939
|
+
ppid: process.ppid,
|
|
940
|
+
memory: process.memoryUsage(),
|
|
941
|
+
cpu: process.cpuUsage(),
|
|
942
|
+
resource: process.resourceUsage(),
|
|
943
|
+
},
|
|
944
|
+
perf: getEventLoopStats(),
|
|
645
945
|
stats: {
|
|
646
946
|
activeSessions: this.store.size(),
|
|
647
947
|
cacheKeys: cacheKeys().length,
|
|
@@ -653,37 +953,33 @@ class HttpDispatcher {
|
|
|
653
953
|
},
|
|
654
954
|
});
|
|
655
955
|
}
|
|
656
|
-
async handleMcpRoutes(
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
956
|
+
async handleMcpRoutes(ctx) {
|
|
957
|
+
switch (ctx.method) {
|
|
958
|
+
case 'POST':
|
|
959
|
+
await this.mcpGateway.handlePost(ctx);
|
|
960
|
+
return true;
|
|
961
|
+
case 'GET':
|
|
962
|
+
await this.mcpGateway.handleGet(ctx);
|
|
963
|
+
return true;
|
|
964
|
+
case 'DELETE':
|
|
965
|
+
await this.mcpGateway.handleDelete(ctx);
|
|
966
|
+
return true;
|
|
967
|
+
default:
|
|
968
|
+
return false;
|
|
668
969
|
}
|
|
669
|
-
return false;
|
|
670
970
|
}
|
|
671
|
-
async authenticateRequest(
|
|
971
|
+
async authenticateRequest(ctx) {
|
|
672
972
|
try {
|
|
673
|
-
|
|
674
|
-
return true;
|
|
973
|
+
return await authService.authenticate(ctx.req, ctx.signal);
|
|
675
974
|
}
|
|
676
975
|
catch (err) {
|
|
677
|
-
res
|
|
976
|
+
sendJson(ctx.res, 401, {
|
|
678
977
|
error: err instanceof Error ? err.message : 'Unauthorized',
|
|
679
978
|
});
|
|
680
|
-
return
|
|
979
|
+
return null;
|
|
681
980
|
}
|
|
682
981
|
}
|
|
683
982
|
}
|
|
684
|
-
/* -------------------------------------------------------------------------------------------------
|
|
685
|
-
* Request pipeline (order is part of behavior)
|
|
686
|
-
* ------------------------------------------------------------------------------------------------- */
|
|
687
983
|
class HttpRequestPipeline {
|
|
688
984
|
rateLimiter;
|
|
689
985
|
dispatcher;
|
|
@@ -692,40 +988,110 @@ class HttpRequestPipeline {
|
|
|
692
988
|
this.dispatcher = dispatcher;
|
|
693
989
|
}
|
|
694
990
|
async handle(rawReq, rawRes) {
|
|
695
|
-
const
|
|
696
|
-
const
|
|
697
|
-
|
|
698
|
-
const url = new URL(req.url ?? '', 'http://localhost');
|
|
699
|
-
req.query = parseQuery(url);
|
|
700
|
-
if (req.socket.remoteAddress)
|
|
701
|
-
req.ip = req.socket.remoteAddress;
|
|
702
|
-
req.params = {};
|
|
703
|
-
// 2. Host/Origin + CORS (preserve exact order)
|
|
704
|
-
if (!hostOriginPolicy.validate(req, res))
|
|
705
|
-
return;
|
|
706
|
-
if (corsPolicy.handle(req, res))
|
|
707
|
-
return;
|
|
708
|
-
// 3. Body parsing
|
|
991
|
+
const requestId = getHeaderValue(rawReq, 'x-request-id') ?? randomUUID();
|
|
992
|
+
const sessionId = getMcpSessionId(rawReq) ?? undefined;
|
|
993
|
+
const { signal, cleanup } = createRequestAbortSignal(rawReq);
|
|
709
994
|
try {
|
|
710
|
-
|
|
995
|
+
await runWithRequestContext({
|
|
996
|
+
requestId,
|
|
997
|
+
operationId: requestId,
|
|
998
|
+
...(sessionId ? { sessionId } : {}),
|
|
999
|
+
}, async () => {
|
|
1000
|
+
const ctx = buildRequestContext(rawReq, rawRes, signal);
|
|
1001
|
+
if (!ctx) {
|
|
1002
|
+
drainRequest(rawReq);
|
|
1003
|
+
return;
|
|
1004
|
+
}
|
|
1005
|
+
if (!hostOriginPolicy.validate(ctx)) {
|
|
1006
|
+
drainRequest(rawReq);
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (corsPolicy.handle(ctx)) {
|
|
1010
|
+
drainRequest(rawReq);
|
|
1011
|
+
return;
|
|
1012
|
+
}
|
|
1013
|
+
if (!this.rateLimiter.check(ctx)) {
|
|
1014
|
+
drainRequest(rawReq);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
try {
|
|
1018
|
+
ctx.body = await jsonBodyReader.read(ctx.req, DEFAULT_BODY_LIMIT_BYTES, ctx.signal);
|
|
1019
|
+
}
|
|
1020
|
+
catch {
|
|
1021
|
+
if (ctx.url.pathname === '/mcp' && ctx.method === 'POST') {
|
|
1022
|
+
sendError(ctx.res, -32700, 'Parse error', 400, null);
|
|
1023
|
+
}
|
|
1024
|
+
else {
|
|
1025
|
+
sendJson(ctx.res, 400, {
|
|
1026
|
+
error: 'Invalid JSON or Payload too large',
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
drainRequest(rawReq);
|
|
1030
|
+
return;
|
|
1031
|
+
}
|
|
1032
|
+
await this.dispatcher.dispatch(ctx);
|
|
1033
|
+
});
|
|
711
1034
|
}
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
return;
|
|
1035
|
+
finally {
|
|
1036
|
+
cleanup();
|
|
715
1037
|
}
|
|
716
|
-
// 4. Rate limit
|
|
717
|
-
if (!this.rateLimiter.check(req, res))
|
|
718
|
-
return;
|
|
719
|
-
// 5. Dispatch
|
|
720
|
-
await this.dispatcher.dispatch(req, res, url);
|
|
721
1038
|
}
|
|
722
1039
|
}
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
1040
|
+
function handlePipelineError(error, res) {
|
|
1041
|
+
logError('Request pipeline failed', error instanceof Error ? error : new Error(String(error)));
|
|
1042
|
+
if (res.writableEnded)
|
|
1043
|
+
return;
|
|
1044
|
+
if (!res.headersSent) {
|
|
1045
|
+
sendJson(res, 500, { error: 'Internal Server Error' });
|
|
1046
|
+
return;
|
|
1047
|
+
}
|
|
1048
|
+
res.end();
|
|
1049
|
+
}
|
|
1050
|
+
async function listen(server, host, port) {
|
|
1051
|
+
await new Promise((resolve, reject) => {
|
|
1052
|
+
function onError(err) {
|
|
1053
|
+
server.off('error', onError);
|
|
1054
|
+
reject(err);
|
|
1055
|
+
}
|
|
1056
|
+
server.once('error', onError);
|
|
1057
|
+
server.listen(port, host, () => {
|
|
1058
|
+
server.off('error', onError);
|
|
1059
|
+
resolve();
|
|
1060
|
+
});
|
|
1061
|
+
});
|
|
1062
|
+
}
|
|
1063
|
+
function resolveListeningPort(server, fallback) {
|
|
1064
|
+
const addr = server.address();
|
|
1065
|
+
if (addr && typeof addr === 'object')
|
|
1066
|
+
return addr.port;
|
|
1067
|
+
return fallback;
|
|
1068
|
+
}
|
|
1069
|
+
function createShutdownHandler(options) {
|
|
1070
|
+
return async (signal) => {
|
|
1071
|
+
logInfo(`Stopping HTTP server (${signal})...`);
|
|
1072
|
+
options.rateLimiter.stop();
|
|
1073
|
+
options.sessionCleanup.abort();
|
|
1074
|
+
drainConnectionsOnShutdown(options.server);
|
|
1075
|
+
eventLoopDelay.disable();
|
|
1076
|
+
const sessions = options.sessionStore.clear();
|
|
1077
|
+
await Promise.all(sessions.map((session) => closeTransportBestEffort(session.transport, 'shutdown-session-close')));
|
|
1078
|
+
await new Promise((resolve, reject) => {
|
|
1079
|
+
options.server.close((err) => {
|
|
1080
|
+
if (err)
|
|
1081
|
+
reject(err);
|
|
1082
|
+
else
|
|
1083
|
+
resolve();
|
|
1084
|
+
});
|
|
1085
|
+
});
|
|
1086
|
+
await options.mcpServer.close();
|
|
1087
|
+
};
|
|
1088
|
+
}
|
|
726
1089
|
export async function startHttpServer() {
|
|
727
1090
|
assertHttpModeConfiguration();
|
|
728
1091
|
enableHttpMode();
|
|
1092
|
+
lastEventLoopUtilization = performance.eventLoopUtilization();
|
|
1093
|
+
eventLoopDelay.reset();
|
|
1094
|
+
eventLoopDelay.enable();
|
|
729
1095
|
const mcpServer = await createMcpServer();
|
|
730
1096
|
const rateLimiter = createRateLimitManagerImpl(config.rateLimit);
|
|
731
1097
|
const sessionStore = createSessionStore(config.server.sessionTtlMs);
|
|
@@ -734,37 +1100,29 @@ export async function startHttpServer() {
|
|
|
734
1100
|
const dispatcher = new HttpDispatcher(sessionStore, mcpGateway);
|
|
735
1101
|
const pipeline = new HttpRequestPipeline(rateLimiter, dispatcher);
|
|
736
1102
|
const server = createServer((req, res) => {
|
|
737
|
-
void pipeline.handle(req, res)
|
|
1103
|
+
void pipeline.handle(req, res).catch((error) => {
|
|
1104
|
+
handlePipelineError(error, res);
|
|
1105
|
+
});
|
|
738
1106
|
});
|
|
1107
|
+
registerInboundBlockList(server);
|
|
739
1108
|
applyHttpServerTuning(server);
|
|
740
|
-
await
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
1109
|
+
await listen(server, config.server.host, config.server.port);
|
|
1110
|
+
const port = resolveListeningPort(server, config.server.port);
|
|
1111
|
+
logInfo(`HTTP server listening on port ${port}`, {
|
|
1112
|
+
platform: process.platform,
|
|
1113
|
+
arch: process.arch,
|
|
1114
|
+
hostname: hostname(),
|
|
1115
|
+
nodeVersion: process.version,
|
|
745
1116
|
});
|
|
746
|
-
const addr = server.address();
|
|
747
|
-
const port = typeof addr === 'object' && addr ? addr.port : config.server.port;
|
|
748
|
-
logInfo(`HTTP server listening on port ${port}`);
|
|
749
1117
|
return {
|
|
750
1118
|
port,
|
|
751
1119
|
host: config.server.host,
|
|
752
|
-
shutdown:
|
|
753
|
-
|
|
754
|
-
rateLimiter
|
|
755
|
-
sessionCleanup
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
try {
|
|
760
|
-
await s.transport.close();
|
|
761
|
-
}
|
|
762
|
-
catch {
|
|
763
|
-
/* ignore */
|
|
764
|
-
}
|
|
765
|
-
}));
|
|
766
|
-
server.close();
|
|
767
|
-
await mcpServer.close();
|
|
768
|
-
},
|
|
1120
|
+
shutdown: createShutdownHandler({
|
|
1121
|
+
server,
|
|
1122
|
+
rateLimiter,
|
|
1123
|
+
sessionCleanup,
|
|
1124
|
+
sessionStore,
|
|
1125
|
+
mcpServer,
|
|
1126
|
+
}),
|
|
769
1127
|
};
|
|
770
1128
|
}
|