@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.
@@ -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
- // --- Body Parsing ---
76
- async function readJsonBody(req, limit = 1024 * 1024) {
77
- const contentType = req.headers['content-type'];
78
- if (!contentType?.includes('application/json'))
79
- return undefined;
80
- return new Promise((resolve, reject) => {
81
- let size = 0;
82
- const chunks = [];
83
- req.on('data', (chunk) => {
84
- size += chunk.length;
85
- if (size > limit) {
86
- req.destroy();
87
- reject(new Error('Payload too large'));
88
- return;
89
- }
90
- chunks.push(chunk);
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
- resolve(JSON.parse(body));
100
- }
101
- catch (err) {
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
- function resolveHostHeader(req) {
169
- const host = getHeaderValue(req, 'host');
170
- if (!host)
171
- return null;
172
- return normalizeHost(host);
173
- }
174
- function resolveOriginHost(origin) {
175
- if (origin === 'null')
176
- return null;
177
- try {
178
- const parsed = new URL(origin);
179
- return normalizeHost(parsed.host);
180
- }
181
- catch {
182
- return null;
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
- if (!ALLOWED_HOSTS.has(host)) {
195
- return rejectHostRequest(res, 403, 'Host not allowed');
195
+ resolveHostHeader(req) {
196
+ const host = getHeaderValue(req, 'host');
197
+ if (!host)
198
+ return null;
199
+ return normalizeHost(host);
196
200
  }
197
- const originHeader = getHeaderValue(req, 'origin');
198
- if (originHeader) {
199
- const originHost = resolveOriginHost(originHeader);
200
- if (!originHost) {
201
- return rejectHostRequest(res, 403, 'Invalid Origin header');
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
- if (!ALLOWED_HOSTS.has(originHost)) {
204
- return rejectHostRequest(res, 403, 'Origin not allowed');
208
+ catch {
209
+ return null;
205
210
  }
206
211
  }
207
- return true;
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
- function createRateLimitManagerImpl(options) {
227
- const store = new Map();
228
- const cleanup = new AbortController();
229
- const interval = setIntervalPromise(options.cleanupIntervalMs, Date.now, {
230
- signal: cleanup.signal,
231
- ref: false,
232
- });
233
- void (async () => {
234
- try {
235
- for await (const getNow of interval) {
236
- const now = getNow();
237
- for (const [key, entry] of store.entries()) {
238
- if (now - entry.lastAccessed > options.windowMs * 2) {
239
- store.delete(key);
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
- catch (err) {
245
- if (!isAbortError(err))
246
- logWarn('Rate limit cleanup failed', { error: err });
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
- return true;
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
- return response.json();
335
- }
336
- function buildIntrospectionAuthInfo(token, payload) {
337
- const expiresAt = typeof payload.exp === 'number' ? payload.exp : undefined;
338
- const clientId = typeof payload.client_id === 'string' ? payload.client_id : 'unknown';
339
- const info = {
340
- token,
341
- clientId,
342
- scopes: typeof payload.scope === 'string' ? payload.scope.split(' ') : [],
343
- resource: config.auth.resourceUrl,
344
- };
345
- if (expiresAt !== undefined) {
346
- info.expiresAt = expiresAt;
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
- return info;
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 authenticateWithToken(token) {
367
- return config.auth.mode === 'oauth'
368
- ? verifyWithIntrospection(token)
369
- : Promise.resolve(verifyStaticToken(token));
296
+ function createRateLimitManagerImpl(options) {
297
+ return new RateLimiter(options);
370
298
  }
371
- function authenticateWithApiKey(req) {
372
- const apiKey = getHeaderValue(req, 'x-api-key');
373
- if (apiKey && config.auth.mode === 'static') {
374
- return verifyStaticToken(apiKey);
375
- }
376
- if (apiKey && config.auth.mode === 'oauth') {
377
- throw new InvalidTokenError('X-API-Key not supported for OAuth');
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
- throw new InvalidTokenError('Missing Authorization header');
380
- }
381
- async function authenticate(req) {
382
- const authHeader = req.headers.authorization;
383
- if (!authHeader) {
384
- return authenticateWithApiKey(req);
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
- // --- MCP Routes ---
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 !== '2025-11-25') {
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
- async function createNewSession(store, mcpServer, res, requestId) {
410
- const allowed = ensureSessionCapacity({
411
- store,
412
- maxSessions: config.server.maxSessions,
413
- evictOldest: (s) => {
414
- const evicted = s.evictOldest();
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
- const tracker = createSlotTracker(store);
431
- const transportImpl = new StreamableHTTPServerTransport({
432
- sessionIdGenerator: () => randomUUID(),
433
- });
434
- const initTimeout = setTimeout(() => {
435
- if (!tracker.isInitialized()) {
436
- tracker.releaseSlot();
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
- }, config.server.sessionInitTimeoutMs);
440
- transportImpl.onclose = () => {
441
- clearTimeout(initTimeout);
442
- if (!tracker.isInitialized())
443
- tracker.releaseSlot();
444
- };
445
- try {
446
- const transport = createTransportAdapter(transportImpl);
447
- await mcpServer.connect(transport);
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
- catch (err) {
450
- clearTimeout(initTimeout);
451
- tracker.releaseSlot();
452
- void transportImpl.close().catch(() => { });
453
- throw err;
454
- }
455
- const newSessionId = transportImpl.sessionId;
456
- if (!newSessionId) {
457
- throw new ServerError('Failed to generate session ID');
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, requestId);
478
- return null;
476
+ sendError(res, -32600, 'Session not found', 404);
477
+ return;
479
478
  }
480
- store.touch(sessionId);
481
- return session.transport;
482
- }
483
- if (!isInitializeRequest(req.body)) {
484
- sendError(res, -32600, 'Missing session ID', 400, requestId);
485
- return null;
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
- if (method === 'DELETE') {
563
- await handleMcpDelete(req, res, ctx.store);
564
- return true;
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
- const namespace = downloadMatch[1];
572
- const hash = downloadMatch[2];
573
- if (namespace && hash) {
574
- return { namespace, hash };
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
- return null;
578
- }
579
- async function dispatchRequest(req, res, url, ctx) {
580
- const { pathname: path } = url;
581
- const { method } = req;
582
- try {
583
- if (method === 'GET' && path === '/health') {
584
- const poolStats = getTransformPoolStats();
585
- res.status(200).json({
586
- status: 'ok',
587
- version: serverVersion,
588
- uptime: Math.floor(process.uptime()),
589
- timestamp: new Date().toISOString(),
590
- stats: {
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
- return;
601
- }
602
- if (!(await authenticateRequest(req, res))) {
603
- return;
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
- const download = checkDownloadRoute(path);
607
- if (download) {
608
- handleDownload(res, download.namespace, download.hash);
609
- return;
610
- }
662
+ await this.mcpGateway.handleGet(req, res);
663
+ return true;
611
664
  }
612
- if (await routeMcpRequest(req, res, url, ctx))
613
- return;
614
- res.status(404).json({ error: 'Not Found' });
665
+ if (method === 'DELETE') {
666
+ await this.mcpGateway.handleDelete(req, res);
667
+ return true;
668
+ }
669
+ return false;
615
670
  }
616
- catch (err) {
617
- logError('Request failed', err instanceof Error ? err : new Error(String(err)));
618
- if (!res.writableEnded) {
619
- res.status(500).json({ error: 'Internal Server Error' });
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
- async function authenticateRequest(req, res) {
624
- try {
625
- req.auth = await authenticate(req);
626
- return true;
627
- }
628
- catch (err) {
629
- res
630
- .status(401)
631
- .json({ error: err instanceof Error ? err.message : 'Unauthorized' });
632
- return false;
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
- // --- Main ---
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 handleRequest(req, res, rateLimiter, {
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
- }