@j0hanz/fetch-url-mcp 1.2.0 → 1.3.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/dist/cache.d.ts +9 -3
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +44 -110
- package/dist/cache.js.map +1 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +9 -4
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +2 -3
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +18 -25
- package/dist/config.js.map +1 -0
- package/dist/crypto.d.ts +1 -0
- package/dist/crypto.d.ts.map +1 -0
- package/dist/crypto.js +1 -0
- package/dist/crypto.js.map +1 -0
- package/dist/dom-noise-removal.d.ts +2 -1
- package/dist/dom-noise-removal.d.ts.map +1 -0
- package/dist/dom-noise-removal.js +8 -4
- package/dist/dom-noise-removal.js.map +1 -0
- package/dist/download.d.ts +4 -0
- package/dist/download.d.ts.map +1 -0
- package/dist/download.js +106 -0
- package/dist/download.js.map +1 -0
- package/dist/errors.d.ts +1 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +1 -0
- package/dist/errors.js.map +1 -0
- package/dist/examples/mcp-fetch-url-client.js +19 -3
- package/dist/examples/mcp-fetch-url-client.js.map +1 -1
- package/dist/fetch-content.d.ts +1 -0
- package/dist/fetch-content.d.ts.map +1 -0
- package/dist/fetch-content.js +14 -14
- package/dist/fetch-content.js.map +1 -0
- package/dist/fetch-stream.d.ts +1 -0
- package/dist/fetch-stream.d.ts.map +1 -0
- package/dist/fetch-stream.js +6 -3
- package/dist/fetch-stream.js.map +1 -0
- package/dist/fetch.d.ts +1 -0
- package/dist/fetch.d.ts.map +1 -0
- package/dist/fetch.js +120 -51
- package/dist/fetch.js.map +1 -0
- package/dist/host-normalization.d.ts +1 -0
- package/dist/host-normalization.d.ts.map +1 -0
- package/dist/host-normalization.js +19 -6
- package/dist/host-normalization.js.map +1 -0
- package/dist/http/auth.d.ts +35 -0
- package/dist/http/auth.d.ts.map +1 -0
- package/dist/http/auth.js +283 -0
- package/dist/http/auth.js.map +1 -0
- package/dist/http/health.d.ts +7 -0
- package/dist/http/health.d.ts.map +1 -0
- package/dist/http/health.js +166 -0
- package/dist/http/health.js.map +1 -0
- package/dist/http/helpers.d.ts +58 -0
- package/dist/http/helpers.d.ts.map +1 -0
- package/dist/http/helpers.js +372 -0
- package/dist/http/helpers.js.map +1 -0
- package/dist/{http-native.d.ts → http/native.d.ts} +1 -0
- package/dist/http/native.d.ts.map +1 -0
- package/dist/http/native.js +529 -0
- package/dist/http/native.js.map +1 -0
- package/dist/http/rate-limit.d.ts +13 -0
- package/dist/http/rate-limit.d.ts.map +1 -0
- package/dist/http/rate-limit.js +81 -0
- package/dist/http/rate-limit.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -0
- package/dist/instructions.d.ts +2 -0
- package/dist/instructions.d.ts.map +1 -0
- package/dist/instructions.js +108 -0
- package/dist/instructions.js.map +1 -0
- package/dist/ip-blocklist.d.ts +1 -0
- package/dist/ip-blocklist.d.ts.map +1 -0
- package/dist/ip-blocklist.js +2 -0
- package/dist/ip-blocklist.js.map +1 -0
- package/dist/json.d.ts +2 -1
- package/dist/json.d.ts.map +1 -0
- package/dist/json.js +19 -6
- package/dist/json.js.map +1 -0
- package/dist/language-detection.d.ts +1 -0
- package/dist/language-detection.d.ts.map +1 -0
- package/dist/language-detection.js +1 -0
- package/dist/language-detection.js.map +1 -0
- package/dist/markdown-cleanup.d.ts +2 -1
- package/dist/markdown-cleanup.d.ts.map +1 -0
- package/dist/markdown-cleanup.js +51 -52
- package/dist/markdown-cleanup.js.map +1 -0
- package/dist/mcp-validator.d.ts +1 -0
- package/dist/mcp-validator.d.ts.map +1 -0
- package/dist/mcp-validator.js +16 -8
- package/dist/mcp-validator.js.map +1 -0
- package/dist/mcp.d.ts +2 -2
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +17 -333
- package/dist/mcp.js.map +1 -0
- package/dist/observability.d.ts +2 -0
- package/dist/observability.d.ts.map +1 -0
- package/dist/observability.js +30 -5
- package/dist/observability.js.map +1 -0
- package/dist/prompts.d.ts +1 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +15 -3
- package/dist/prompts.js.map +1 -0
- package/dist/resources.d.ts +1 -0
- package/dist/resources.d.ts.map +1 -0
- package/dist/resources.js +30 -23
- package/dist/resources.js.map +1 -0
- package/dist/server-tuning.d.ts +1 -0
- package/dist/server-tuning.d.ts.map +1 -0
- package/dist/server-tuning.js +11 -15
- package/dist/server-tuning.js.map +1 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +23 -23
- package/dist/server.js.map +1 -0
- package/dist/session.d.ts +1 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +55 -28
- package/dist/session.js.map +1 -0
- package/dist/tasks/execution.d.ts +42 -0
- package/dist/tasks/execution.d.ts.map +1 -0
- package/dist/tasks/execution.js +232 -0
- package/dist/tasks/execution.js.map +1 -0
- package/dist/{tasks.d.ts → tasks/manager.d.ts} +6 -0
- package/dist/tasks/manager.d.ts.map +1 -0
- package/dist/{tasks.js → tasks/manager.js} +86 -37
- package/dist/tasks/manager.js.map +1 -0
- package/dist/tasks/owner.d.ts +33 -0
- package/dist/tasks/owner.d.ts.map +1 -0
- package/dist/tasks/owner.js +99 -0
- package/dist/tasks/owner.js.map +1 -0
- package/dist/timer-utils.d.ts +1 -0
- package/dist/timer-utils.d.ts.map +1 -0
- package/dist/timer-utils.js +12 -5
- package/dist/timer-utils.js.map +1 -0
- package/dist/tool-errors.d.ts +12 -0
- package/dist/tool-errors.d.ts.map +1 -0
- package/dist/tool-errors.js +52 -0
- package/dist/tool-errors.js.map +1 -0
- package/dist/tool-pipeline.d.ts +72 -0
- package/dist/tool-pipeline.d.ts.map +1 -0
- package/dist/tool-pipeline.js +407 -0
- package/dist/tool-pipeline.js.map +1 -0
- package/dist/tool-progress.d.ts +32 -0
- package/dist/tool-progress.d.ts.map +1 -0
- package/dist/tool-progress.js +123 -0
- package/dist/tool-progress.js.map +1 -0
- package/dist/tools.d.ts +35 -111
- package/dist/tools.d.ts.map +1 -0
- package/dist/tools.js +93 -566
- package/dist/tools.js.map +1 -0
- package/dist/{transform.d.ts → transform/transform.d.ts} +2 -1
- package/dist/transform/transform.d.ts.map +1 -0
- package/dist/{transform.js → transform/transform.js} +73 -769
- package/dist/transform/transform.js.map +1 -0
- package/dist/{transform-types.d.ts → transform/types.d.ts} +1 -0
- package/dist/transform/types.d.ts.map +1 -0
- package/dist/{transform-types.js → transform/types.js} +1 -0
- package/dist/transform/types.js.map +1 -0
- package/dist/transform/worker-pool.d.ts +93 -0
- package/dist/transform/worker-pool.d.ts.map +1 -0
- package/dist/transform/worker-pool.js +759 -0
- package/dist/transform/worker-pool.js.map +1 -0
- package/dist/transform/workers/transform-child.d.ts +2 -0
- package/dist/transform/workers/transform-child.d.ts.map +1 -0
- package/dist/{workers → transform/workers}/transform-child.js +3 -1
- package/dist/transform/workers/transform-child.js.map +1 -0
- package/dist/transform/workers/transform-worker.d.ts +2 -0
- package/dist/transform/workers/transform-worker.d.ts.map +1 -0
- package/dist/{workers → transform/workers}/transform-worker.js +2 -1
- package/dist/transform/workers/transform-worker.js.map +1 -0
- package/dist/type-guards.d.ts +1 -0
- package/dist/type-guards.d.ts.map +1 -0
- package/dist/type-guards.js +1 -0
- package/dist/type-guards.js.map +1 -0
- package/package.json +6 -7
- package/dist/AGENTS.md +0 -152
- package/dist/http-native.js +0 -1320
- package/dist/instructions.md +0 -113
- package/dist/workers/transform-child.d.ts +0 -1
- package/dist/workers/transform-worker.d.ts +0 -1
package/dist/http-native.js
DELETED
|
@@ -1,1320 +0,0 @@
|
|
|
1
|
-
import { Buffer } from 'node:buffer';
|
|
2
|
-
import { randomBytes, randomUUID } from 'node:crypto';
|
|
3
|
-
import { readFileSync } from 'node:fs';
|
|
4
|
-
import { createServer, } from 'node:http';
|
|
5
|
-
import { createServer as createHttpsServer, } from 'node:https';
|
|
6
|
-
import { freemem, hostname, totalmem } from 'node:os';
|
|
7
|
-
import { monitorEventLoopDelay, performance } from 'node:perf_hooks';
|
|
8
|
-
import process from 'node:process';
|
|
9
|
-
import { Writable } from 'node:stream';
|
|
10
|
-
import { pipeline } from 'node:stream/promises';
|
|
11
|
-
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
12
|
-
import { InvalidTokenError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
13
|
-
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
14
|
-
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
15
|
-
import { keys as cacheKeys, handleDownload } from './cache.js';
|
|
16
|
-
import { config, enableHttpMode, serverVersion } from './config.js';
|
|
17
|
-
import { hmacSha256Hex, timingSafeEqualUtf8 } from './crypto.js';
|
|
18
|
-
import { normalizeHost } from './host-normalization.js';
|
|
19
|
-
import { createDefaultBlockList, normalizeIpForBlockList, } from './ip-blocklist.js';
|
|
20
|
-
import { acceptsEventStream, acceptsJsonAndEventStream, isJsonRpcBatchRequest, isMcpRequestBody, } from './mcp-validator.js';
|
|
21
|
-
import { cancelTasksForOwner } from './mcp.js';
|
|
22
|
-
import { logError, logInfo, logWarn, registerMcpSessionServer, resolveMcpSessionIdByServer, runWithRequestContext, unregisterMcpSessionServer, unregisterMcpSessionServerByServer, } from './observability.js';
|
|
23
|
-
import { applyHttpServerTuning, drainConnectionsOnShutdown, } from './server-tuning.js';
|
|
24
|
-
import { createMcpServerForHttpSession } from './server.js';
|
|
25
|
-
import { composeCloseHandlers, createSessionStore, createSlotTracker, ensureSessionCapacity, reserveSessionSlot, startSessionCleanupLoop, } from './session.js';
|
|
26
|
-
import { getTransformPoolStats } from './transform.js';
|
|
27
|
-
import { isObject } from './type-guards.js';
|
|
28
|
-
function createTransportAdapter(transportImpl) {
|
|
29
|
-
const noopOnClose = () => { };
|
|
30
|
-
const noopOnError = () => { };
|
|
31
|
-
const noopOnMessage = () => { };
|
|
32
|
-
const baseOnClose = transportImpl.onclose;
|
|
33
|
-
let oncloseHandler = noopOnClose;
|
|
34
|
-
let onerrorHandler = noopOnError;
|
|
35
|
-
let onmessageHandler = noopOnMessage;
|
|
36
|
-
return {
|
|
37
|
-
start: () => transportImpl.start(),
|
|
38
|
-
send: (message, options) => transportImpl.send(message, options),
|
|
39
|
-
close: () => transportImpl.close(),
|
|
40
|
-
get onclose() {
|
|
41
|
-
return oncloseHandler;
|
|
42
|
-
},
|
|
43
|
-
set onclose(handler) {
|
|
44
|
-
oncloseHandler = handler;
|
|
45
|
-
transportImpl.onclose = composeCloseHandlers(baseOnClose, handler);
|
|
46
|
-
},
|
|
47
|
-
get onerror() {
|
|
48
|
-
return onerrorHandler;
|
|
49
|
-
},
|
|
50
|
-
set onerror(handler) {
|
|
51
|
-
onerrorHandler = handler;
|
|
52
|
-
transportImpl.onerror = handler;
|
|
53
|
-
},
|
|
54
|
-
get onmessage() {
|
|
55
|
-
return onmessageHandler;
|
|
56
|
-
},
|
|
57
|
-
set onmessage(handler) {
|
|
58
|
-
onmessageHandler = handler;
|
|
59
|
-
transportImpl.onmessage = handler;
|
|
60
|
-
},
|
|
61
|
-
};
|
|
62
|
-
}
|
|
63
|
-
function sendJson(res, status, body) {
|
|
64
|
-
res.statusCode = status;
|
|
65
|
-
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
66
|
-
setNoStoreHeaders(res);
|
|
67
|
-
res.end(JSON.stringify(body));
|
|
68
|
-
}
|
|
69
|
-
function sendText(res, status, body) {
|
|
70
|
-
res.statusCode = status;
|
|
71
|
-
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
72
|
-
setNoStoreHeaders(res);
|
|
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 setNoStoreHeaders(res) {
|
|
81
|
-
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
82
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
83
|
-
}
|
|
84
|
-
function drainRequest(req) {
|
|
85
|
-
if (req.readableEnded)
|
|
86
|
-
return;
|
|
87
|
-
try {
|
|
88
|
-
req.resume();
|
|
89
|
-
}
|
|
90
|
-
catch {
|
|
91
|
-
// Best-effort only.
|
|
92
|
-
}
|
|
93
|
-
}
|
|
94
|
-
function createRequestAbortSignal(req) {
|
|
95
|
-
const controller = new AbortController();
|
|
96
|
-
let cleanedUp = false;
|
|
97
|
-
const abortRequest = () => {
|
|
98
|
-
if (cleanedUp)
|
|
99
|
-
return;
|
|
100
|
-
if (!controller.signal.aborted)
|
|
101
|
-
controller.abort();
|
|
102
|
-
};
|
|
103
|
-
if (req.destroyed) {
|
|
104
|
-
abortRequest();
|
|
105
|
-
return {
|
|
106
|
-
signal: controller.signal,
|
|
107
|
-
cleanup: () => {
|
|
108
|
-
cleanedUp = true;
|
|
109
|
-
},
|
|
110
|
-
};
|
|
111
|
-
}
|
|
112
|
-
const onAborted = () => {
|
|
113
|
-
abortRequest();
|
|
114
|
-
};
|
|
115
|
-
const onClose = () => {
|
|
116
|
-
// A normal close after a complete body should not be treated as cancellation.
|
|
117
|
-
if (req.complete)
|
|
118
|
-
return;
|
|
119
|
-
abortRequest();
|
|
120
|
-
};
|
|
121
|
-
const onError = () => {
|
|
122
|
-
abortRequest();
|
|
123
|
-
};
|
|
124
|
-
req.once('aborted', onAborted);
|
|
125
|
-
req.once('close', onClose);
|
|
126
|
-
req.once('error', onError);
|
|
127
|
-
return {
|
|
128
|
-
signal: controller.signal,
|
|
129
|
-
cleanup: () => {
|
|
130
|
-
cleanedUp = true;
|
|
131
|
-
req.removeListener('aborted', onAborted);
|
|
132
|
-
req.removeListener('close', onClose);
|
|
133
|
-
req.removeListener('error', onError);
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
function normalizeRemoteAddress(address) {
|
|
138
|
-
if (!address)
|
|
139
|
-
return null;
|
|
140
|
-
const trimmed = address.trim();
|
|
141
|
-
if (!trimmed)
|
|
142
|
-
return null;
|
|
143
|
-
const normalized = normalizeIpForBlockList(trimmed);
|
|
144
|
-
if (normalized)
|
|
145
|
-
return normalized.ip;
|
|
146
|
-
return trimmed;
|
|
147
|
-
}
|
|
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
|
-
const SINGLE_VALUE_HEADER_NAMES = [
|
|
177
|
-
'authorization',
|
|
178
|
-
'x-api-key',
|
|
179
|
-
'host',
|
|
180
|
-
'origin',
|
|
181
|
-
'content-length',
|
|
182
|
-
'mcp-session-id',
|
|
183
|
-
'x-mcp-session-id',
|
|
184
|
-
];
|
|
185
|
-
function hasDuplicateHeader(req, name) {
|
|
186
|
-
const values = req.headersDistinct[name];
|
|
187
|
-
return Array.isArray(values) && values.length > 1;
|
|
188
|
-
}
|
|
189
|
-
function findDuplicateSingleValueHeader(req) {
|
|
190
|
-
for (const name of SINGLE_VALUE_HEADER_NAMES) {
|
|
191
|
-
if (hasDuplicateHeader(req, name))
|
|
192
|
-
return name;
|
|
193
|
-
}
|
|
194
|
-
return null;
|
|
195
|
-
}
|
|
196
|
-
function getMcpSessionId(req) {
|
|
197
|
-
return (getHeaderValue(req, 'mcp-session-id') ??
|
|
198
|
-
getHeaderValue(req, 'x-mcp-session-id'));
|
|
199
|
-
}
|
|
200
|
-
function buildRequestContext(req, res, signal) {
|
|
201
|
-
let url;
|
|
202
|
-
try {
|
|
203
|
-
url = new URL(req.url ?? '', 'http://localhost');
|
|
204
|
-
}
|
|
205
|
-
catch {
|
|
206
|
-
sendJson(res, 400, { error: 'Invalid request URL' });
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
return {
|
|
210
|
-
req,
|
|
211
|
-
res,
|
|
212
|
-
url,
|
|
213
|
-
method: req.method,
|
|
214
|
-
ip: normalizeRemoteAddress(req.socket.remoteAddress),
|
|
215
|
-
body: undefined,
|
|
216
|
-
...(signal ? { signal } : {}),
|
|
217
|
-
};
|
|
218
|
-
}
|
|
219
|
-
async function closeTransportBestEffort(transport, context) {
|
|
220
|
-
try {
|
|
221
|
-
await transport.close();
|
|
222
|
-
}
|
|
223
|
-
catch (error) {
|
|
224
|
-
logWarn('Transport close failed', { context, error });
|
|
225
|
-
}
|
|
226
|
-
}
|
|
227
|
-
async function closeMcpServerBestEffort(server, context) {
|
|
228
|
-
try {
|
|
229
|
-
await server.close();
|
|
230
|
-
}
|
|
231
|
-
catch (error) {
|
|
232
|
-
logWarn('MCP server close failed', { context, error });
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
class JsonBodyError extends Error {
|
|
236
|
-
kind;
|
|
237
|
-
constructor(kind, message) {
|
|
238
|
-
super(message);
|
|
239
|
-
this.name = 'JsonBodyError';
|
|
240
|
-
this.kind = kind;
|
|
241
|
-
}
|
|
242
|
-
}
|
|
243
|
-
const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024;
|
|
244
|
-
function isRequestReadAborted(req) {
|
|
245
|
-
return req.destroyed && !req.complete;
|
|
246
|
-
}
|
|
247
|
-
class JsonBodyReader {
|
|
248
|
-
async read(req, limit = DEFAULT_BODY_LIMIT_BYTES, signal) {
|
|
249
|
-
const contentType = getHeaderValue(req, 'content-type');
|
|
250
|
-
if (!contentType?.includes('application/json'))
|
|
251
|
-
return undefined;
|
|
252
|
-
const contentLengthHeader = getHeaderValue(req, 'content-length');
|
|
253
|
-
if (contentLengthHeader) {
|
|
254
|
-
const contentLength = Number.parseInt(contentLengthHeader, 10);
|
|
255
|
-
if (Number.isFinite(contentLength) && contentLength > limit) {
|
|
256
|
-
try {
|
|
257
|
-
req.destroy();
|
|
258
|
-
}
|
|
259
|
-
catch {
|
|
260
|
-
// Best-effort only.
|
|
261
|
-
}
|
|
262
|
-
throw new JsonBodyError('payload-too-large', 'Payload too large');
|
|
263
|
-
}
|
|
264
|
-
}
|
|
265
|
-
if (signal?.aborted || isRequestReadAborted(req)) {
|
|
266
|
-
throw new JsonBodyError('read-failed', 'Request aborted');
|
|
267
|
-
}
|
|
268
|
-
const body = await this.readBody(req, limit, signal);
|
|
269
|
-
if (!body)
|
|
270
|
-
return undefined;
|
|
271
|
-
try {
|
|
272
|
-
return JSON.parse(body);
|
|
273
|
-
}
|
|
274
|
-
catch (err) {
|
|
275
|
-
throw new JsonBodyError('invalid-json', err instanceof Error ? err.message : String(err));
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
async readBody(req, limit, signal) {
|
|
279
|
-
const abortListener = this.attachAbortListener(req, signal);
|
|
280
|
-
try {
|
|
281
|
-
const { chunks, size } = await this.collectChunks(req, limit, signal);
|
|
282
|
-
if (chunks.length === 0)
|
|
283
|
-
return undefined;
|
|
284
|
-
return Buffer.concat(chunks, size).toString('utf8');
|
|
285
|
-
}
|
|
286
|
-
finally {
|
|
287
|
-
this.detachAbortListener(signal, abortListener);
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
|
-
attachAbortListener(req, signal) {
|
|
291
|
-
if (!signal)
|
|
292
|
-
return null;
|
|
293
|
-
const listener = () => {
|
|
294
|
-
try {
|
|
295
|
-
req.destroy();
|
|
296
|
-
}
|
|
297
|
-
catch {
|
|
298
|
-
// Best-effort only.
|
|
299
|
-
}
|
|
300
|
-
};
|
|
301
|
-
if (signal.aborted) {
|
|
302
|
-
listener();
|
|
303
|
-
}
|
|
304
|
-
else {
|
|
305
|
-
signal.addEventListener('abort', listener, { once: true });
|
|
306
|
-
}
|
|
307
|
-
return listener;
|
|
308
|
-
}
|
|
309
|
-
detachAbortListener(signal, listener) {
|
|
310
|
-
if (!signal || !listener)
|
|
311
|
-
return;
|
|
312
|
-
try {
|
|
313
|
-
signal.removeEventListener('abort', listener);
|
|
314
|
-
}
|
|
315
|
-
catch {
|
|
316
|
-
// Best-effort cleanup.
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
async collectChunks(req, limit, signal) {
|
|
320
|
-
let size = 0;
|
|
321
|
-
const chunks = [];
|
|
322
|
-
const sink = new Writable({
|
|
323
|
-
write: (chunk, _encoding, callback) => {
|
|
324
|
-
try {
|
|
325
|
-
if (signal?.aborted || isRequestReadAborted(req)) {
|
|
326
|
-
callback(new JsonBodyError('read-failed', 'Request aborted'));
|
|
327
|
-
return;
|
|
328
|
-
}
|
|
329
|
-
const buf = this.normalizeChunk(chunk);
|
|
330
|
-
size += buf.length;
|
|
331
|
-
if (size > limit) {
|
|
332
|
-
req.destroy();
|
|
333
|
-
callback(new JsonBodyError('payload-too-large', 'Payload too large'));
|
|
334
|
-
return;
|
|
335
|
-
}
|
|
336
|
-
chunks.push(buf);
|
|
337
|
-
callback();
|
|
338
|
-
}
|
|
339
|
-
catch (err) {
|
|
340
|
-
callback(err instanceof Error ? err : new Error(String(err)));
|
|
341
|
-
}
|
|
342
|
-
},
|
|
343
|
-
});
|
|
344
|
-
try {
|
|
345
|
-
if (signal?.aborted || isRequestReadAborted(req)) {
|
|
346
|
-
throw new JsonBodyError('read-failed', 'Request aborted');
|
|
347
|
-
}
|
|
348
|
-
await pipeline(req, sink, signal ? { signal } : undefined);
|
|
349
|
-
return { chunks, size };
|
|
350
|
-
}
|
|
351
|
-
catch (err) {
|
|
352
|
-
if (err instanceof JsonBodyError)
|
|
353
|
-
throw err;
|
|
354
|
-
if (signal?.aborted || isRequestReadAborted(req)) {
|
|
355
|
-
throw new JsonBodyError('read-failed', 'Request aborted');
|
|
356
|
-
}
|
|
357
|
-
throw new JsonBodyError('read-failed', err instanceof Error ? err.message : String(err));
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
normalizeChunk(chunk) {
|
|
361
|
-
if (Buffer.isBuffer(chunk))
|
|
362
|
-
return chunk;
|
|
363
|
-
if (typeof chunk === 'string')
|
|
364
|
-
return Buffer.from(chunk, 'utf8');
|
|
365
|
-
return Buffer.from(chunk.buffer, chunk.byteOffset, chunk.byteLength);
|
|
366
|
-
}
|
|
367
|
-
}
|
|
368
|
-
const jsonBodyReader = new JsonBodyReader();
|
|
369
|
-
class CorsPolicy {
|
|
370
|
-
handle(ctx) {
|
|
371
|
-
const { req, res } = ctx;
|
|
372
|
-
const origin = getHeaderValue(req, 'origin');
|
|
373
|
-
if (origin) {
|
|
374
|
-
res.setHeader('Access-Control-Allow-Origin', origin);
|
|
375
|
-
res.setHeader('Vary', 'Origin');
|
|
376
|
-
}
|
|
377
|
-
else {
|
|
378
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
379
|
-
}
|
|
380
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
|
|
381
|
-
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');
|
|
382
|
-
if (req.method !== 'OPTIONS')
|
|
383
|
-
return false;
|
|
384
|
-
sendEmpty(res, 204);
|
|
385
|
-
return true;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
const corsPolicy = new CorsPolicy();
|
|
389
|
-
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
390
|
-
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
|
|
391
|
-
function hasConstantTimeMatch(candidates, input) {
|
|
392
|
-
// Avoid leaking match index via early-return.
|
|
393
|
-
let matched = 0;
|
|
394
|
-
for (const candidate of candidates) {
|
|
395
|
-
matched |= timingSafeEqualUtf8(candidate, input) ? 1 : 0;
|
|
396
|
-
}
|
|
397
|
-
return matched === 1;
|
|
398
|
-
}
|
|
399
|
-
function isWildcardHost(host) {
|
|
400
|
-
return WILDCARD_HOSTS.has(host);
|
|
401
|
-
}
|
|
402
|
-
function buildAllowedHosts() {
|
|
403
|
-
const allowed = new Set(LOOPBACK_HOSTS);
|
|
404
|
-
const configuredHost = normalizeHost(config.server.host);
|
|
405
|
-
if (configuredHost && !isWildcardHost(configuredHost)) {
|
|
406
|
-
allowed.add(configuredHost);
|
|
407
|
-
}
|
|
408
|
-
for (const host of config.security.allowedHosts) {
|
|
409
|
-
const normalized = normalizeHost(host);
|
|
410
|
-
if (normalized)
|
|
411
|
-
allowed.add(normalized);
|
|
412
|
-
}
|
|
413
|
-
return allowed;
|
|
414
|
-
}
|
|
415
|
-
const ALLOWED_HOSTS = buildAllowedHosts();
|
|
416
|
-
class HostOriginPolicy {
|
|
417
|
-
validate(ctx) {
|
|
418
|
-
const { req, res } = ctx;
|
|
419
|
-
const host = this.resolveHostHeader(req);
|
|
420
|
-
if (!host)
|
|
421
|
-
return this.reject(res, 400, 'Missing or invalid Host header');
|
|
422
|
-
if (!ALLOWED_HOSTS.has(host))
|
|
423
|
-
return this.reject(res, 403, 'Host not allowed');
|
|
424
|
-
const originHeader = getHeaderValue(req, 'origin');
|
|
425
|
-
if (!originHeader)
|
|
426
|
-
return true;
|
|
427
|
-
const originHost = this.resolveOriginHost(originHeader);
|
|
428
|
-
if (!originHost)
|
|
429
|
-
return this.reject(res, 403, 'Invalid Origin header');
|
|
430
|
-
if (!ALLOWED_HOSTS.has(originHost))
|
|
431
|
-
return this.reject(res, 403, 'Origin not allowed');
|
|
432
|
-
return true;
|
|
433
|
-
}
|
|
434
|
-
resolveHostHeader(req) {
|
|
435
|
-
const host = getHeaderValue(req, 'host');
|
|
436
|
-
if (!host)
|
|
437
|
-
return null;
|
|
438
|
-
return normalizeHost(host);
|
|
439
|
-
}
|
|
440
|
-
resolveOriginHost(origin) {
|
|
441
|
-
if (origin === 'null')
|
|
442
|
-
return null;
|
|
443
|
-
try {
|
|
444
|
-
const parsed = new URL(origin);
|
|
445
|
-
return normalizeHost(parsed.host);
|
|
446
|
-
}
|
|
447
|
-
catch {
|
|
448
|
-
return null;
|
|
449
|
-
}
|
|
450
|
-
}
|
|
451
|
-
reject(res, status, message) {
|
|
452
|
-
sendJson(res, status, { error: message });
|
|
453
|
-
return false;
|
|
454
|
-
}
|
|
455
|
-
}
|
|
456
|
-
const hostOriginPolicy = new HostOriginPolicy();
|
|
457
|
-
function assertHttpModeConfiguration() {
|
|
458
|
-
const configuredHost = normalizeHost(config.server.host);
|
|
459
|
-
const isLoopback = configuredHost !== null && LOOPBACK_HOSTS.has(configuredHost);
|
|
460
|
-
const isRemoteBinding = !isLoopback;
|
|
461
|
-
if (isRemoteBinding && !config.security.allowRemote) {
|
|
462
|
-
throw new Error('ALLOW_REMOTE must be true to bind to non-loopback interfaces');
|
|
463
|
-
}
|
|
464
|
-
if (isRemoteBinding && config.auth.mode !== 'oauth') {
|
|
465
|
-
throw new Error('OAuth authentication is required for remote bindings');
|
|
466
|
-
}
|
|
467
|
-
if (config.auth.mode === 'static' && config.auth.staticTokens.length === 0) {
|
|
468
|
-
throw new Error('Static auth requires ACCESS_TOKENS or API_KEY to be configured');
|
|
469
|
-
}
|
|
470
|
-
}
|
|
471
|
-
function isAbortError(error) {
|
|
472
|
-
return error instanceof Error && error.name === 'AbortError';
|
|
473
|
-
}
|
|
474
|
-
class RateLimiter {
|
|
475
|
-
options;
|
|
476
|
-
store = new Map();
|
|
477
|
-
cleanup = new AbortController();
|
|
478
|
-
constructor(options) {
|
|
479
|
-
this.options = options;
|
|
480
|
-
this.startCleanupLoop();
|
|
481
|
-
}
|
|
482
|
-
startCleanupLoop() {
|
|
483
|
-
const interval = setIntervalPromise(this.options.cleanupIntervalMs, Date.now, { signal: this.cleanup.signal, ref: false });
|
|
484
|
-
void (async () => {
|
|
485
|
-
try {
|
|
486
|
-
for await (const getNow of interval) {
|
|
487
|
-
this.cleanupEntries(getNow());
|
|
488
|
-
}
|
|
489
|
-
}
|
|
490
|
-
catch (err) {
|
|
491
|
-
if (!isAbortError(err)) {
|
|
492
|
-
logWarn('Rate limit cleanup failed', { error: err });
|
|
493
|
-
}
|
|
494
|
-
}
|
|
495
|
-
})();
|
|
496
|
-
}
|
|
497
|
-
cleanupEntries(now) {
|
|
498
|
-
const maxIdle = this.options.windowMs * 2;
|
|
499
|
-
for (const [key, entry] of this.store.entries()) {
|
|
500
|
-
if (now - entry.lastAccessed > maxIdle) {
|
|
501
|
-
this.store.delete(key);
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
}
|
|
505
|
-
check(ctx) {
|
|
506
|
-
if (!this.options.enabled || ctx.method === 'OPTIONS')
|
|
507
|
-
return true;
|
|
508
|
-
const key = ctx.ip ?? 'unknown';
|
|
509
|
-
const now = Date.now();
|
|
510
|
-
let entry = this.store.get(key);
|
|
511
|
-
if (entry) {
|
|
512
|
-
if (now > entry.resetTime) {
|
|
513
|
-
entry.count = 1;
|
|
514
|
-
entry.resetTime = now + this.options.windowMs;
|
|
515
|
-
entry.lastAccessed = now;
|
|
516
|
-
}
|
|
517
|
-
else {
|
|
518
|
-
entry.count += 1;
|
|
519
|
-
entry.lastAccessed = now;
|
|
520
|
-
}
|
|
521
|
-
}
|
|
522
|
-
else {
|
|
523
|
-
entry = {
|
|
524
|
-
count: 1,
|
|
525
|
-
resetTime: now + this.options.windowMs,
|
|
526
|
-
lastAccessed: now,
|
|
527
|
-
};
|
|
528
|
-
this.store.set(key, entry);
|
|
529
|
-
}
|
|
530
|
-
if (entry.count > this.options.maxRequests) {
|
|
531
|
-
const retryAfter = Math.max(1, Math.ceil((entry.resetTime - now) / 1000));
|
|
532
|
-
ctx.res.setHeader('Retry-After', String(retryAfter));
|
|
533
|
-
sendJson(ctx.res, 429, { error: 'Rate limit exceeded', retryAfter });
|
|
534
|
-
return false;
|
|
535
|
-
}
|
|
536
|
-
return true;
|
|
537
|
-
}
|
|
538
|
-
stop() {
|
|
539
|
-
this.cleanup.abort();
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
function createRateLimitManagerImpl(options) {
|
|
543
|
-
return new RateLimiter(options);
|
|
544
|
-
}
|
|
545
|
-
const STATIC_TOKEN_TTL_SECONDS = 60 * 60 * 24;
|
|
546
|
-
const STATIC_TOKEN_HMAC_KEY = randomBytes(32);
|
|
547
|
-
const SESSION_AUTH_FINGERPRINT_KEY = randomBytes(32);
|
|
548
|
-
class AuthService {
|
|
549
|
-
staticTokenDigests = config.auth.staticTokens.map((token) => hmacSha256Hex(STATIC_TOKEN_HMAC_KEY, token));
|
|
550
|
-
async authenticate(req, signal) {
|
|
551
|
-
const authHeader = getHeaderValue(req, 'authorization');
|
|
552
|
-
if (!authHeader) {
|
|
553
|
-
return this.authenticateWithApiKey(req);
|
|
554
|
-
}
|
|
555
|
-
const token = this.resolveBearerToken(authHeader);
|
|
556
|
-
return this.authenticateWithToken(token, signal);
|
|
557
|
-
}
|
|
558
|
-
authenticateWithToken(token, signal) {
|
|
559
|
-
return config.auth.mode === 'oauth'
|
|
560
|
-
? this.verifyWithIntrospection(token, signal)
|
|
561
|
-
: Promise.resolve(this.verifyStaticToken(token));
|
|
562
|
-
}
|
|
563
|
-
authenticateWithApiKey(req) {
|
|
564
|
-
const apiKey = getHeaderValue(req, 'x-api-key');
|
|
565
|
-
if (apiKey && config.auth.mode === 'static') {
|
|
566
|
-
return this.verifyStaticToken(apiKey);
|
|
567
|
-
}
|
|
568
|
-
if (apiKey && config.auth.mode === 'oauth') {
|
|
569
|
-
throw new InvalidTokenError('X-API-Key not supported for OAuth');
|
|
570
|
-
}
|
|
571
|
-
throw new InvalidTokenError('Missing Authorization header');
|
|
572
|
-
}
|
|
573
|
-
resolveBearerToken(authHeader) {
|
|
574
|
-
if (!authHeader.startsWith('Bearer ')) {
|
|
575
|
-
throw new InvalidTokenError('Invalid Authorization header format');
|
|
576
|
-
}
|
|
577
|
-
const token = authHeader.substring(7);
|
|
578
|
-
if (!token) {
|
|
579
|
-
throw new InvalidTokenError('Invalid Authorization header format');
|
|
580
|
-
}
|
|
581
|
-
return token;
|
|
582
|
-
}
|
|
583
|
-
buildStaticAuthInfo(token) {
|
|
584
|
-
return {
|
|
585
|
-
token,
|
|
586
|
-
clientId: 'static-token',
|
|
587
|
-
scopes: config.auth.requiredScopes,
|
|
588
|
-
expiresAt: Math.floor(Date.now() / 1000) + STATIC_TOKEN_TTL_SECONDS,
|
|
589
|
-
resource: config.auth.resourceUrl,
|
|
590
|
-
};
|
|
591
|
-
}
|
|
592
|
-
verifyStaticToken(token) {
|
|
593
|
-
if (this.staticTokenDigests.length === 0) {
|
|
594
|
-
throw new InvalidTokenError('No static tokens configured');
|
|
595
|
-
}
|
|
596
|
-
const tokenDigest = hmacSha256Hex(STATIC_TOKEN_HMAC_KEY, token);
|
|
597
|
-
const matched = hasConstantTimeMatch(this.staticTokenDigests, tokenDigest);
|
|
598
|
-
if (!matched)
|
|
599
|
-
throw new InvalidTokenError('Invalid token');
|
|
600
|
-
return this.buildStaticAuthInfo(token);
|
|
601
|
-
}
|
|
602
|
-
stripHash(url) {
|
|
603
|
-
const clean = new URL(url);
|
|
604
|
-
clean.hash = '';
|
|
605
|
-
return clean.href;
|
|
606
|
-
}
|
|
607
|
-
buildBasicAuthHeader(clientId, clientSecret) {
|
|
608
|
-
// Base64 is only an encoding for header transport; it is NOT encryption.
|
|
609
|
-
const credentials = `${clientId}:${clientSecret ?? ''}`;
|
|
610
|
-
return `Basic ${Buffer.from(credentials, 'utf8').toString('base64')}`;
|
|
611
|
-
}
|
|
612
|
-
buildIntrospectionRequest(token, resourceUrl, clientId, clientSecret) {
|
|
613
|
-
const body = new URLSearchParams({
|
|
614
|
-
token,
|
|
615
|
-
token_type_hint: 'access_token',
|
|
616
|
-
resource: this.stripHash(resourceUrl),
|
|
617
|
-
}).toString();
|
|
618
|
-
const headers = {
|
|
619
|
-
'content-type': 'application/x-www-form-urlencoded',
|
|
620
|
-
};
|
|
621
|
-
if (clientId) {
|
|
622
|
-
headers['authorization'] = this.buildBasicAuthHeader(clientId, clientSecret);
|
|
623
|
-
}
|
|
624
|
-
return { body, headers };
|
|
625
|
-
}
|
|
626
|
-
async requestIntrospection(url, request, timeoutMs, signal) {
|
|
627
|
-
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
628
|
-
const combinedSignal = signal
|
|
629
|
-
? AbortSignal.any([signal, timeoutSignal])
|
|
630
|
-
: timeoutSignal;
|
|
631
|
-
const response = await fetch(url, {
|
|
632
|
-
method: 'POST',
|
|
633
|
-
headers: request.headers,
|
|
634
|
-
body: request.body,
|
|
635
|
-
signal: combinedSignal,
|
|
636
|
-
});
|
|
637
|
-
if (!response.ok) {
|
|
638
|
-
if (response.body) {
|
|
639
|
-
await response.body.cancel();
|
|
640
|
-
}
|
|
641
|
-
throw new ServerError(`Token introspection failed: ${response.status}`);
|
|
642
|
-
}
|
|
643
|
-
return response.json();
|
|
644
|
-
}
|
|
645
|
-
buildIntrospectionAuthInfo(token, payload) {
|
|
646
|
-
const { exp, client_id: clientIdRaw, scope: scopeRaw } = payload;
|
|
647
|
-
const expiresAt = typeof exp === 'number' ? exp : undefined;
|
|
648
|
-
const clientId = typeof clientIdRaw === 'string' ? clientIdRaw : 'unknown';
|
|
649
|
-
const info = {
|
|
650
|
-
token,
|
|
651
|
-
clientId,
|
|
652
|
-
scopes: typeof scopeRaw === 'string' ? scopeRaw.split(' ') : [],
|
|
653
|
-
resource: config.auth.resourceUrl,
|
|
654
|
-
};
|
|
655
|
-
if (expiresAt !== undefined)
|
|
656
|
-
info.expiresAt = expiresAt;
|
|
657
|
-
return info;
|
|
658
|
-
}
|
|
659
|
-
async verifyWithIntrospection(token, signal) {
|
|
660
|
-
if (!config.auth.introspectionUrl) {
|
|
661
|
-
throw new ServerError('Introspection not configured');
|
|
662
|
-
}
|
|
663
|
-
const req = this.buildIntrospectionRequest(token, config.auth.resourceUrl, config.auth.clientId, config.auth.clientSecret);
|
|
664
|
-
const payload = await this.requestIntrospection(config.auth.introspectionUrl, req, config.auth.introspectionTimeoutMs, signal);
|
|
665
|
-
if (!isObject(payload) || payload['active'] !== true) {
|
|
666
|
-
throw new InvalidTokenError('Token is inactive');
|
|
667
|
-
}
|
|
668
|
-
return this.buildIntrospectionAuthInfo(token, payload);
|
|
669
|
-
}
|
|
670
|
-
}
|
|
671
|
-
const authService = new AuthService();
|
|
672
|
-
const EVENT_LOOP_DELAY_RESOLUTION_MS = 20;
|
|
673
|
-
const eventLoopDelay = monitorEventLoopDelay({
|
|
674
|
-
resolution: EVENT_LOOP_DELAY_RESOLUTION_MS,
|
|
675
|
-
});
|
|
676
|
-
let lastEventLoopUtilization = performance.eventLoopUtilization();
|
|
677
|
-
function roundTo(value, precision) {
|
|
678
|
-
const factor = 10 ** precision;
|
|
679
|
-
return Math.round(value * factor) / factor;
|
|
680
|
-
}
|
|
681
|
-
function formatEventLoopUtilization(snapshot) {
|
|
682
|
-
return {
|
|
683
|
-
utilization: roundTo(snapshot.utilization, 4),
|
|
684
|
-
activeMs: Math.round(snapshot.active),
|
|
685
|
-
idleMs: Math.round(snapshot.idle),
|
|
686
|
-
};
|
|
687
|
-
}
|
|
688
|
-
function toMs(valueNs) {
|
|
689
|
-
return roundTo(valueNs / 1_000_000, 3);
|
|
690
|
-
}
|
|
691
|
-
function getEventLoopStats() {
|
|
692
|
-
const current = performance.eventLoopUtilization();
|
|
693
|
-
const delta = performance.eventLoopUtilization(current, lastEventLoopUtilization);
|
|
694
|
-
lastEventLoopUtilization = current;
|
|
695
|
-
return {
|
|
696
|
-
utilization: {
|
|
697
|
-
total: formatEventLoopUtilization(current),
|
|
698
|
-
sinceLast: formatEventLoopUtilization(delta),
|
|
699
|
-
},
|
|
700
|
-
delay: {
|
|
701
|
-
minMs: toMs(eventLoopDelay.min),
|
|
702
|
-
maxMs: toMs(eventLoopDelay.max),
|
|
703
|
-
meanMs: toMs(eventLoopDelay.mean),
|
|
704
|
-
stddevMs: toMs(eventLoopDelay.stddev),
|
|
705
|
-
p50Ms: toMs(eventLoopDelay.percentile(50)),
|
|
706
|
-
p95Ms: toMs(eventLoopDelay.percentile(95)),
|
|
707
|
-
p99Ms: toMs(eventLoopDelay.percentile(99)),
|
|
708
|
-
},
|
|
709
|
-
};
|
|
710
|
-
}
|
|
711
|
-
function sendError(res, code, message, status = 400, id = null) {
|
|
712
|
-
sendJson(res, status, {
|
|
713
|
-
jsonrpc: '2.0',
|
|
714
|
-
error: { code, message },
|
|
715
|
-
id,
|
|
716
|
-
});
|
|
717
|
-
}
|
|
718
|
-
const DEFAULT_MCP_PROTOCOL_VERSION = '2025-11-25';
|
|
719
|
-
const LEGACY_MCP_PROTOCOL_VERSION = '2025-03-26';
|
|
720
|
-
const SUPPORTED_MCP_PROTOCOL_VERSIONS = new Set([
|
|
721
|
-
DEFAULT_MCP_PROTOCOL_VERSION,
|
|
722
|
-
LEGACY_MCP_PROTOCOL_VERSION,
|
|
723
|
-
]);
|
|
724
|
-
function ensureMcpProtocolVersion(req, res) {
|
|
725
|
-
const versionHeader = getHeaderValue(req, 'mcp-protocol-version');
|
|
726
|
-
if (!versionHeader) {
|
|
727
|
-
// Backwards-compatible fallback when header is missing.
|
|
728
|
-
return true;
|
|
729
|
-
}
|
|
730
|
-
const version = versionHeader.trim();
|
|
731
|
-
if (SUPPORTED_MCP_PROTOCOL_VERSIONS.has(version))
|
|
732
|
-
return true;
|
|
733
|
-
sendError(res, -32600, `Unsupported MCP-Protocol-Version: ${version}`);
|
|
734
|
-
return false;
|
|
735
|
-
}
|
|
736
|
-
function isVerboseHealthRequest(ctx) {
|
|
737
|
-
const value = ctx.url.searchParams.get('verbose');
|
|
738
|
-
if (!value)
|
|
739
|
-
return false;
|
|
740
|
-
const normalized = value.trim().toLowerCase();
|
|
741
|
-
return normalized === '1' || normalized === 'true';
|
|
742
|
-
}
|
|
743
|
-
function buildHealthResponse(store, includeDiagnostics) {
|
|
744
|
-
const base = {
|
|
745
|
-
status: 'ok',
|
|
746
|
-
version: serverVersion,
|
|
747
|
-
uptime: Math.floor(process.uptime()),
|
|
748
|
-
timestamp: new Date().toISOString(),
|
|
749
|
-
};
|
|
750
|
-
if (!includeDiagnostics)
|
|
751
|
-
return base;
|
|
752
|
-
const poolStats = getTransformPoolStats();
|
|
753
|
-
return {
|
|
754
|
-
...base,
|
|
755
|
-
os: {
|
|
756
|
-
hostname: hostname(),
|
|
757
|
-
platform: process.platform,
|
|
758
|
-
arch: process.arch,
|
|
759
|
-
memoryFree: freemem(),
|
|
760
|
-
memoryTotal: totalmem(),
|
|
761
|
-
},
|
|
762
|
-
process: {
|
|
763
|
-
pid: process.pid,
|
|
764
|
-
ppid: process.ppid,
|
|
765
|
-
memory: process.memoryUsage(),
|
|
766
|
-
cpu: process.cpuUsage(),
|
|
767
|
-
resource: process.resourceUsage(),
|
|
768
|
-
},
|
|
769
|
-
perf: getEventLoopStats(),
|
|
770
|
-
stats: {
|
|
771
|
-
activeSessions: store.size(),
|
|
772
|
-
cacheKeys: cacheKeys().length,
|
|
773
|
-
workerPool: poolStats ?? {
|
|
774
|
-
queueDepth: 0,
|
|
775
|
-
activeWorkers: 0,
|
|
776
|
-
capacity: 0,
|
|
777
|
-
},
|
|
778
|
-
},
|
|
779
|
-
};
|
|
780
|
-
}
|
|
781
|
-
function sendHealth(store, res, includeDiagnostics) {
|
|
782
|
-
res.setHeader('Cache-Control', 'no-store');
|
|
783
|
-
sendJson(res, 200, buildHealthResponse(store, includeDiagnostics));
|
|
784
|
-
}
|
|
785
|
-
function shouldAllowHealthWithoutAuth(ctx) {
|
|
786
|
-
if (ctx.method !== 'GET' || ctx.url.pathname !== '/health')
|
|
787
|
-
return false;
|
|
788
|
-
if (isVerboseHealthRequest(ctx))
|
|
789
|
-
return false;
|
|
790
|
-
return true;
|
|
791
|
-
}
|
|
792
|
-
function shouldAllowVerboseHealthWithoutAuth(ctx) {
|
|
793
|
-
if (ctx.method !== 'GET' || ctx.url.pathname !== '/health')
|
|
794
|
-
return false;
|
|
795
|
-
if (!isVerboseHealthRequest(ctx))
|
|
796
|
-
return false;
|
|
797
|
-
// Local-only deployments can expose verbose diagnostics without auth.
|
|
798
|
-
return !config.security.allowRemote;
|
|
799
|
-
}
|
|
800
|
-
function isHealthRoute(ctx) {
|
|
801
|
-
return ctx.method === 'GET' && ctx.url.pathname === '/health';
|
|
802
|
-
}
|
|
803
|
-
function ensureHealthAuthIfNeeded(ctx, authPresent) {
|
|
804
|
-
if (!isHealthRoute(ctx))
|
|
805
|
-
return true;
|
|
806
|
-
if (shouldAllowHealthWithoutAuth(ctx))
|
|
807
|
-
return true;
|
|
808
|
-
if (shouldAllowVerboseHealthWithoutAuth(ctx))
|
|
809
|
-
return true;
|
|
810
|
-
if (authPresent)
|
|
811
|
-
return true;
|
|
812
|
-
if (!isVerboseHealthRequest(ctx))
|
|
813
|
-
return true;
|
|
814
|
-
sendJson(ctx.res, 401, {
|
|
815
|
-
error: 'Authentication required for verbose health metrics',
|
|
816
|
-
});
|
|
817
|
-
return false;
|
|
818
|
-
}
|
|
819
|
-
function resolveHealthDiagnosticsMode(ctx, authPresent) {
|
|
820
|
-
if (!isHealthRoute(ctx))
|
|
821
|
-
return false;
|
|
822
|
-
if (!isVerboseHealthRequest(ctx))
|
|
823
|
-
return false;
|
|
824
|
-
if (authPresent)
|
|
825
|
-
return true;
|
|
826
|
-
return !config.security.allowRemote;
|
|
827
|
-
}
|
|
828
|
-
function shouldHandleHealthRoute(ctx) {
|
|
829
|
-
return ctx.method === 'GET' && ctx.url.pathname === '/health';
|
|
830
|
-
}
|
|
831
|
-
function sendHealthRouteResponse(store, ctx, authPresent) {
|
|
832
|
-
if (!shouldHandleHealthRoute(ctx))
|
|
833
|
-
return false;
|
|
834
|
-
if (!ensureHealthAuthIfNeeded(ctx, authPresent))
|
|
835
|
-
return true;
|
|
836
|
-
const includeDiagnostics = resolveHealthDiagnosticsMode(ctx, authPresent);
|
|
837
|
-
sendHealth(store, ctx.res, includeDiagnostics);
|
|
838
|
-
return true;
|
|
839
|
-
}
|
|
840
|
-
function buildAuthFingerprint(auth) {
|
|
841
|
-
if (!auth)
|
|
842
|
-
return null;
|
|
843
|
-
const safeClientId = typeof auth.clientId === 'string' ? auth.clientId : '';
|
|
844
|
-
const safeToken = typeof auth.token === 'string' ? auth.token : '';
|
|
845
|
-
if (!safeClientId && !safeToken)
|
|
846
|
-
return null;
|
|
847
|
-
return hmacSha256Hex(SESSION_AUTH_FINGERPRINT_KEY, `${safeClientId}:${safeToken}`);
|
|
848
|
-
}
|
|
849
|
-
class McpSessionGateway {
|
|
850
|
-
store;
|
|
851
|
-
createSessionServer;
|
|
852
|
-
constructor(store, createSessionServer) {
|
|
853
|
-
this.store = store;
|
|
854
|
-
this.createSessionServer = createSessionServer;
|
|
855
|
-
}
|
|
856
|
-
async handlePost(ctx) {
|
|
857
|
-
if (!ensureMcpProtocolVersion(ctx.req, ctx.res))
|
|
858
|
-
return;
|
|
859
|
-
if (!acceptsJsonAndEventStream(getHeaderValue(ctx.req, 'accept'))) {
|
|
860
|
-
sendJson(ctx.res, 400, {
|
|
861
|
-
error: 'Accept header must include application/json and text/event-stream',
|
|
862
|
-
});
|
|
863
|
-
return;
|
|
864
|
-
}
|
|
865
|
-
const { body } = ctx;
|
|
866
|
-
if (isJsonRpcBatchRequest(body)) {
|
|
867
|
-
sendError(ctx.res, -32600, 'Batch requests not supported');
|
|
868
|
-
return;
|
|
869
|
-
}
|
|
870
|
-
if (!isMcpRequestBody(body)) {
|
|
871
|
-
sendError(ctx.res, -32600, 'Invalid request body');
|
|
872
|
-
return;
|
|
873
|
-
}
|
|
874
|
-
const requestId = body.id ?? null;
|
|
875
|
-
logInfo('[MCP POST]', {
|
|
876
|
-
method: body.method,
|
|
877
|
-
id: body.id,
|
|
878
|
-
sessionId: getMcpSessionId(ctx.req),
|
|
879
|
-
});
|
|
880
|
-
const transport = await this.getOrCreateTransport(ctx, requestId);
|
|
881
|
-
if (!transport)
|
|
882
|
-
return;
|
|
883
|
-
await transport.handleRequest(ctx.req, ctx.res, body);
|
|
884
|
-
}
|
|
885
|
-
async handleGet(ctx) {
|
|
886
|
-
if (!ensureMcpProtocolVersion(ctx.req, ctx.res))
|
|
887
|
-
return;
|
|
888
|
-
const sessionId = getMcpSessionId(ctx.req);
|
|
889
|
-
if (!sessionId) {
|
|
890
|
-
sendError(ctx.res, -32600, 'Missing session ID');
|
|
891
|
-
return;
|
|
892
|
-
}
|
|
893
|
-
const session = this.store.get(sessionId);
|
|
894
|
-
if (!session) {
|
|
895
|
-
sendError(ctx.res, -32600, 'Session not found', 404);
|
|
896
|
-
return;
|
|
897
|
-
}
|
|
898
|
-
const acceptHeader = getHeaderValue(ctx.req, 'accept');
|
|
899
|
-
if (!acceptsEventStream(acceptHeader)) {
|
|
900
|
-
sendJson(ctx.res, 405, { error: 'Method Not Allowed' });
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
this.store.touch(sessionId);
|
|
904
|
-
await session.transport.handleRequest(ctx.req, ctx.res);
|
|
905
|
-
}
|
|
906
|
-
async handleDelete(ctx) {
|
|
907
|
-
if (!ensureMcpProtocolVersion(ctx.req, ctx.res))
|
|
908
|
-
return;
|
|
909
|
-
const sessionId = getMcpSessionId(ctx.req);
|
|
910
|
-
if (!sessionId) {
|
|
911
|
-
sendError(ctx.res, -32600, 'Missing session ID');
|
|
912
|
-
return;
|
|
913
|
-
}
|
|
914
|
-
const session = this.store.get(sessionId);
|
|
915
|
-
if (session) {
|
|
916
|
-
await session.transport.close();
|
|
917
|
-
this.cleanupSessionRecord(sessionId, 'session-delete');
|
|
918
|
-
}
|
|
919
|
-
sendText(ctx.res, 200, 'Session closed');
|
|
920
|
-
}
|
|
921
|
-
async getOrCreateTransport(ctx, requestId) {
|
|
922
|
-
const sessionId = getMcpSessionId(ctx.req);
|
|
923
|
-
if (sessionId) {
|
|
924
|
-
const fingerprint = buildAuthFingerprint(ctx.auth);
|
|
925
|
-
return this.getExistingTransport(sessionId, fingerprint, ctx.res, requestId);
|
|
926
|
-
}
|
|
927
|
-
if (!isInitializeRequest(ctx.body)) {
|
|
928
|
-
sendError(ctx.res, -32600, 'Missing session ID', 400, requestId);
|
|
929
|
-
return null;
|
|
930
|
-
}
|
|
931
|
-
return this.createNewSession(ctx, requestId);
|
|
932
|
-
}
|
|
933
|
-
getExistingTransport(sessionId, authFingerprint, res, requestId) {
|
|
934
|
-
const session = this.store.get(sessionId);
|
|
935
|
-
if (!session) {
|
|
936
|
-
sendError(res, -32600, 'Session not found', 404, requestId);
|
|
937
|
-
return null;
|
|
938
|
-
}
|
|
939
|
-
if (!authFingerprint || session.authFingerprint !== authFingerprint) {
|
|
940
|
-
sendError(res, -32600, 'Session not found', 404, requestId);
|
|
941
|
-
return null;
|
|
942
|
-
}
|
|
943
|
-
this.store.touch(sessionId);
|
|
944
|
-
return session.transport;
|
|
945
|
-
}
|
|
946
|
-
async createNewSession(ctx, requestId) {
|
|
947
|
-
const authFingerprint = buildAuthFingerprint(ctx.auth);
|
|
948
|
-
if (!authFingerprint) {
|
|
949
|
-
sendError(ctx.res, -32603, 'Missing auth context', 500, requestId);
|
|
950
|
-
return null;
|
|
951
|
-
}
|
|
952
|
-
if (!this.reserveCapacity(ctx.res, requestId))
|
|
953
|
-
return null;
|
|
954
|
-
const tracker = createSlotTracker(this.store);
|
|
955
|
-
const newSessionId = randomUUID();
|
|
956
|
-
let sessionServer;
|
|
957
|
-
try {
|
|
958
|
-
sessionServer = await this.createSessionServer();
|
|
959
|
-
}
|
|
960
|
-
catch (error) {
|
|
961
|
-
tracker.releaseSlot();
|
|
962
|
-
throw error;
|
|
963
|
-
}
|
|
964
|
-
const transportImpl = new StreamableHTTPServerTransport({
|
|
965
|
-
sessionIdGenerator: () => newSessionId,
|
|
966
|
-
});
|
|
967
|
-
const initTimeout = setTimeout(() => {
|
|
968
|
-
if (!tracker.isInitialized()) {
|
|
969
|
-
tracker.releaseSlot();
|
|
970
|
-
void closeTransportBestEffort(transportImpl, 'session-init-timeout');
|
|
971
|
-
void closeMcpServerBestEffort(sessionServer, 'session-init-timeout');
|
|
972
|
-
}
|
|
973
|
-
}, config.server.sessionInitTimeoutMs);
|
|
974
|
-
initTimeout.unref();
|
|
975
|
-
transportImpl.onclose = () => {
|
|
976
|
-
clearTimeout(initTimeout);
|
|
977
|
-
if (!tracker.isInitialized())
|
|
978
|
-
tracker.releaseSlot();
|
|
979
|
-
};
|
|
980
|
-
try {
|
|
981
|
-
const transport = createTransportAdapter(transportImpl);
|
|
982
|
-
await sessionServer.connect(transport);
|
|
983
|
-
}
|
|
984
|
-
catch (err) {
|
|
985
|
-
clearTimeout(initTimeout);
|
|
986
|
-
tracker.releaseSlot();
|
|
987
|
-
void closeTransportBestEffort(transportImpl, 'session-connect-failed');
|
|
988
|
-
void closeMcpServerBestEffort(sessionServer, 'session-connect-failed');
|
|
989
|
-
throw err;
|
|
990
|
-
}
|
|
991
|
-
tracker.markInitialized();
|
|
992
|
-
tracker.releaseSlot();
|
|
993
|
-
this.store.set(newSessionId, {
|
|
994
|
-
server: sessionServer,
|
|
995
|
-
transport: transportImpl,
|
|
996
|
-
createdAt: Date.now(),
|
|
997
|
-
lastSeen: Date.now(),
|
|
998
|
-
protocolInitialized: false,
|
|
999
|
-
authFingerprint,
|
|
1000
|
-
});
|
|
1001
|
-
registerMcpSessionServer(newSessionId, sessionServer);
|
|
1002
|
-
transportImpl.onclose = composeCloseHandlers(transportImpl.onclose, () => {
|
|
1003
|
-
this.cleanupSessionRecord(newSessionId, 'session-close');
|
|
1004
|
-
});
|
|
1005
|
-
return transportImpl;
|
|
1006
|
-
}
|
|
1007
|
-
cleanupSessionRecord(sessionId, context) {
|
|
1008
|
-
const session = this.store.remove(sessionId);
|
|
1009
|
-
if (!session)
|
|
1010
|
-
return;
|
|
1011
|
-
cancelTasksForOwner(`session:${sessionId}`, 'The task was cancelled because the MCP session ended.');
|
|
1012
|
-
unregisterMcpSessionServer(sessionId);
|
|
1013
|
-
void closeMcpServerBestEffort(session.server, `${context}-server`);
|
|
1014
|
-
}
|
|
1015
|
-
reserveCapacity(res, requestId) {
|
|
1016
|
-
const allowed = ensureSessionCapacity({
|
|
1017
|
-
store: this.store,
|
|
1018
|
-
maxSessions: config.server.maxSessions,
|
|
1019
|
-
evictOldest: (store) => {
|
|
1020
|
-
const evicted = store.evictOldest();
|
|
1021
|
-
if (evicted) {
|
|
1022
|
-
const sessionId = resolveMcpSessionIdByServer(evicted.server);
|
|
1023
|
-
if (sessionId) {
|
|
1024
|
-
cancelTasksForOwner(`session:${sessionId}`, 'The task was cancelled because the MCP session was evicted.');
|
|
1025
|
-
unregisterMcpSessionServer(sessionId);
|
|
1026
|
-
}
|
|
1027
|
-
unregisterMcpSessionServerByServer(evicted.server);
|
|
1028
|
-
void closeTransportBestEffort(evicted.transport, 'session-eviction');
|
|
1029
|
-
void closeMcpServerBestEffort(evicted.server, 'session-eviction');
|
|
1030
|
-
return true;
|
|
1031
|
-
}
|
|
1032
|
-
return false;
|
|
1033
|
-
},
|
|
1034
|
-
});
|
|
1035
|
-
if (!allowed) {
|
|
1036
|
-
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
1037
|
-
return false;
|
|
1038
|
-
}
|
|
1039
|
-
if (!reserveSessionSlot(this.store, config.server.maxSessions)) {
|
|
1040
|
-
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
1041
|
-
return false;
|
|
1042
|
-
}
|
|
1043
|
-
return true;
|
|
1044
|
-
}
|
|
1045
|
-
}
|
|
1046
|
-
function checkDownloadRoute(path) {
|
|
1047
|
-
const downloadMatch = /^\/mcp\/downloads\/([^/]+)\/([^/]+)$/.exec(path);
|
|
1048
|
-
if (!downloadMatch)
|
|
1049
|
-
return null;
|
|
1050
|
-
const namespace = downloadMatch[1];
|
|
1051
|
-
const hash = downloadMatch[2];
|
|
1052
|
-
if (!namespace || !hash)
|
|
1053
|
-
return null;
|
|
1054
|
-
return { namespace, hash };
|
|
1055
|
-
}
|
|
1056
|
-
class HttpDispatcher {
|
|
1057
|
-
store;
|
|
1058
|
-
mcpGateway;
|
|
1059
|
-
constructor(store, mcpGateway) {
|
|
1060
|
-
this.store = store;
|
|
1061
|
-
this.mcpGateway = mcpGateway;
|
|
1062
|
-
}
|
|
1063
|
-
async tryHandleHealthRoute(ctx) {
|
|
1064
|
-
if (!shouldHandleHealthRoute(ctx))
|
|
1065
|
-
return false;
|
|
1066
|
-
const requiresAuthForVerbose = isVerboseHealthRequest(ctx) && config.security.allowRemote;
|
|
1067
|
-
if (!requiresAuthForVerbose) {
|
|
1068
|
-
sendHealthRouteResponse(this.store, ctx, false);
|
|
1069
|
-
return true;
|
|
1070
|
-
}
|
|
1071
|
-
const healthAuth = await this.authenticateRequest(ctx);
|
|
1072
|
-
if (!healthAuth)
|
|
1073
|
-
return true;
|
|
1074
|
-
sendHealthRouteResponse(this.store, ctx, true);
|
|
1075
|
-
return true;
|
|
1076
|
-
}
|
|
1077
|
-
tryHandleDownloadRoute(ctx) {
|
|
1078
|
-
if (ctx.method !== 'GET')
|
|
1079
|
-
return false;
|
|
1080
|
-
const download = checkDownloadRoute(ctx.url.pathname);
|
|
1081
|
-
if (!download)
|
|
1082
|
-
return false;
|
|
1083
|
-
handleDownload(ctx.res, download.namespace, download.hash);
|
|
1084
|
-
return true;
|
|
1085
|
-
}
|
|
1086
|
-
async dispatch(ctx) {
|
|
1087
|
-
try {
|
|
1088
|
-
if (await this.tryHandleHealthRoute(ctx))
|
|
1089
|
-
return;
|
|
1090
|
-
const auth = await this.authenticateRequest(ctx);
|
|
1091
|
-
if (!auth)
|
|
1092
|
-
return;
|
|
1093
|
-
const authCtx = { ...ctx, auth };
|
|
1094
|
-
if (this.tryHandleDownloadRoute(ctx))
|
|
1095
|
-
return;
|
|
1096
|
-
if (ctx.url.pathname === '/mcp') {
|
|
1097
|
-
const handled = await this.handleMcpRoutes(authCtx);
|
|
1098
|
-
if (handled)
|
|
1099
|
-
return;
|
|
1100
|
-
}
|
|
1101
|
-
sendJson(ctx.res, 404, { error: 'Not Found' });
|
|
1102
|
-
}
|
|
1103
|
-
catch (err) {
|
|
1104
|
-
const error = err instanceof Error ? err : new Error(String(err));
|
|
1105
|
-
logError('Request failed', error);
|
|
1106
|
-
if (!ctx.res.writableEnded) {
|
|
1107
|
-
sendJson(ctx.res, 500, { error: 'Internal Server Error' });
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
}
|
|
1111
|
-
async handleMcpRoutes(ctx) {
|
|
1112
|
-
switch (ctx.method) {
|
|
1113
|
-
case 'POST':
|
|
1114
|
-
await this.mcpGateway.handlePost(ctx);
|
|
1115
|
-
return true;
|
|
1116
|
-
case 'GET':
|
|
1117
|
-
await this.mcpGateway.handleGet(ctx);
|
|
1118
|
-
return true;
|
|
1119
|
-
case 'DELETE':
|
|
1120
|
-
await this.mcpGateway.handleDelete(ctx);
|
|
1121
|
-
return true;
|
|
1122
|
-
default:
|
|
1123
|
-
return false;
|
|
1124
|
-
}
|
|
1125
|
-
}
|
|
1126
|
-
async authenticateRequest(ctx) {
|
|
1127
|
-
try {
|
|
1128
|
-
return await authService.authenticate(ctx.req, ctx.signal);
|
|
1129
|
-
}
|
|
1130
|
-
catch (err) {
|
|
1131
|
-
sendJson(ctx.res, 401, {
|
|
1132
|
-
error: err instanceof Error ? err.message : 'Unauthorized',
|
|
1133
|
-
});
|
|
1134
|
-
return null;
|
|
1135
|
-
}
|
|
1136
|
-
}
|
|
1137
|
-
}
|
|
1138
|
-
class HttpRequestPipeline {
|
|
1139
|
-
rateLimiter;
|
|
1140
|
-
dispatcher;
|
|
1141
|
-
constructor(rateLimiter, dispatcher) {
|
|
1142
|
-
this.rateLimiter = rateLimiter;
|
|
1143
|
-
this.dispatcher = dispatcher;
|
|
1144
|
-
}
|
|
1145
|
-
async handle(rawReq, rawRes) {
|
|
1146
|
-
const requestId = getHeaderValue(rawReq, 'x-request-id') ?? randomUUID();
|
|
1147
|
-
const sessionId = getMcpSessionId(rawReq) ?? undefined;
|
|
1148
|
-
const { signal, cleanup } = createRequestAbortSignal(rawReq);
|
|
1149
|
-
try {
|
|
1150
|
-
await runWithRequestContext({
|
|
1151
|
-
requestId,
|
|
1152
|
-
operationId: requestId,
|
|
1153
|
-
...(sessionId ? { sessionId } : {}),
|
|
1154
|
-
}, async () => {
|
|
1155
|
-
const duplicateHeader = findDuplicateSingleValueHeader(rawReq);
|
|
1156
|
-
if (duplicateHeader) {
|
|
1157
|
-
sendJson(rawRes, 400, {
|
|
1158
|
-
error: `Duplicate ${duplicateHeader} header is not allowed`,
|
|
1159
|
-
});
|
|
1160
|
-
drainRequest(rawReq);
|
|
1161
|
-
return;
|
|
1162
|
-
}
|
|
1163
|
-
const ctx = buildRequestContext(rawReq, rawRes, signal);
|
|
1164
|
-
if (!ctx) {
|
|
1165
|
-
drainRequest(rawReq);
|
|
1166
|
-
return;
|
|
1167
|
-
}
|
|
1168
|
-
if (!hostOriginPolicy.validate(ctx)) {
|
|
1169
|
-
drainRequest(rawReq);
|
|
1170
|
-
return;
|
|
1171
|
-
}
|
|
1172
|
-
if (corsPolicy.handle(ctx)) {
|
|
1173
|
-
drainRequest(rawReq);
|
|
1174
|
-
return;
|
|
1175
|
-
}
|
|
1176
|
-
if (!this.rateLimiter.check(ctx)) {
|
|
1177
|
-
drainRequest(rawReq);
|
|
1178
|
-
return;
|
|
1179
|
-
}
|
|
1180
|
-
try {
|
|
1181
|
-
ctx.body = await jsonBodyReader.read(ctx.req, DEFAULT_BODY_LIMIT_BYTES, ctx.signal);
|
|
1182
|
-
}
|
|
1183
|
-
catch {
|
|
1184
|
-
if (ctx.url.pathname === '/mcp' && ctx.method === 'POST') {
|
|
1185
|
-
sendError(ctx.res, -32700, 'Parse error', 400, null);
|
|
1186
|
-
}
|
|
1187
|
-
else {
|
|
1188
|
-
sendJson(ctx.res, 400, {
|
|
1189
|
-
error: 'Invalid JSON or Payload too large',
|
|
1190
|
-
});
|
|
1191
|
-
}
|
|
1192
|
-
drainRequest(rawReq);
|
|
1193
|
-
return;
|
|
1194
|
-
}
|
|
1195
|
-
await this.dispatcher.dispatch(ctx);
|
|
1196
|
-
});
|
|
1197
|
-
}
|
|
1198
|
-
finally {
|
|
1199
|
-
cleanup();
|
|
1200
|
-
}
|
|
1201
|
-
}
|
|
1202
|
-
}
|
|
1203
|
-
function handlePipelineError(error, res) {
|
|
1204
|
-
logError('Request pipeline failed', error instanceof Error ? error : new Error(String(error)));
|
|
1205
|
-
if (res.writableEnded)
|
|
1206
|
-
return;
|
|
1207
|
-
if (!res.headersSent) {
|
|
1208
|
-
sendJson(res, 500, { error: 'Internal Server Error' });
|
|
1209
|
-
return;
|
|
1210
|
-
}
|
|
1211
|
-
res.end();
|
|
1212
|
-
}
|
|
1213
|
-
function createNetworkServer(listener) {
|
|
1214
|
-
const { https } = config.server;
|
|
1215
|
-
if (!https.enabled) {
|
|
1216
|
-
return createServer(listener);
|
|
1217
|
-
}
|
|
1218
|
-
const { keyFile, certFile, caFile } = https;
|
|
1219
|
-
if (!keyFile || !certFile) {
|
|
1220
|
-
throw new Error('HTTPS enabled but SERVER_TLS_KEY_FILE / SERVER_TLS_CERT_FILE are missing');
|
|
1221
|
-
}
|
|
1222
|
-
const tlsOptions = {
|
|
1223
|
-
key: readFileSync(keyFile),
|
|
1224
|
-
cert: readFileSync(certFile),
|
|
1225
|
-
};
|
|
1226
|
-
if (caFile) {
|
|
1227
|
-
tlsOptions.ca = readFileSync(caFile);
|
|
1228
|
-
}
|
|
1229
|
-
return createHttpsServer(tlsOptions, listener);
|
|
1230
|
-
}
|
|
1231
|
-
async function listen(server, host, port) {
|
|
1232
|
-
await new Promise((resolve, reject) => {
|
|
1233
|
-
function onError(err) {
|
|
1234
|
-
server.off('error', onError);
|
|
1235
|
-
reject(err);
|
|
1236
|
-
}
|
|
1237
|
-
server.once('error', onError);
|
|
1238
|
-
server.listen(port, host, () => {
|
|
1239
|
-
server.off('error', onError);
|
|
1240
|
-
resolve();
|
|
1241
|
-
});
|
|
1242
|
-
});
|
|
1243
|
-
}
|
|
1244
|
-
function resolveListeningPort(server, fallback) {
|
|
1245
|
-
const addr = server.address();
|
|
1246
|
-
if (addr && typeof addr === 'object')
|
|
1247
|
-
return addr.port;
|
|
1248
|
-
return fallback;
|
|
1249
|
-
}
|
|
1250
|
-
function createShutdownHandler(options) {
|
|
1251
|
-
const closeBatchSize = 10;
|
|
1252
|
-
return async (signal) => {
|
|
1253
|
-
logInfo(`Stopping HTTP server (${signal})...`);
|
|
1254
|
-
options.rateLimiter.stop();
|
|
1255
|
-
options.sessionCleanup.abort();
|
|
1256
|
-
drainConnectionsOnShutdown(options.server);
|
|
1257
|
-
eventLoopDelay.disable();
|
|
1258
|
-
const sessions = options.sessionStore.clear();
|
|
1259
|
-
for (let i = 0; i < sessions.length; i += closeBatchSize) {
|
|
1260
|
-
const batch = sessions.slice(i, i + closeBatchSize);
|
|
1261
|
-
await Promise.all(batch.map(async (session) => {
|
|
1262
|
-
const sessionId = resolveMcpSessionIdByServer(session.server);
|
|
1263
|
-
if (sessionId) {
|
|
1264
|
-
cancelTasksForOwner(`session:${sessionId}`, 'The task was cancelled because the HTTP server is shutting down.');
|
|
1265
|
-
unregisterMcpSessionServer(sessionId);
|
|
1266
|
-
}
|
|
1267
|
-
unregisterMcpSessionServerByServer(session.server);
|
|
1268
|
-
await closeTransportBestEffort(session.transport, 'shutdown-session-close');
|
|
1269
|
-
await closeMcpServerBestEffort(session.server, 'shutdown-session-close');
|
|
1270
|
-
}));
|
|
1271
|
-
}
|
|
1272
|
-
await new Promise((resolve, reject) => {
|
|
1273
|
-
options.server.close((err) => {
|
|
1274
|
-
if (err)
|
|
1275
|
-
reject(err);
|
|
1276
|
-
else
|
|
1277
|
-
resolve();
|
|
1278
|
-
});
|
|
1279
|
-
});
|
|
1280
|
-
};
|
|
1281
|
-
}
|
|
1282
|
-
export async function startHttpServer() {
|
|
1283
|
-
assertHttpModeConfiguration();
|
|
1284
|
-
enableHttpMode();
|
|
1285
|
-
lastEventLoopUtilization = performance.eventLoopUtilization();
|
|
1286
|
-
eventLoopDelay.reset();
|
|
1287
|
-
eventLoopDelay.enable();
|
|
1288
|
-
const rateLimiter = createRateLimitManagerImpl(config.rateLimit);
|
|
1289
|
-
const sessionStore = createSessionStore(config.server.sessionTtlMs);
|
|
1290
|
-
const sessionCleanup = startSessionCleanupLoop(sessionStore, config.server.sessionTtlMs);
|
|
1291
|
-
const mcpGateway = new McpSessionGateway(sessionStore, createMcpServerForHttpSession);
|
|
1292
|
-
const dispatcher = new HttpDispatcher(sessionStore, mcpGateway);
|
|
1293
|
-
const pipeline = new HttpRequestPipeline(rateLimiter, dispatcher);
|
|
1294
|
-
const server = createNetworkServer((req, res) => {
|
|
1295
|
-
void pipeline.handle(req, res).catch((error) => {
|
|
1296
|
-
handlePipelineError(error, res);
|
|
1297
|
-
});
|
|
1298
|
-
});
|
|
1299
|
-
registerInboundBlockList(server);
|
|
1300
|
-
applyHttpServerTuning(server);
|
|
1301
|
-
await listen(server, config.server.host, config.server.port);
|
|
1302
|
-
const port = resolveListeningPort(server, config.server.port);
|
|
1303
|
-
const protocol = config.server.https.enabled ? 'https' : 'http';
|
|
1304
|
-
logInfo(`${protocol.toUpperCase()} server listening on port ${port}`, {
|
|
1305
|
-
platform: process.platform,
|
|
1306
|
-
arch: process.arch,
|
|
1307
|
-
hostname: hostname(),
|
|
1308
|
-
nodeVersion: process.version,
|
|
1309
|
-
});
|
|
1310
|
-
return {
|
|
1311
|
-
port,
|
|
1312
|
-
host: config.server.host,
|
|
1313
|
-
shutdown: createShutdownHandler({
|
|
1314
|
-
server,
|
|
1315
|
-
rateLimiter,
|
|
1316
|
-
sessionCleanup,
|
|
1317
|
-
sessionStore,
|
|
1318
|
-
}),
|
|
1319
|
-
};
|
|
1320
|
-
}
|