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