@j0hanz/superfetch 2.4.3 → 2.4.5
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 +8 -8
- package/dist/cache.js +277 -264
- package/dist/config.d.ts +1 -0
- package/dist/config.js +1 -0
- package/dist/crypto.js +4 -3
- package/dist/dom-noise-removal.js +355 -297
- package/dist/fetch.d.ts +13 -7
- package/dist/fetch.js +636 -690
- package/dist/http-native.js +535 -474
- package/dist/instructions.md +38 -27
- package/dist/language-detection.js +190 -153
- package/dist/markdown-cleanup.js +171 -158
- package/dist/mcp.js +183 -2
- package/dist/resources.d.ts +2 -0
- package/dist/resources.js +44 -0
- package/dist/session.js +144 -105
- package/dist/tasks.d.ts +37 -0
- package/dist/tasks.js +66 -0
- package/dist/tools.d.ts +8 -12
- package/dist/tools.js +196 -147
- package/dist/transform.d.ts +3 -1
- package/dist/transform.js +680 -778
- package/package.json +6 -6
package/dist/http-native.js
CHANGED
|
@@ -16,6 +16,9 @@ import { applyHttpServerTuning, drainConnectionsOnShutdown, } from './server-tun
|
|
|
16
16
|
import { composeCloseHandlers, createSessionStore, createSlotTracker, ensureSessionCapacity, reserveSessionSlot, startSessionCleanupLoop, } from './session.js';
|
|
17
17
|
import { getTransformPoolStats } from './transform.js';
|
|
18
18
|
import { isObject } from './type-guards.js';
|
|
19
|
+
/* -------------------------------------------------------------------------------------------------
|
|
20
|
+
* Transport adaptation
|
|
21
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
19
22
|
function createTransportAdapter(transportImpl) {
|
|
20
23
|
const noopOnClose = () => { };
|
|
21
24
|
const noopOnError = () => { };
|
|
@@ -72,52 +75,55 @@ function shimResponse(res) {
|
|
|
72
75
|
};
|
|
73
76
|
return shim;
|
|
74
77
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
size
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
});
|
|
92
|
-
req.on('end', () => {
|
|
93
|
-
try {
|
|
94
|
-
const body = Buffer.concat(chunks).toString();
|
|
95
|
-
if (!body) {
|
|
96
|
-
resolve(undefined);
|
|
78
|
+
/* -------------------------------------------------------------------------------------------------
|
|
79
|
+
* Request parsing helpers
|
|
80
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
81
|
+
class JsonBodyReader {
|
|
82
|
+
async read(req, limit = 1024 * 1024) {
|
|
83
|
+
const contentType = req.headers['content-type'];
|
|
84
|
+
if (!contentType?.includes('application/json'))
|
|
85
|
+
return undefined;
|
|
86
|
+
return new Promise((resolve, reject) => {
|
|
87
|
+
let size = 0;
|
|
88
|
+
const chunks = [];
|
|
89
|
+
req.on('data', (chunk) => {
|
|
90
|
+
size += chunk.length;
|
|
91
|
+
if (size > limit) {
|
|
92
|
+
req.destroy();
|
|
93
|
+
reject(new Error('Payload too large'));
|
|
97
94
|
return;
|
|
98
95
|
}
|
|
99
|
-
|
|
100
|
-
}
|
|
101
|
-
|
|
96
|
+
chunks.push(chunk);
|
|
97
|
+
});
|
|
98
|
+
req.on('end', () => {
|
|
99
|
+
try {
|
|
100
|
+
const body = Buffer.concat(chunks).toString();
|
|
101
|
+
if (!body) {
|
|
102
|
+
resolve(undefined);
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
resolve(JSON.parse(body));
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
109
|
+
}
|
|
110
|
+
});
|
|
111
|
+
req.on('error', (err) => {
|
|
102
112
|
reject(err instanceof Error ? err : new Error(String(err)));
|
|
103
|
-
}
|
|
104
|
-
});
|
|
105
|
-
req.on('error', (err) => {
|
|
106
|
-
reject(err instanceof Error ? err : new Error(String(err)));
|
|
113
|
+
});
|
|
107
114
|
});
|
|
108
|
-
}
|
|
115
|
+
}
|
|
109
116
|
}
|
|
117
|
+
const jsonBodyReader = new JsonBodyReader();
|
|
110
118
|
function parseQuery(url) {
|
|
111
119
|
const query = {};
|
|
112
120
|
for (const [key, value] of url.searchParams) {
|
|
113
121
|
const existing = query[key];
|
|
114
122
|
if (existing) {
|
|
115
|
-
if (Array.isArray(existing))
|
|
123
|
+
if (Array.isArray(existing))
|
|
116
124
|
existing.push(value);
|
|
117
|
-
|
|
118
|
-
else {
|
|
125
|
+
else
|
|
119
126
|
query[key] = [existing, value];
|
|
120
|
-
}
|
|
121
127
|
}
|
|
122
128
|
else {
|
|
123
129
|
query[key] = value;
|
|
@@ -125,18 +131,6 @@ function parseQuery(url) {
|
|
|
125
131
|
}
|
|
126
132
|
return query;
|
|
127
133
|
}
|
|
128
|
-
// --- CORS & Headers ---
|
|
129
|
-
function handleCors(req, res) {
|
|
130
|
-
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
131
|
-
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
|
|
132
|
-
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, MCP-Protocol-Version, X-MCP-Session-ID');
|
|
133
|
-
if (req.method === 'OPTIONS') {
|
|
134
|
-
res.writeHead(204);
|
|
135
|
-
res.end();
|
|
136
|
-
return true;
|
|
137
|
-
}
|
|
138
|
-
return false;
|
|
139
|
-
}
|
|
140
134
|
function getHeaderValue(req, name) {
|
|
141
135
|
const val = req.headers[name.toLowerCase()];
|
|
142
136
|
if (!val)
|
|
@@ -145,6 +139,23 @@ function getHeaderValue(req, name) {
|
|
|
145
139
|
return val[0] ?? null;
|
|
146
140
|
return val;
|
|
147
141
|
}
|
|
142
|
+
/* -------------------------------------------------------------------------------------------------
|
|
143
|
+
* CORS & Host/Origin policy
|
|
144
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
145
|
+
class CorsPolicy {
|
|
146
|
+
handle(req, res) {
|
|
147
|
+
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
148
|
+
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, DELETE');
|
|
149
|
+
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-API-Key, MCP-Protocol-Version, X-MCP-Session-ID');
|
|
150
|
+
if (req.method === 'OPTIONS') {
|
|
151
|
+
res.writeHead(204);
|
|
152
|
+
res.end();
|
|
153
|
+
return true;
|
|
154
|
+
}
|
|
155
|
+
return false;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
const corsPolicy = new CorsPolicy();
|
|
148
159
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
149
160
|
const WILDCARD_HOSTS = new Set(['0.0.0.0', '::']);
|
|
150
161
|
function isWildcardHost(host) {
|
|
@@ -158,54 +169,55 @@ function buildAllowedHosts() {
|
|
|
158
169
|
}
|
|
159
170
|
for (const host of config.security.allowedHosts) {
|
|
160
171
|
const normalized = normalizeHost(host);
|
|
161
|
-
if (normalized)
|
|
172
|
+
if (normalized)
|
|
162
173
|
allowed.add(normalized);
|
|
163
|
-
}
|
|
164
174
|
}
|
|
165
175
|
return allowed;
|
|
166
176
|
}
|
|
167
177
|
const ALLOWED_HOSTS = buildAllowedHosts();
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
function rejectHostRequest(res, status, message) {
|
|
186
|
-
res.status(status).json({ error: message });
|
|
187
|
-
return false;
|
|
188
|
-
}
|
|
189
|
-
function validateHostAndOrigin(req, res) {
|
|
190
|
-
const host = resolveHostHeader(req);
|
|
191
|
-
if (!host) {
|
|
192
|
-
return rejectHostRequest(res, 400, 'Missing or invalid Host header');
|
|
178
|
+
class HostOriginPolicy {
|
|
179
|
+
validate(req, res) {
|
|
180
|
+
const host = this.resolveHostHeader(req);
|
|
181
|
+
if (!host)
|
|
182
|
+
return this.reject(res, 400, 'Missing or invalid Host header');
|
|
183
|
+
if (!ALLOWED_HOSTS.has(host))
|
|
184
|
+
return this.reject(res, 403, 'Host not allowed');
|
|
185
|
+
const originHeader = getHeaderValue(req, 'origin');
|
|
186
|
+
if (originHeader) {
|
|
187
|
+
const originHost = this.resolveOriginHost(originHeader);
|
|
188
|
+
if (!originHost)
|
|
189
|
+
return this.reject(res, 403, 'Invalid Origin header');
|
|
190
|
+
if (!ALLOWED_HOSTS.has(originHost))
|
|
191
|
+
return this.reject(res, 403, 'Origin not allowed');
|
|
192
|
+
}
|
|
193
|
+
return true;
|
|
193
194
|
}
|
|
194
|
-
|
|
195
|
-
|
|
195
|
+
resolveHostHeader(req) {
|
|
196
|
+
const host = getHeaderValue(req, 'host');
|
|
197
|
+
if (!host)
|
|
198
|
+
return null;
|
|
199
|
+
return normalizeHost(host);
|
|
196
200
|
}
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
201
|
+
resolveOriginHost(origin) {
|
|
202
|
+
if (origin === 'null')
|
|
203
|
+
return null;
|
|
204
|
+
try {
|
|
205
|
+
const parsed = new URL(origin);
|
|
206
|
+
return normalizeHost(parsed.host);
|
|
202
207
|
}
|
|
203
|
-
|
|
204
|
-
return
|
|
208
|
+
catch {
|
|
209
|
+
return null;
|
|
205
210
|
}
|
|
206
211
|
}
|
|
207
|
-
|
|
212
|
+
reject(res, status, message) {
|
|
213
|
+
res.status(status).json({ error: message });
|
|
214
|
+
return false;
|
|
215
|
+
}
|
|
208
216
|
}
|
|
217
|
+
const hostOriginPolicy = new HostOriginPolicy();
|
|
218
|
+
/* -------------------------------------------------------------------------------------------------
|
|
219
|
+
* HTTP mode configuration assertions
|
|
220
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
209
221
|
function assertHttpModeConfiguration() {
|
|
210
222
|
const configuredHost = normalizeHost(config.server.host);
|
|
211
223
|
const isLoopback = configuredHost !== null && LOOPBACK_HOSTS.has(configuredHost);
|
|
@@ -223,170 +235,184 @@ function assertHttpModeConfiguration() {
|
|
|
223
235
|
function isAbortError(error) {
|
|
224
236
|
return error instanceof Error && error.name === 'AbortError';
|
|
225
237
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
238
|
+
class RateLimiter {
|
|
239
|
+
options;
|
|
240
|
+
store = new Map();
|
|
241
|
+
cleanup = new AbortController();
|
|
242
|
+
constructor(options) {
|
|
243
|
+
this.options = options;
|
|
244
|
+
this.startCleanupLoop();
|
|
245
|
+
}
|
|
246
|
+
startCleanupLoop() {
|
|
247
|
+
const interval = setIntervalPromise(this.options.cleanupIntervalMs, Date.now, { signal: this.cleanup.signal, ref: false });
|
|
248
|
+
void (async () => {
|
|
249
|
+
try {
|
|
250
|
+
for await (const getNow of interval) {
|
|
251
|
+
const now = getNow();
|
|
252
|
+
for (const [key, entry] of this.store.entries()) {
|
|
253
|
+
if (now - entry.lastAccessed > this.options.windowMs * 2) {
|
|
254
|
+
this.store.delete(key);
|
|
255
|
+
}
|
|
240
256
|
}
|
|
241
257
|
}
|
|
242
258
|
}
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
248
|
-
})();
|
|
249
|
-
return {
|
|
250
|
-
check: (req, res) => {
|
|
251
|
-
if (!options.enabled || req.method === 'OPTIONS')
|
|
252
|
-
return true;
|
|
253
|
-
const key = req.ip ?? 'unknown';
|
|
254
|
-
const now = Date.now();
|
|
255
|
-
let entry = store.get(key);
|
|
256
|
-
if (!entry || now > entry.resetTime) {
|
|
257
|
-
entry = {
|
|
258
|
-
count: 1,
|
|
259
|
-
resetTime: now + options.windowMs,
|
|
260
|
-
lastAccessed: now,
|
|
261
|
-
};
|
|
262
|
-
store.set(key, entry);
|
|
263
|
-
}
|
|
264
|
-
else {
|
|
265
|
-
entry.count++;
|
|
266
|
-
entry.lastAccessed = now;
|
|
267
|
-
}
|
|
268
|
-
if (entry.count > options.maxRequests) {
|
|
269
|
-
const retryAfter = Math.max(1, Math.ceil((entry.resetTime - now) / 1000));
|
|
270
|
-
res.setHeader('Retry-After', String(retryAfter));
|
|
271
|
-
res.status(429).json({ error: 'Rate limit exceeded', retryAfter });
|
|
272
|
-
return false;
|
|
259
|
+
catch (err) {
|
|
260
|
+
if (!isAbortError(err)) {
|
|
261
|
+
logWarn('Rate limit cleanup failed', { error: err });
|
|
262
|
+
}
|
|
273
263
|
}
|
|
274
|
-
|
|
275
|
-
},
|
|
276
|
-
stop: () => {
|
|
277
|
-
cleanup.abort();
|
|
278
|
-
},
|
|
279
|
-
};
|
|
280
|
-
}
|
|
281
|
-
// --- Auth ---
|
|
282
|
-
const STATIC_TOKEN_TTL_SECONDS = 60 * 60 * 24;
|
|
283
|
-
function buildStaticAuthInfo(token) {
|
|
284
|
-
return {
|
|
285
|
-
token,
|
|
286
|
-
clientId: 'static-token',
|
|
287
|
-
scopes: config.auth.requiredScopes,
|
|
288
|
-
expiresAt: Math.floor(Date.now() / 1000) + STATIC_TOKEN_TTL_SECONDS,
|
|
289
|
-
resource: config.auth.resourceUrl,
|
|
290
|
-
};
|
|
291
|
-
}
|
|
292
|
-
function verifyStaticToken(token) {
|
|
293
|
-
if (config.auth.staticTokens.length === 0) {
|
|
294
|
-
throw new InvalidTokenError('No static tokens configured');
|
|
295
|
-
}
|
|
296
|
-
const matched = config.auth.staticTokens.some((candidate) => timingSafeEqualUtf8(candidate, token));
|
|
297
|
-
if (!matched)
|
|
298
|
-
throw new InvalidTokenError('Invalid token');
|
|
299
|
-
return buildStaticAuthInfo(token);
|
|
300
|
-
}
|
|
301
|
-
function stripHash(url) {
|
|
302
|
-
const clean = new URL(url);
|
|
303
|
-
clean.hash = '';
|
|
304
|
-
return clean.href;
|
|
305
|
-
}
|
|
306
|
-
function buildBasicAuthHeader(clientId, clientSecret) {
|
|
307
|
-
const credentials = `${clientId}:${clientSecret ?? ''}`;
|
|
308
|
-
return `Basic ${Buffer.from(credentials).toString('base64')}`;
|
|
309
|
-
}
|
|
310
|
-
function buildIntrospectionRequest(token, resourceUrl, clientId, clientSecret) {
|
|
311
|
-
const body = new URLSearchParams({
|
|
312
|
-
token,
|
|
313
|
-
token_type_hint: 'access_token',
|
|
314
|
-
resource: stripHash(resourceUrl),
|
|
315
|
-
}).toString();
|
|
316
|
-
const headers = {
|
|
317
|
-
'content-type': 'application/x-www-form-urlencoded',
|
|
318
|
-
};
|
|
319
|
-
if (clientId)
|
|
320
|
-
headers.authorization = buildBasicAuthHeader(clientId, clientSecret);
|
|
321
|
-
return { body, headers };
|
|
322
|
-
}
|
|
323
|
-
async function requestIntrospection(url, request, timeoutMs) {
|
|
324
|
-
const response = await fetch(url, {
|
|
325
|
-
method: 'POST',
|
|
326
|
-
headers: request.headers,
|
|
327
|
-
body: request.body,
|
|
328
|
-
signal: AbortSignal.timeout(timeoutMs),
|
|
329
|
-
});
|
|
330
|
-
if (!response.ok) {
|
|
331
|
-
await response.body?.cancel();
|
|
332
|
-
throw new ServerError(`Token introspection failed: ${response.status}`);
|
|
264
|
+
})();
|
|
333
265
|
}
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
266
|
+
check(req, res) {
|
|
267
|
+
if (!this.options.enabled || req.method === 'OPTIONS')
|
|
268
|
+
return true;
|
|
269
|
+
const key = req.ip ?? 'unknown';
|
|
270
|
+
const now = Date.now();
|
|
271
|
+
let entry = this.store.get(key);
|
|
272
|
+
if (!entry || now > entry.resetTime) {
|
|
273
|
+
entry = {
|
|
274
|
+
count: 1,
|
|
275
|
+
resetTime: now + this.options.windowMs,
|
|
276
|
+
lastAccessed: now,
|
|
277
|
+
};
|
|
278
|
+
this.store.set(key, entry);
|
|
279
|
+
}
|
|
280
|
+
else {
|
|
281
|
+
entry.count += 1;
|
|
282
|
+
entry.lastAccessed = now;
|
|
283
|
+
}
|
|
284
|
+
if (entry.count > this.options.maxRequests) {
|
|
285
|
+
const retryAfter = Math.max(1, Math.ceil((entry.resetTime - now) / 1000));
|
|
286
|
+
res.setHeader('Retry-After', String(retryAfter));
|
|
287
|
+
res.status(429).json({ error: 'Rate limit exceeded', retryAfter });
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
return true;
|
|
347
291
|
}
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
async function verifyWithIntrospection(token) {
|
|
351
|
-
if (!config.auth.introspectionUrl)
|
|
352
|
-
throw new ServerError('Introspection not configured');
|
|
353
|
-
const req = buildIntrospectionRequest(token, config.auth.resourceUrl, config.auth.clientId, config.auth.clientSecret);
|
|
354
|
-
const payload = await requestIntrospection(config.auth.introspectionUrl, req, config.auth.introspectionTimeoutMs);
|
|
355
|
-
if (!isObject(payload) || payload.active !== true)
|
|
356
|
-
throw new InvalidTokenError('Token is inactive');
|
|
357
|
-
return buildIntrospectionAuthInfo(token, payload);
|
|
358
|
-
}
|
|
359
|
-
function resolveBearerToken(authHeader) {
|
|
360
|
-
const [type, token] = authHeader.split(' ');
|
|
361
|
-
if (type !== 'Bearer' || !token) {
|
|
362
|
-
throw new InvalidTokenError('Invalid Authorization header format');
|
|
292
|
+
stop() {
|
|
293
|
+
this.cleanup.abort();
|
|
363
294
|
}
|
|
364
|
-
return token;
|
|
365
295
|
}
|
|
366
|
-
function
|
|
367
|
-
return
|
|
368
|
-
? verifyWithIntrospection(token)
|
|
369
|
-
: Promise.resolve(verifyStaticToken(token));
|
|
296
|
+
function createRateLimitManagerImpl(options) {
|
|
297
|
+
return new RateLimiter(options);
|
|
370
298
|
}
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
299
|
+
/* -------------------------------------------------------------------------------------------------
|
|
300
|
+
* Auth (static + OAuth introspection)
|
|
301
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
302
|
+
const STATIC_TOKEN_TTL_SECONDS = 60 * 60 * 24;
|
|
303
|
+
class AuthService {
|
|
304
|
+
async authenticate(req) {
|
|
305
|
+
const authHeader = req.headers.authorization;
|
|
306
|
+
if (!authHeader) {
|
|
307
|
+
return this.authenticateWithApiKey(req);
|
|
308
|
+
}
|
|
309
|
+
const token = this.resolveBearerToken(authHeader);
|
|
310
|
+
return this.authenticateWithToken(token);
|
|
311
|
+
}
|
|
312
|
+
authenticateWithToken(token) {
|
|
313
|
+
return config.auth.mode === 'oauth'
|
|
314
|
+
? this.verifyWithIntrospection(token)
|
|
315
|
+
: Promise.resolve(this.verifyStaticToken(token));
|
|
316
|
+
}
|
|
317
|
+
authenticateWithApiKey(req) {
|
|
318
|
+
const apiKey = getHeaderValue(req, 'x-api-key');
|
|
319
|
+
if (apiKey && config.auth.mode === 'static') {
|
|
320
|
+
return this.verifyStaticToken(apiKey);
|
|
321
|
+
}
|
|
322
|
+
if (apiKey && config.auth.mode === 'oauth') {
|
|
323
|
+
throw new InvalidTokenError('X-API-Key not supported for OAuth');
|
|
324
|
+
}
|
|
325
|
+
throw new InvalidTokenError('Missing Authorization header');
|
|
378
326
|
}
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
return
|
|
327
|
+
resolveBearerToken(authHeader) {
|
|
328
|
+
const [type, token] = authHeader.split(' ');
|
|
329
|
+
if (type !== 'Bearer' || !token) {
|
|
330
|
+
throw new InvalidTokenError('Invalid Authorization header format');
|
|
331
|
+
}
|
|
332
|
+
return token;
|
|
333
|
+
}
|
|
334
|
+
buildStaticAuthInfo(token) {
|
|
335
|
+
return {
|
|
336
|
+
token,
|
|
337
|
+
clientId: 'static-token',
|
|
338
|
+
scopes: config.auth.requiredScopes,
|
|
339
|
+
expiresAt: Math.floor(Date.now() / 1000) + STATIC_TOKEN_TTL_SECONDS,
|
|
340
|
+
resource: config.auth.resourceUrl,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
verifyStaticToken(token) {
|
|
344
|
+
if (config.auth.staticTokens.length === 0) {
|
|
345
|
+
throw new InvalidTokenError('No static tokens configured');
|
|
346
|
+
}
|
|
347
|
+
const matched = config.auth.staticTokens.some((candidate) => timingSafeEqualUtf8(candidate, token));
|
|
348
|
+
if (!matched)
|
|
349
|
+
throw new InvalidTokenError('Invalid token');
|
|
350
|
+
return this.buildStaticAuthInfo(token);
|
|
351
|
+
}
|
|
352
|
+
stripHash(url) {
|
|
353
|
+
const clean = new URL(url);
|
|
354
|
+
clean.hash = '';
|
|
355
|
+
return clean.href;
|
|
356
|
+
}
|
|
357
|
+
buildBasicAuthHeader(clientId, clientSecret) {
|
|
358
|
+
const credentials = `${clientId}:${clientSecret ?? ''}`;
|
|
359
|
+
return `Basic ${Buffer.from(credentials).toString('base64')}`;
|
|
360
|
+
}
|
|
361
|
+
buildIntrospectionRequest(token, resourceUrl, clientId, clientSecret) {
|
|
362
|
+
const body = new URLSearchParams({
|
|
363
|
+
token,
|
|
364
|
+
token_type_hint: 'access_token',
|
|
365
|
+
resource: this.stripHash(resourceUrl),
|
|
366
|
+
}).toString();
|
|
367
|
+
const headers = {
|
|
368
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
369
|
+
};
|
|
370
|
+
if (clientId)
|
|
371
|
+
headers.authorization = this.buildBasicAuthHeader(clientId, clientSecret);
|
|
372
|
+
return { body, headers };
|
|
373
|
+
}
|
|
374
|
+
async requestIntrospection(url, request, timeoutMs) {
|
|
375
|
+
const response = await fetch(url, {
|
|
376
|
+
method: 'POST',
|
|
377
|
+
headers: request.headers,
|
|
378
|
+
body: request.body,
|
|
379
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
380
|
+
});
|
|
381
|
+
if (!response.ok) {
|
|
382
|
+
await response.body?.cancel();
|
|
383
|
+
throw new ServerError(`Token introspection failed: ${response.status}`);
|
|
384
|
+
}
|
|
385
|
+
return response.json();
|
|
386
|
+
}
|
|
387
|
+
buildIntrospectionAuthInfo(token, payload) {
|
|
388
|
+
const expiresAt = typeof payload.exp === 'number' ? payload.exp : undefined;
|
|
389
|
+
const clientId = typeof payload.client_id === 'string' ? payload.client_id : 'unknown';
|
|
390
|
+
const info = {
|
|
391
|
+
token,
|
|
392
|
+
clientId,
|
|
393
|
+
scopes: typeof payload.scope === 'string' ? payload.scope.split(' ') : [],
|
|
394
|
+
resource: config.auth.resourceUrl,
|
|
395
|
+
};
|
|
396
|
+
if (expiresAt !== undefined)
|
|
397
|
+
info.expiresAt = expiresAt;
|
|
398
|
+
return info;
|
|
399
|
+
}
|
|
400
|
+
async verifyWithIntrospection(token) {
|
|
401
|
+
if (!config.auth.introspectionUrl) {
|
|
402
|
+
throw new ServerError('Introspection not configured');
|
|
403
|
+
}
|
|
404
|
+
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);
|
|
406
|
+
if (!isObject(payload) || payload.active !== true) {
|
|
407
|
+
throw new InvalidTokenError('Token is inactive');
|
|
408
|
+
}
|
|
409
|
+
return this.buildIntrospectionAuthInfo(token, payload);
|
|
385
410
|
}
|
|
386
|
-
const token = resolveBearerToken(authHeader);
|
|
387
|
-
return authenticateWithToken(token);
|
|
388
411
|
}
|
|
389
|
-
|
|
412
|
+
const authService = new AuthService();
|
|
413
|
+
/* -------------------------------------------------------------------------------------------------
|
|
414
|
+
* MCP routing + session gateway
|
|
415
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
390
416
|
function sendError(res, code, message, status = 400, id = null) {
|
|
391
417
|
res.status(status).json({
|
|
392
418
|
jsonrpc: '2.0',
|
|
@@ -394,245 +420,309 @@ function sendError(res, code, message, status = 400, id = null) {
|
|
|
394
420
|
id,
|
|
395
421
|
});
|
|
396
422
|
}
|
|
423
|
+
const MCP_PROTOCOL_VERSION = '2025-11-25';
|
|
397
424
|
function ensureMcpProtocolVersion(req, res) {
|
|
398
425
|
const version = getHeaderValue(req, 'mcp-protocol-version');
|
|
399
426
|
if (!version) {
|
|
400
427
|
sendError(res, -32600, 'Missing MCP-Protocol-Version header');
|
|
401
428
|
return false;
|
|
402
429
|
}
|
|
403
|
-
if (version !==
|
|
430
|
+
if (version !== MCP_PROTOCOL_VERSION) {
|
|
404
431
|
sendError(res, -32600, `Unsupported MCP-Protocol-Version: ${version}`);
|
|
405
432
|
return false;
|
|
406
433
|
}
|
|
407
434
|
return true;
|
|
408
435
|
}
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
if (evicted) {
|
|
416
|
-
void evicted.transport.close().catch(() => { });
|
|
417
|
-
return true;
|
|
418
|
-
}
|
|
419
|
-
return false;
|
|
420
|
-
},
|
|
421
|
-
});
|
|
422
|
-
if (!allowed) {
|
|
423
|
-
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
424
|
-
return null;
|
|
425
|
-
}
|
|
426
|
-
if (!reserveSessionSlot(store, config.server.maxSessions)) {
|
|
427
|
-
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
428
|
-
return null;
|
|
436
|
+
class McpSessionGateway {
|
|
437
|
+
store;
|
|
438
|
+
mcpServer;
|
|
439
|
+
constructor(store, mcpServer) {
|
|
440
|
+
this.store = store;
|
|
441
|
+
this.mcpServer = mcpServer;
|
|
429
442
|
}
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
void transportImpl.close().catch(() => { });
|
|
443
|
+
async handlePost(req, res) {
|
|
444
|
+
if (!ensureMcpProtocolVersion(req, res))
|
|
445
|
+
return;
|
|
446
|
+
const { body } = req;
|
|
447
|
+
if (isJsonRpcBatchRequest(body)) {
|
|
448
|
+
sendError(res, -32600, 'Batch requests not supported');
|
|
449
|
+
return;
|
|
438
450
|
}
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
451
|
+
if (!isMcpRequestBody(body)) {
|
|
452
|
+
sendError(res, -32600, 'Invalid request body');
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const requestId = body.id ?? null;
|
|
456
|
+
logInfo('[MCP POST]', {
|
|
457
|
+
method: body.method,
|
|
458
|
+
id: body.id,
|
|
459
|
+
sessionId: getHeaderValue(req, 'mcp-session-id'),
|
|
460
|
+
});
|
|
461
|
+
const transport = await this.getOrCreateTransport(req, res, requestId);
|
|
462
|
+
if (!transport)
|
|
463
|
+
return;
|
|
464
|
+
await transport.handleRequest(req, res, body);
|
|
448
465
|
}
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
}
|
|
459
|
-
tracker.markInitialized();
|
|
460
|
-
tracker.releaseSlot();
|
|
461
|
-
store.set(newSessionId, {
|
|
462
|
-
transport: transportImpl,
|
|
463
|
-
createdAt: Date.now(),
|
|
464
|
-
lastSeen: Date.now(),
|
|
465
|
-
protocolInitialized: false,
|
|
466
|
-
});
|
|
467
|
-
transportImpl.onclose = composeCloseHandlers(transportImpl.onclose, () => {
|
|
468
|
-
store.remove(newSessionId);
|
|
469
|
-
});
|
|
470
|
-
return transportImpl;
|
|
471
|
-
}
|
|
472
|
-
async function getOrCreateTransport(req, res, store, mcpServer, requestId) {
|
|
473
|
-
const sessionId = getHeaderValue(req, 'mcp-session-id');
|
|
474
|
-
if (sessionId) {
|
|
475
|
-
const session = store.get(sessionId);
|
|
466
|
+
async handleGet(req, res) {
|
|
467
|
+
if (!ensureMcpProtocolVersion(req, res))
|
|
468
|
+
return;
|
|
469
|
+
const sessionId = getHeaderValue(req, 'mcp-session-id');
|
|
470
|
+
if (!sessionId) {
|
|
471
|
+
sendError(res, -32600, 'Missing session ID');
|
|
472
|
+
return;
|
|
473
|
+
}
|
|
474
|
+
const session = this.store.get(sessionId);
|
|
476
475
|
if (!session) {
|
|
477
|
-
sendError(res, -32600, 'Session not found', 404
|
|
478
|
-
return
|
|
476
|
+
sendError(res, -32600, 'Session not found', 404);
|
|
477
|
+
return;
|
|
479
478
|
}
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
return createNewSession(store, mcpServer, res, requestId);
|
|
488
|
-
}
|
|
489
|
-
async function handleMcpPost(req, res, store, mcpServer) {
|
|
490
|
-
if (!ensureMcpProtocolVersion(req, res))
|
|
491
|
-
return;
|
|
492
|
-
const { body } = req;
|
|
493
|
-
if (isJsonRpcBatchRequest(body)) {
|
|
494
|
-
sendError(res, -32600, 'Batch requests not supported');
|
|
495
|
-
return;
|
|
496
|
-
}
|
|
497
|
-
if (!isMcpRequestBody(body)) {
|
|
498
|
-
sendError(res, -32600, 'Invalid request body');
|
|
499
|
-
return;
|
|
500
|
-
}
|
|
501
|
-
const requestId = body.id ?? null;
|
|
502
|
-
logInfo('[MCP POST]', {
|
|
503
|
-
method: body.method,
|
|
504
|
-
id: body.id,
|
|
505
|
-
sessionId: getHeaderValue(req, 'mcp-session-id'),
|
|
506
|
-
});
|
|
507
|
-
const transport = await getOrCreateTransport(req, res, store, mcpServer, requestId);
|
|
508
|
-
if (!transport)
|
|
509
|
-
return;
|
|
510
|
-
await transport.handleRequest(req, res, body);
|
|
511
|
-
}
|
|
512
|
-
async function handleMcpGet(req, res, store) {
|
|
513
|
-
if (!ensureMcpProtocolVersion(req, res))
|
|
514
|
-
return;
|
|
515
|
-
const sessionId = getHeaderValue(req, 'mcp-session-id');
|
|
516
|
-
if (!sessionId) {
|
|
517
|
-
sendError(res, -32600, 'Missing session ID');
|
|
518
|
-
return;
|
|
519
|
-
}
|
|
520
|
-
const session = store.get(sessionId);
|
|
521
|
-
if (!session) {
|
|
522
|
-
sendError(res, -32600, 'Session not found', 404);
|
|
523
|
-
return;
|
|
524
|
-
}
|
|
525
|
-
const acceptHeader = getHeaderValue(req, 'accept');
|
|
526
|
-
if (!acceptsEventStream(acceptHeader)) {
|
|
527
|
-
res.status(406).json({ error: 'Not Acceptable' });
|
|
528
|
-
return;
|
|
529
|
-
}
|
|
530
|
-
store.touch(sessionId);
|
|
531
|
-
await session.transport.handleRequest(req, res);
|
|
532
|
-
}
|
|
533
|
-
async function handleMcpDelete(req, res, store) {
|
|
534
|
-
if (!ensureMcpProtocolVersion(req, res))
|
|
535
|
-
return;
|
|
536
|
-
const sessionId = getHeaderValue(req, 'mcp-session-id');
|
|
537
|
-
if (!sessionId) {
|
|
538
|
-
sendError(res, -32600, 'Missing session ID');
|
|
539
|
-
return;
|
|
540
|
-
}
|
|
541
|
-
const session = store.get(sessionId);
|
|
542
|
-
if (session) {
|
|
543
|
-
await session.transport.close();
|
|
544
|
-
store.remove(sessionId);
|
|
545
|
-
}
|
|
546
|
-
res.status(200).send('Session closed');
|
|
547
|
-
}
|
|
548
|
-
// --- Dispatch ---
|
|
549
|
-
async function routeMcpRequest(req, res, url, ctx) {
|
|
550
|
-
const { pathname: path } = url;
|
|
551
|
-
const { method } = req;
|
|
552
|
-
if (path !== '/mcp')
|
|
553
|
-
return false;
|
|
554
|
-
if (method === 'POST') {
|
|
555
|
-
await handleMcpPost(req, res, ctx.store, ctx.mcpServer);
|
|
556
|
-
return true;
|
|
557
|
-
}
|
|
558
|
-
if (method === 'GET') {
|
|
559
|
-
await handleMcpGet(req, res, ctx.store);
|
|
560
|
-
return true;
|
|
479
|
+
const acceptHeader = getHeaderValue(req, 'accept');
|
|
480
|
+
if (!acceptsEventStream(acceptHeader)) {
|
|
481
|
+
res.status(406).json({ error: 'Not Acceptable' });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
this.store.touch(sessionId);
|
|
485
|
+
await session.transport.handleRequest(req, res);
|
|
561
486
|
}
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
487
|
+
async handleDelete(req, res) {
|
|
488
|
+
if (!ensureMcpProtocolVersion(req, res))
|
|
489
|
+
return;
|
|
490
|
+
const sessionId = getHeaderValue(req, 'mcp-session-id');
|
|
491
|
+
if (!sessionId) {
|
|
492
|
+
sendError(res, -32600, 'Missing session ID');
|
|
493
|
+
return;
|
|
494
|
+
}
|
|
495
|
+
const session = this.store.get(sessionId);
|
|
496
|
+
if (session) {
|
|
497
|
+
await session.transport.close();
|
|
498
|
+
this.store.remove(sessionId);
|
|
499
|
+
}
|
|
500
|
+
res.status(200).send('Session closed');
|
|
501
|
+
}
|
|
502
|
+
async getOrCreateTransport(req, res, requestId) {
|
|
503
|
+
const sessionId = getHeaderValue(req, 'mcp-session-id');
|
|
504
|
+
if (sessionId) {
|
|
505
|
+
const session = this.store.get(sessionId);
|
|
506
|
+
if (!session) {
|
|
507
|
+
sendError(res, -32600, 'Session not found', 404, requestId);
|
|
508
|
+
return null;
|
|
509
|
+
}
|
|
510
|
+
this.store.touch(sessionId);
|
|
511
|
+
return session.transport;
|
|
512
|
+
}
|
|
513
|
+
if (!isInitializeRequest(req.body)) {
|
|
514
|
+
sendError(res, -32600, 'Missing session ID', 400, requestId);
|
|
515
|
+
return null;
|
|
516
|
+
}
|
|
517
|
+
return this.createNewSession(res, requestId);
|
|
518
|
+
}
|
|
519
|
+
async createNewSession(res, requestId) {
|
|
520
|
+
const allowed = ensureSessionCapacity({
|
|
521
|
+
store: this.store,
|
|
522
|
+
maxSessions: config.server.maxSessions,
|
|
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);
|
|
534
|
+
return null;
|
|
535
|
+
}
|
|
536
|
+
if (!reserveSessionSlot(this.store, config.server.maxSessions)) {
|
|
537
|
+
sendError(res, -32000, 'Server busy', 503, requestId);
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
const tracker = createSlotTracker(this.store);
|
|
541
|
+
const transportImpl = new StreamableHTTPServerTransport({
|
|
542
|
+
sessionIdGenerator: () => randomUUID(),
|
|
543
|
+
});
|
|
544
|
+
const initTimeout = setTimeout(() => {
|
|
545
|
+
if (!tracker.isInitialized()) {
|
|
546
|
+
tracker.releaseSlot();
|
|
547
|
+
void transportImpl.close().catch(() => { });
|
|
548
|
+
}
|
|
549
|
+
}, config.server.sessionInitTimeoutMs);
|
|
550
|
+
transportImpl.onclose = () => {
|
|
551
|
+
clearTimeout(initTimeout);
|
|
552
|
+
if (!tracker.isInitialized())
|
|
553
|
+
tracker.releaseSlot();
|
|
554
|
+
};
|
|
555
|
+
try {
|
|
556
|
+
const transport = createTransportAdapter(transportImpl);
|
|
557
|
+
await this.mcpServer.connect(transport);
|
|
558
|
+
}
|
|
559
|
+
catch (err) {
|
|
560
|
+
clearTimeout(initTimeout);
|
|
561
|
+
tracker.releaseSlot();
|
|
562
|
+
void transportImpl.close().catch(() => { });
|
|
563
|
+
throw err;
|
|
564
|
+
}
|
|
565
|
+
const newSessionId = transportImpl.sessionId;
|
|
566
|
+
if (!newSessionId) {
|
|
567
|
+
throw new ServerError('Failed to generate session ID');
|
|
568
|
+
}
|
|
569
|
+
tracker.markInitialized();
|
|
570
|
+
tracker.releaseSlot();
|
|
571
|
+
this.store.set(newSessionId, {
|
|
572
|
+
transport: transportImpl,
|
|
573
|
+
createdAt: Date.now(),
|
|
574
|
+
lastSeen: Date.now(),
|
|
575
|
+
protocolInitialized: false,
|
|
576
|
+
});
|
|
577
|
+
transportImpl.onclose = composeCloseHandlers(transportImpl.onclose, () => {
|
|
578
|
+
this.store.remove(newSessionId);
|
|
579
|
+
});
|
|
580
|
+
return transportImpl;
|
|
565
581
|
}
|
|
566
|
-
return false;
|
|
567
582
|
}
|
|
583
|
+
/* -------------------------------------------------------------------------------------------------
|
|
584
|
+
* Downloads + dispatcher
|
|
585
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
568
586
|
function checkDownloadRoute(path) {
|
|
569
587
|
const downloadMatch = /^\/mcp\/downloads\/([^/]+)\/([^/]+)$/.exec(path);
|
|
570
|
-
if (downloadMatch)
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
588
|
+
if (!downloadMatch)
|
|
589
|
+
return null;
|
|
590
|
+
const namespace = downloadMatch[1];
|
|
591
|
+
const hash = downloadMatch[2];
|
|
592
|
+
if (!namespace || !hash)
|
|
593
|
+
return null;
|
|
594
|
+
return { namespace, hash };
|
|
595
|
+
}
|
|
596
|
+
class HttpDispatcher {
|
|
597
|
+
store;
|
|
598
|
+
mcpGateway;
|
|
599
|
+
constructor(store, mcpGateway) {
|
|
600
|
+
this.store = store;
|
|
601
|
+
this.mcpGateway = mcpGateway;
|
|
602
|
+
}
|
|
603
|
+
async dispatch(req, res, url) {
|
|
604
|
+
const { pathname: path } = url;
|
|
605
|
+
const { method } = req;
|
|
606
|
+
try {
|
|
607
|
+
// 1) Health endpoint bypasses auth (preserve existing behavior)
|
|
608
|
+
if (method === 'GET' && path === '/health') {
|
|
609
|
+
this.handleHealthCheck(res);
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
// 2) Auth required for everything else (preserve existing behavior)
|
|
613
|
+
if (!(await this.authenticateRequest(req, res)))
|
|
614
|
+
return;
|
|
615
|
+
// 3) Downloads
|
|
616
|
+
if (method === 'GET') {
|
|
617
|
+
const download = checkDownloadRoute(path);
|
|
618
|
+
if (download) {
|
|
619
|
+
handleDownload(res, download.namespace, download.hash);
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
// 4) MCP routes
|
|
624
|
+
if (path === '/mcp') {
|
|
625
|
+
if (await this.handleMcpRoutes(req, res, method)) {
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
res.status(404).json({ error: 'Not Found' });
|
|
630
|
+
}
|
|
631
|
+
catch (err) {
|
|
632
|
+
logError('Request failed', err instanceof Error ? err : new Error(String(err)));
|
|
633
|
+
if (!res.writableEnded) {
|
|
634
|
+
res.status(500).json({ error: 'Internal Server Error' });
|
|
635
|
+
}
|
|
575
636
|
}
|
|
576
637
|
}
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
activeSessions: ctx.store.size(),
|
|
592
|
-
cacheKeys: cacheKeys().length,
|
|
593
|
-
workerPool: poolStats ?? {
|
|
594
|
-
queueDepth: 0,
|
|
595
|
-
activeWorkers: 0,
|
|
596
|
-
capacity: 0,
|
|
597
|
-
},
|
|
638
|
+
handleHealthCheck(res) {
|
|
639
|
+
const poolStats = getTransformPoolStats();
|
|
640
|
+
res.status(200).json({
|
|
641
|
+
status: 'ok',
|
|
642
|
+
version: serverVersion,
|
|
643
|
+
uptime: Math.floor(process.uptime()),
|
|
644
|
+
timestamp: new Date().toISOString(),
|
|
645
|
+
stats: {
|
|
646
|
+
activeSessions: this.store.size(),
|
|
647
|
+
cacheKeys: cacheKeys().length,
|
|
648
|
+
workerPool: poolStats ?? {
|
|
649
|
+
queueDepth: 0,
|
|
650
|
+
activeWorkers: 0,
|
|
651
|
+
capacity: 0,
|
|
598
652
|
},
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
653
|
+
},
|
|
654
|
+
});
|
|
655
|
+
}
|
|
656
|
+
async handleMcpRoutes(req, res, method) {
|
|
657
|
+
if (method === 'POST') {
|
|
658
|
+
await this.mcpGateway.handlePost(req, res);
|
|
659
|
+
return true;
|
|
604
660
|
}
|
|
605
661
|
if (method === 'GET') {
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
handleDownload(res, download.namespace, download.hash);
|
|
609
|
-
return;
|
|
610
|
-
}
|
|
662
|
+
await this.mcpGateway.handleGet(req, res);
|
|
663
|
+
return true;
|
|
611
664
|
}
|
|
612
|
-
if (
|
|
613
|
-
|
|
614
|
-
|
|
665
|
+
if (method === 'DELETE') {
|
|
666
|
+
await this.mcpGateway.handleDelete(req, res);
|
|
667
|
+
return true;
|
|
668
|
+
}
|
|
669
|
+
return false;
|
|
615
670
|
}
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
671
|
+
async authenticateRequest(req, res) {
|
|
672
|
+
try {
|
|
673
|
+
req.auth = await authService.authenticate(req);
|
|
674
|
+
return true;
|
|
675
|
+
}
|
|
676
|
+
catch (err) {
|
|
677
|
+
res.status(401).json({
|
|
678
|
+
error: err instanceof Error ? err.message : 'Unauthorized',
|
|
679
|
+
});
|
|
680
|
+
return false;
|
|
620
681
|
}
|
|
621
682
|
}
|
|
622
683
|
}
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
684
|
+
/* -------------------------------------------------------------------------------------------------
|
|
685
|
+
* Request pipeline (order is part of behavior)
|
|
686
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
687
|
+
class HttpRequestPipeline {
|
|
688
|
+
rateLimiter;
|
|
689
|
+
dispatcher;
|
|
690
|
+
constructor(rateLimiter, dispatcher) {
|
|
691
|
+
this.rateLimiter = rateLimiter;
|
|
692
|
+
this.dispatcher = dispatcher;
|
|
693
|
+
}
|
|
694
|
+
async handle(rawReq, rawRes) {
|
|
695
|
+
const res = shimResponse(rawRes);
|
|
696
|
+
const req = rawReq;
|
|
697
|
+
// 1. Basic setup
|
|
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
|
|
709
|
+
try {
|
|
710
|
+
req.body = await jsonBodyReader.read(req);
|
|
711
|
+
}
|
|
712
|
+
catch {
|
|
713
|
+
res.status(400).json({ error: 'Invalid JSON or Payload too large' });
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
// 4. Rate limit
|
|
717
|
+
if (!this.rateLimiter.check(req, res))
|
|
718
|
+
return;
|
|
719
|
+
// 5. Dispatch
|
|
720
|
+
await this.dispatcher.dispatch(req, res, url);
|
|
633
721
|
}
|
|
634
722
|
}
|
|
635
|
-
|
|
723
|
+
/* -------------------------------------------------------------------------------------------------
|
|
724
|
+
* Server lifecycle
|
|
725
|
+
* ------------------------------------------------------------------------------------------------- */
|
|
636
726
|
export async function startHttpServer() {
|
|
637
727
|
assertHttpModeConfiguration();
|
|
638
728
|
enableHttpMode();
|
|
@@ -640,11 +730,11 @@ export async function startHttpServer() {
|
|
|
640
730
|
const rateLimiter = createRateLimitManagerImpl(config.rateLimit);
|
|
641
731
|
const sessionStore = createSessionStore(config.server.sessionTtlMs);
|
|
642
732
|
const sessionCleanup = startSessionCleanupLoop(sessionStore, config.server.sessionTtlMs);
|
|
733
|
+
const mcpGateway = new McpSessionGateway(sessionStore, mcpServer);
|
|
734
|
+
const dispatcher = new HttpDispatcher(sessionStore, mcpGateway);
|
|
735
|
+
const pipeline = new HttpRequestPipeline(rateLimiter, dispatcher);
|
|
643
736
|
const server = createServer((req, res) => {
|
|
644
|
-
void
|
|
645
|
-
store: sessionStore,
|
|
646
|
-
mcpServer,
|
|
647
|
-
});
|
|
737
|
+
void pipeline.handle(req, res);
|
|
648
738
|
});
|
|
649
739
|
applyHttpServerTuning(server);
|
|
650
740
|
await new Promise((resolve, reject) => {
|
|
@@ -678,32 +768,3 @@ export async function startHttpServer() {
|
|
|
678
768
|
},
|
|
679
769
|
};
|
|
680
770
|
}
|
|
681
|
-
async function handleRequest(rawReq, rawRes, rateLimiter, ctx) {
|
|
682
|
-
const res = shimResponse(rawRes);
|
|
683
|
-
const req = rawReq;
|
|
684
|
-
// 1. Basic Setup
|
|
685
|
-
const url = new URL(req.url ?? '', 'http://localhost');
|
|
686
|
-
req.query = parseQuery(url);
|
|
687
|
-
if (req.socket.remoteAddress) {
|
|
688
|
-
req.ip = req.socket.remoteAddress;
|
|
689
|
-
}
|
|
690
|
-
req.params = {};
|
|
691
|
-
// 2. CORS
|
|
692
|
-
if (!validateHostAndOrigin(req, res))
|
|
693
|
-
return;
|
|
694
|
-
if (handleCors(req, res))
|
|
695
|
-
return;
|
|
696
|
-
// 3. Body Parsing
|
|
697
|
-
try {
|
|
698
|
-
req.body = await readJsonBody(req);
|
|
699
|
-
}
|
|
700
|
-
catch {
|
|
701
|
-
res.status(400).json({ error: 'Invalid JSON or Payload too large' });
|
|
702
|
-
return;
|
|
703
|
-
}
|
|
704
|
-
// 4. Rate Limit
|
|
705
|
-
if (!rateLimiter.check(req, res))
|
|
706
|
-
return;
|
|
707
|
-
// 5. Routing
|
|
708
|
-
await dispatchRequest(req, res, url, ctx);
|
|
709
|
-
}
|