@j0hanz/superfetch 2.0.1 → 2.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +120 -38
- package/dist/cache.d.ts +42 -0
- package/dist/cache.js +565 -0
- package/dist/config/env-parsers.d.ts +1 -0
- package/dist/config/env-parsers.js +12 -0
- package/dist/config/index.d.ts +7 -0
- package/dist/config/index.js +10 -3
- package/dist/config/types/content.d.ts +1 -0
- package/dist/config.d.ts +77 -0
- package/dist/config.js +261 -0
- package/dist/crypto.d.ts +2 -0
- package/dist/crypto.js +32 -0
- package/dist/errors.d.ts +10 -0
- package/dist/errors.js +28 -0
- package/dist/fetch.d.ts +40 -0
- package/dist/fetch.js +910 -0
- package/dist/http/base-middleware.d.ts +7 -0
- package/dist/http/base-middleware.js +143 -0
- package/dist/http/cors.d.ts +0 -5
- package/dist/http/cors.js +0 -6
- package/dist/http/download-routes.js +6 -2
- package/dist/http/error-handler.d.ts +2 -0
- package/dist/http/error-handler.js +55 -0
- package/dist/http/mcp-routes.js +2 -2
- package/dist/http/mcp-sessions.d.ts +3 -5
- package/dist/http/mcp-sessions.js +8 -8
- package/dist/http/server-tuning.d.ts +9 -0
- package/dist/http/server-tuning.js +45 -0
- package/dist/http/server.d.ts +0 -10
- package/dist/http/server.js +33 -333
- package/dist/http.d.ts +78 -0
- package/dist/http.js +1437 -0
- package/dist/index.js +3 -3
- package/dist/mcp.d.ts +3 -0
- package/dist/mcp.js +94 -0
- package/dist/observability.d.ts +16 -0
- package/dist/observability.js +78 -0
- package/dist/server.js +20 -5
- package/dist/services/cache.d.ts +1 -1
- package/dist/services/context.d.ts +2 -0
- package/dist/services/context.js +3 -0
- package/dist/services/extractor.d.ts +1 -0
- package/dist/services/extractor.js +28 -2
- package/dist/services/fetcher.d.ts +2 -0
- package/dist/services/fetcher.js +35 -14
- package/dist/services/logger.js +4 -1
- package/dist/services/telemetry.d.ts +19 -0
- package/dist/services/telemetry.js +43 -0
- package/dist/services/transform-worker-pool.d.ts +10 -3
- package/dist/services/transform-worker-pool.js +213 -184
- package/dist/tools/handlers/fetch-url.tool.js +8 -6
- package/dist/tools/index.d.ts +1 -0
- package/dist/tools/index.js +13 -1
- package/dist/tools/schemas.d.ts +2 -0
- package/dist/tools/schemas.js +8 -0
- package/dist/tools/utils/content-transform-core.d.ts +5 -0
- package/dist/tools/utils/content-transform-core.js +180 -0
- package/dist/tools/utils/content-transform-workers.d.ts +1 -0
- package/dist/tools/utils/content-transform-workers.js +1 -0
- package/dist/tools/utils/content-transform.d.ts +3 -5
- package/dist/tools/utils/content-transform.js +35 -148
- package/dist/tools/utils/raw-markdown.js +15 -1
- package/dist/tools.d.ts +104 -0
- package/dist/tools.js +421 -0
- package/dist/transform.d.ts +69 -0
- package/dist/transform.js +1509 -0
- package/dist/transformers/markdown.d.ts +4 -1
- package/dist/transformers/markdown.js +182 -53
- package/dist/utils/cancellation.d.ts +1 -0
- package/dist/utils/cancellation.js +18 -0
- package/dist/utils/code-language.d.ts +0 -9
- package/dist/utils/code-language.js +5 -5
- package/dist/utils/host-normalizer.d.ts +1 -0
- package/dist/utils/host-normalizer.js +37 -0
- package/dist/utils/url-redactor.d.ts +1 -0
- package/dist/utils/url-redactor.js +13 -0
- package/dist/utils/url-validator.js +8 -5
- package/dist/workers/transform-worker.js +82 -38
- package/package.json +8 -7
package/dist/http.js
ADDED
|
@@ -0,0 +1,1437 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { isIP } from 'node:net';
|
|
3
|
+
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
4
|
+
import { z } from 'zod';
|
|
5
|
+
import { InvalidTokenError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
|
|
6
|
+
import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
|
|
7
|
+
import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, } from '@modelcontextprotocol/sdk/server/auth/router.js';
|
|
8
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
9
|
+
import { isInitializeRequest } from '@modelcontextprotocol/sdk/types.js';
|
|
10
|
+
import { registerDownloadRoutes } from './cache.js';
|
|
11
|
+
import { config, enableHttpMode } from './config.js';
|
|
12
|
+
import { timingSafeEqualUtf8 } from './crypto.js';
|
|
13
|
+
import { FetchError, getErrorMessage } from './errors.js';
|
|
14
|
+
import { destroyAgents } from './fetch.js';
|
|
15
|
+
import { createMcpServer } from './mcp.js';
|
|
16
|
+
import { logDebug, logError, logInfo, logWarn, runWithRequestContext, } from './observability.js';
|
|
17
|
+
import { shutdownTransformWorkerPool } from './transform.js';
|
|
18
|
+
function getRateLimitKey(req) {
|
|
19
|
+
return req.ip ?? req.socket.remoteAddress ?? 'unknown';
|
|
20
|
+
}
|
|
21
|
+
function createCleanupInterval(store, options) {
|
|
22
|
+
const controller = new AbortController();
|
|
23
|
+
void startCleanupLoop(store, options, controller.signal).catch(handleCleanupError);
|
|
24
|
+
return controller;
|
|
25
|
+
}
|
|
26
|
+
function createRateLimitMiddleware(options) {
|
|
27
|
+
const store = new Map();
|
|
28
|
+
const cleanupController = createCleanupInterval(store, options);
|
|
29
|
+
const stop = () => {
|
|
30
|
+
cleanupController.abort();
|
|
31
|
+
};
|
|
32
|
+
const middleware = createRateLimitHandler(store, options);
|
|
33
|
+
return { middleware, stop, store };
|
|
34
|
+
}
|
|
35
|
+
function createRateLimitHandler(store, options) {
|
|
36
|
+
return (req, res, next) => {
|
|
37
|
+
if (shouldSkipRateLimit(req, options)) {
|
|
38
|
+
next();
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
const now = Date.now();
|
|
42
|
+
const key = getRateLimitKey(req);
|
|
43
|
+
const resolution = resolveRateLimitEntry(store, key, now, options);
|
|
44
|
+
if (resolution.isNew) {
|
|
45
|
+
next();
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (handleRateLimitExceeded(res, resolution.entry, now, options)) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
next();
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
async function startCleanupLoop(store, options, signal) {
|
|
55
|
+
for await (const getNow of setIntervalPromise(options.cleanupIntervalMs, Date.now, { signal, ref: false })) {
|
|
56
|
+
evictStaleEntries(store, options, getNow());
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function evictStaleEntries(store, options, now) {
|
|
60
|
+
for (const [key, entry] of store.entries()) {
|
|
61
|
+
if (now - entry.lastAccessed > options.windowMs * 2) {
|
|
62
|
+
store.delete(key);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function isSessionAbortError(error) {
|
|
67
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
68
|
+
}
|
|
69
|
+
function handleCleanupError(error) {
|
|
70
|
+
if (isAbortError(error)) {
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
logWarn('Rate limit cleanup loop failed', {
|
|
74
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function shouldSkipRateLimit(req, options) {
|
|
78
|
+
return !options.enabled || req.method === 'OPTIONS';
|
|
79
|
+
}
|
|
80
|
+
function resolveRateLimitEntry(store, key, now, options) {
|
|
81
|
+
const existing = store.get(key);
|
|
82
|
+
if (!existing || now > existing.resetTime) {
|
|
83
|
+
const entry = createNewEntry(now, options);
|
|
84
|
+
store.set(key, entry);
|
|
85
|
+
return { entry, isNew: true };
|
|
86
|
+
}
|
|
87
|
+
updateEntry(existing, now);
|
|
88
|
+
return { entry: existing, isNew: false };
|
|
89
|
+
}
|
|
90
|
+
function createNewEntry(now, options) {
|
|
91
|
+
return {
|
|
92
|
+
count: 1,
|
|
93
|
+
resetTime: now + options.windowMs,
|
|
94
|
+
lastAccessed: now,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
function updateEntry(entry, now) {
|
|
98
|
+
entry.count += 1;
|
|
99
|
+
entry.lastAccessed = now;
|
|
100
|
+
}
|
|
101
|
+
function handleRateLimitExceeded(res, entry, now, options) {
|
|
102
|
+
if (entry.count <= options.maxRequests) {
|
|
103
|
+
return false;
|
|
104
|
+
}
|
|
105
|
+
const retryAfter = Math.max(1, Math.ceil((entry.resetTime - now) / 1000));
|
|
106
|
+
res.set('Retry-After', String(retryAfter));
|
|
107
|
+
res.status(429).json({
|
|
108
|
+
error: 'Rate limit exceeded',
|
|
109
|
+
retryAfter,
|
|
110
|
+
});
|
|
111
|
+
return true;
|
|
112
|
+
}
|
|
113
|
+
function assertHttpConfiguration() {
|
|
114
|
+
ensureBindAllowed();
|
|
115
|
+
ensureStaticTokens();
|
|
116
|
+
if (config.auth.mode === 'oauth') {
|
|
117
|
+
ensureOauthConfiguration();
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
function ensureBindAllowed() {
|
|
121
|
+
const isLoopback = ['127.0.0.1', '::1', 'localhost'].includes(config.server.host);
|
|
122
|
+
if (!config.security.allowRemote && !isLoopback) {
|
|
123
|
+
logError('Refusing to bind to non-loopback host without ALLOW_REMOTE=true', { host: config.server.host });
|
|
124
|
+
process.exit(1);
|
|
125
|
+
}
|
|
126
|
+
if (!isLoopback &&
|
|
127
|
+
config.security.allowRemote &&
|
|
128
|
+
config.auth.mode !== 'oauth') {
|
|
129
|
+
logError('Remote HTTP mode requires OAuth configuration; refusing to start');
|
|
130
|
+
process.exit(1);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
function ensureStaticTokens() {
|
|
134
|
+
if (config.auth.mode === 'static' && config.auth.staticTokens.length === 0) {
|
|
135
|
+
logError('At least one static access token is required for HTTP mode');
|
|
136
|
+
process.exit(1);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
function ensureOauthConfiguration() {
|
|
140
|
+
if (!config.auth.issuerUrl || !config.auth.authorizationUrl) {
|
|
141
|
+
logError('OAUTH_ISSUER_URL and OAUTH_AUTHORIZATION_URL are required for OAuth mode');
|
|
142
|
+
process.exit(1);
|
|
143
|
+
}
|
|
144
|
+
if (!config.auth.tokenUrl) {
|
|
145
|
+
logError('OAUTH_TOKEN_URL is required for OAuth mode');
|
|
146
|
+
process.exit(1);
|
|
147
|
+
}
|
|
148
|
+
if (!config.auth.introspectionUrl) {
|
|
149
|
+
logError('OAUTH_INTROSPECTION_URL is required for OAuth mode');
|
|
150
|
+
process.exit(1);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
154
|
+
let inFlight = null;
|
|
155
|
+
let initialSignal = null;
|
|
156
|
+
return (signal) => {
|
|
157
|
+
if (inFlight) {
|
|
158
|
+
logWarn('Shutdown already in progress; ignoring signal', {
|
|
159
|
+
signal,
|
|
160
|
+
initialSignal,
|
|
161
|
+
});
|
|
162
|
+
return inFlight;
|
|
163
|
+
}
|
|
164
|
+
initialSignal = signal;
|
|
165
|
+
inFlight = shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup).catch((error) => {
|
|
166
|
+
logError('Shutdown handler failed', error instanceof Error ? error : { error: getErrorMessage(error) });
|
|
167
|
+
throw error;
|
|
168
|
+
});
|
|
169
|
+
return inFlight;
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
async function shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
173
|
+
logInfo(`${signal} received, shutting down gracefully...`);
|
|
174
|
+
stopRateLimitCleanup();
|
|
175
|
+
sessionCleanupController.abort();
|
|
176
|
+
await closeSessions(sessionStore);
|
|
177
|
+
destroyAgents();
|
|
178
|
+
await shutdownTransformWorkerPool();
|
|
179
|
+
drainConnectionsOnShutdown(server);
|
|
180
|
+
closeServer(server);
|
|
181
|
+
scheduleForcedShutdown(10000);
|
|
182
|
+
}
|
|
183
|
+
async function closeSessions(sessionStore) {
|
|
184
|
+
const sessions = sessionStore.clear();
|
|
185
|
+
await Promise.allSettled(sessions.map((session) => session.transport.close().catch((error) => {
|
|
186
|
+
logWarn('Failed to close session during shutdown', {
|
|
187
|
+
error: getErrorMessage(error),
|
|
188
|
+
});
|
|
189
|
+
})));
|
|
190
|
+
}
|
|
191
|
+
function closeServer(server) {
|
|
192
|
+
server.close(() => {
|
|
193
|
+
logInfo('HTTP server closed');
|
|
194
|
+
process.exit(0);
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
function scheduleForcedShutdown(timeoutMs) {
|
|
198
|
+
setTimeout(() => {
|
|
199
|
+
logError('Forced shutdown after timeout');
|
|
200
|
+
process.exit(1);
|
|
201
|
+
}, timeoutMs).unref();
|
|
202
|
+
}
|
|
203
|
+
function registerSignalHandlers(shutdown) {
|
|
204
|
+
process.once('SIGINT', () => {
|
|
205
|
+
void shutdown('SIGINT');
|
|
206
|
+
});
|
|
207
|
+
process.once('SIGTERM', () => {
|
|
208
|
+
void shutdown('SIGTERM');
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
function startListening(app) {
|
|
212
|
+
return app
|
|
213
|
+
.listen(config.server.port, config.server.host, () => {
|
|
214
|
+
logInfo('superFetch MCP server started', {
|
|
215
|
+
host: config.server.host,
|
|
216
|
+
port: config.server.port,
|
|
217
|
+
});
|
|
218
|
+
const baseUrl = `http://${config.server.host}:${config.server.port}`;
|
|
219
|
+
logInfo(`superFetch MCP server running at ${baseUrl} (health: ${baseUrl}/health, mcp: ${baseUrl}/mcp)`);
|
|
220
|
+
logInfo('Run with --stdio flag for direct stdio integration');
|
|
221
|
+
})
|
|
222
|
+
.on('error', (err) => {
|
|
223
|
+
logError('Failed to start server', err);
|
|
224
|
+
process.exit(1);
|
|
225
|
+
});
|
|
226
|
+
}
|
|
227
|
+
function buildMiddleware() {
|
|
228
|
+
const { middleware: rateLimitMiddleware, stop: stopRateLimitCleanup } = createRateLimitMiddleware(config.rateLimit);
|
|
229
|
+
const authMiddleware = createAuthMiddleware();
|
|
230
|
+
// No CORS - MCP clients don't run in browsers
|
|
231
|
+
const corsMiddleware = createCorsMiddleware();
|
|
232
|
+
return {
|
|
233
|
+
rateLimitMiddleware,
|
|
234
|
+
stopRateLimitCleanup,
|
|
235
|
+
authMiddleware,
|
|
236
|
+
corsMiddleware,
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function createSessionInfrastructure() {
|
|
240
|
+
const sessionStore = createSessionStore(config.server.sessionTtlMs);
|
|
241
|
+
const sessionCleanupController = startSessionCleanupLoop(sessionStore, config.server.sessionTtlMs);
|
|
242
|
+
return { sessionStore, sessionCleanupController };
|
|
243
|
+
}
|
|
244
|
+
function registerHttpRoutes(app, sessionStore, authMiddleware) {
|
|
245
|
+
app.use('/mcp', authMiddleware);
|
|
246
|
+
app.use('/mcp/downloads', authMiddleware);
|
|
247
|
+
registerMcpRoutes(app, {
|
|
248
|
+
sessionStore,
|
|
249
|
+
maxSessions: config.server.maxSessions,
|
|
250
|
+
});
|
|
251
|
+
registerDownloadRoutes(app);
|
|
252
|
+
app.use(errorHandler);
|
|
253
|
+
}
|
|
254
|
+
function attachAuthMetadata(app) {
|
|
255
|
+
const authMetadataRouter = createAuthMetadataRouter();
|
|
256
|
+
if (authMetadataRouter) {
|
|
257
|
+
app.use(authMetadataRouter);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
async function buildServerContext() {
|
|
261
|
+
const { app, authMiddleware, stopRateLimitCleanup } = await createAppWithMiddleware();
|
|
262
|
+
const { sessionStore, sessionCleanupController } = attachSessionRoutes(app, authMiddleware);
|
|
263
|
+
return { app, sessionStore, sessionCleanupController, stopRateLimitCleanup };
|
|
264
|
+
}
|
|
265
|
+
async function createAppWithMiddleware() {
|
|
266
|
+
const { app, jsonParser } = await createExpressApp();
|
|
267
|
+
const { rateLimitMiddleware, stopRateLimitCleanup, authMiddleware, corsMiddleware, } = buildMiddleware();
|
|
268
|
+
attachBaseMiddleware({
|
|
269
|
+
app,
|
|
270
|
+
jsonParser,
|
|
271
|
+
rateLimitMiddleware,
|
|
272
|
+
corsMiddleware,
|
|
273
|
+
});
|
|
274
|
+
attachAuthMetadata(app);
|
|
275
|
+
assertHttpConfiguration();
|
|
276
|
+
return { app, authMiddleware, stopRateLimitCleanup };
|
|
277
|
+
}
|
|
278
|
+
function attachSessionRoutes(app, authMiddleware) {
|
|
279
|
+
const { sessionStore, sessionCleanupController } = createSessionInfrastructure();
|
|
280
|
+
registerHttpRoutes(app, sessionStore, authMiddleware);
|
|
281
|
+
return { sessionStore, sessionCleanupController };
|
|
282
|
+
}
|
|
283
|
+
export async function startHttpServer() {
|
|
284
|
+
enableHttpMode();
|
|
285
|
+
const { app, sessionStore, sessionCleanupController, stopRateLimitCleanup } = await buildServerContext();
|
|
286
|
+
const server = startListening(app);
|
|
287
|
+
applyHttpServerTuning(server);
|
|
288
|
+
const shutdown = createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
|
|
289
|
+
registerSignalHandlers(shutdown);
|
|
290
|
+
return { shutdown };
|
|
291
|
+
}
|
|
292
|
+
async function createExpressApp() {
|
|
293
|
+
const { default: express } = await import('express');
|
|
294
|
+
const app = express();
|
|
295
|
+
const jsonParser = express.json({ limit: '1mb' });
|
|
296
|
+
return { app, jsonParser };
|
|
297
|
+
}
|
|
298
|
+
function getStatusCode(fetchError) {
|
|
299
|
+
return fetchError ? fetchError.statusCode : 500;
|
|
300
|
+
}
|
|
301
|
+
function getErrorCode(fetchError) {
|
|
302
|
+
return fetchError ? fetchError.code : 'INTERNAL_ERROR';
|
|
303
|
+
}
|
|
304
|
+
function getFetchErrorMessage(fetchError) {
|
|
305
|
+
return fetchError ? fetchError.message : 'Internal Server Error';
|
|
306
|
+
}
|
|
307
|
+
function getErrorDetails(fetchError) {
|
|
308
|
+
if (fetchError && Object.keys(fetchError.details).length > 0) {
|
|
309
|
+
return fetchError.details;
|
|
310
|
+
}
|
|
311
|
+
return undefined;
|
|
312
|
+
}
|
|
313
|
+
function resolveRetryAfter(fetchError) {
|
|
314
|
+
if (fetchError?.statusCode !== 429)
|
|
315
|
+
return undefined;
|
|
316
|
+
const { retryAfter } = fetchError.details;
|
|
317
|
+
return isRetryAfterValue(retryAfter) ? String(retryAfter) : undefined;
|
|
318
|
+
}
|
|
319
|
+
function isRetryAfterValue(value) {
|
|
320
|
+
return typeof value === 'number' || typeof value === 'string';
|
|
321
|
+
}
|
|
322
|
+
function setRetryAfterHeader(res, fetchError) {
|
|
323
|
+
const retryAfter = resolveRetryAfter(fetchError);
|
|
324
|
+
if (retryAfter === undefined)
|
|
325
|
+
return;
|
|
326
|
+
res.set('Retry-After', retryAfter);
|
|
327
|
+
}
|
|
328
|
+
function buildErrorResponse(fetchError) {
|
|
329
|
+
const details = getErrorDetails(fetchError);
|
|
330
|
+
const response = {
|
|
331
|
+
error: {
|
|
332
|
+
message: getFetchErrorMessage(fetchError),
|
|
333
|
+
code: getErrorCode(fetchError),
|
|
334
|
+
statusCode: getStatusCode(fetchError),
|
|
335
|
+
...(details && { details }),
|
|
336
|
+
},
|
|
337
|
+
};
|
|
338
|
+
return response;
|
|
339
|
+
}
|
|
340
|
+
export function errorHandler(err, req, res, next) {
|
|
341
|
+
if (res.headersSent) {
|
|
342
|
+
next(err);
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
const fetchError = err instanceof FetchError ? err : null;
|
|
346
|
+
const statusCode = getStatusCode(fetchError);
|
|
347
|
+
logError(`HTTP ${statusCode}: ${err.message} - ${req.method} ${req.path}`, err);
|
|
348
|
+
setRetryAfterHeader(res, fetchError);
|
|
349
|
+
res.status(statusCode).json(buildErrorResponse(fetchError));
|
|
350
|
+
}
|
|
351
|
+
export function normalizeHost(value) {
|
|
352
|
+
const trimmed = value.trim().toLowerCase();
|
|
353
|
+
if (!trimmed)
|
|
354
|
+
return null;
|
|
355
|
+
const first = takeFirstHostValue(trimmed);
|
|
356
|
+
if (!first)
|
|
357
|
+
return null;
|
|
358
|
+
const ipv6 = stripIpv6Brackets(first);
|
|
359
|
+
if (ipv6)
|
|
360
|
+
return ipv6;
|
|
361
|
+
if (isIpV6Literal(first)) {
|
|
362
|
+
return first;
|
|
363
|
+
}
|
|
364
|
+
return stripPortIfPresent(first);
|
|
365
|
+
}
|
|
366
|
+
function takeFirstHostValue(value) {
|
|
367
|
+
const first = value.split(',')[0];
|
|
368
|
+
if (!first)
|
|
369
|
+
return null;
|
|
370
|
+
const trimmed = first.trim();
|
|
371
|
+
return trimmed ? trimmed : null;
|
|
372
|
+
}
|
|
373
|
+
function stripIpv6Brackets(value) {
|
|
374
|
+
if (!value.startsWith('['))
|
|
375
|
+
return null;
|
|
376
|
+
const end = value.indexOf(']');
|
|
377
|
+
if (end === -1)
|
|
378
|
+
return null;
|
|
379
|
+
return value.slice(1, end);
|
|
380
|
+
}
|
|
381
|
+
function stripPortIfPresent(value) {
|
|
382
|
+
const colonIndex = value.indexOf(':');
|
|
383
|
+
if (colonIndex === -1)
|
|
384
|
+
return value;
|
|
385
|
+
return value.slice(0, colonIndex);
|
|
386
|
+
}
|
|
387
|
+
function isIpV6Literal(value) {
|
|
388
|
+
return isIP(value) === 6;
|
|
389
|
+
}
|
|
390
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
391
|
+
function getNonEmptyStringHeader(value) {
|
|
392
|
+
if (typeof value !== 'string')
|
|
393
|
+
return null;
|
|
394
|
+
const trimmed = value.trim();
|
|
395
|
+
return trimmed === '' ? null : trimmed;
|
|
396
|
+
}
|
|
397
|
+
function respondHostNotAllowed(res) {
|
|
398
|
+
res.status(403).json({
|
|
399
|
+
error: 'Host not allowed',
|
|
400
|
+
code: 'HOST_NOT_ALLOWED',
|
|
401
|
+
});
|
|
402
|
+
}
|
|
403
|
+
function respondOriginNotAllowed(res) {
|
|
404
|
+
res.status(403).json({
|
|
405
|
+
error: 'Origin not allowed',
|
|
406
|
+
code: 'ORIGIN_NOT_ALLOWED',
|
|
407
|
+
});
|
|
408
|
+
}
|
|
409
|
+
function tryParseOriginHostname(originHeader) {
|
|
410
|
+
try {
|
|
411
|
+
return new URL(originHeader).hostname.toLowerCase();
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
return null;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
function isWildcardHost(host) {
|
|
418
|
+
return host === '0.0.0.0' || host === '::';
|
|
419
|
+
}
|
|
420
|
+
function addLoopbackHosts(allowedHosts) {
|
|
421
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
422
|
+
allowedHosts.add(host);
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
function addConfiguredHost(allowedHosts) {
|
|
426
|
+
const configuredHost = normalizeHost(config.server.host);
|
|
427
|
+
if (!configuredHost)
|
|
428
|
+
return;
|
|
429
|
+
if (isWildcardHost(configuredHost))
|
|
430
|
+
return;
|
|
431
|
+
allowedHosts.add(configuredHost);
|
|
432
|
+
}
|
|
433
|
+
function addExplicitAllowedHosts(allowedHosts) {
|
|
434
|
+
for (const host of config.security.allowedHosts) {
|
|
435
|
+
const normalized = normalizeHost(host);
|
|
436
|
+
if (!normalized) {
|
|
437
|
+
logDebug('Ignoring invalid allowed host entry', { host });
|
|
438
|
+
continue;
|
|
439
|
+
}
|
|
440
|
+
allowedHosts.add(normalized);
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
function buildAllowedHosts() {
|
|
444
|
+
const allowedHosts = new Set();
|
|
445
|
+
addLoopbackHosts(allowedHosts);
|
|
446
|
+
addConfiguredHost(allowedHosts);
|
|
447
|
+
addExplicitAllowedHosts(allowedHosts);
|
|
448
|
+
return allowedHosts;
|
|
449
|
+
}
|
|
450
|
+
function createHostValidationMiddleware() {
|
|
451
|
+
const allowedHosts = buildAllowedHosts();
|
|
452
|
+
return (req, res, next) => {
|
|
453
|
+
const hostHeader = typeof req.headers.host === 'string' ? req.headers.host : '';
|
|
454
|
+
const normalized = normalizeHost(hostHeader);
|
|
455
|
+
if (!normalized || !allowedHosts.has(normalized)) {
|
|
456
|
+
respondHostNotAllowed(res);
|
|
457
|
+
return;
|
|
458
|
+
}
|
|
459
|
+
next();
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
function createOriginValidationMiddleware() {
|
|
463
|
+
const allowedHosts = buildAllowedHosts();
|
|
464
|
+
return (req, res, next) => {
|
|
465
|
+
const originHeader = getNonEmptyStringHeader(req.headers.origin);
|
|
466
|
+
if (!originHeader) {
|
|
467
|
+
next();
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
const originHostname = tryParseOriginHostname(originHeader);
|
|
471
|
+
if (!originHostname || !allowedHosts.has(originHostname)) {
|
|
472
|
+
respondOriginNotAllowed(res);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
next();
|
|
476
|
+
};
|
|
477
|
+
}
|
|
478
|
+
function createJsonParseErrorHandler() {
|
|
479
|
+
return (err, _req, res, next) => {
|
|
480
|
+
if (err instanceof SyntaxError && 'body' in err) {
|
|
481
|
+
res.status(400).json({
|
|
482
|
+
jsonrpc: '2.0',
|
|
483
|
+
error: {
|
|
484
|
+
code: -32700,
|
|
485
|
+
message: 'Parse error: Invalid JSON',
|
|
486
|
+
},
|
|
487
|
+
id: null,
|
|
488
|
+
});
|
|
489
|
+
return;
|
|
490
|
+
}
|
|
491
|
+
next();
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
function createContextMiddleware() {
|
|
495
|
+
return (req, _res, next) => {
|
|
496
|
+
const requestId = randomUUID();
|
|
497
|
+
const sessionId = getSessionId(req);
|
|
498
|
+
const context = sessionId === undefined
|
|
499
|
+
? { requestId, operationId: requestId }
|
|
500
|
+
: { requestId, operationId: requestId, sessionId };
|
|
501
|
+
runWithRequestContext(context, () => {
|
|
502
|
+
next();
|
|
503
|
+
});
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
function registerHealthRoute(app) {
|
|
507
|
+
app.get('/health', (_req, res) => {
|
|
508
|
+
res.json({
|
|
509
|
+
status: 'healthy',
|
|
510
|
+
name: config.server.name,
|
|
511
|
+
version: config.server.version,
|
|
512
|
+
uptime: process.uptime(),
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
}
|
|
516
|
+
export function attachBaseMiddleware(options) {
|
|
517
|
+
const { app, jsonParser, rateLimitMiddleware, corsMiddleware } = options;
|
|
518
|
+
app.use(createHostValidationMiddleware());
|
|
519
|
+
app.use(createOriginValidationMiddleware());
|
|
520
|
+
app.use(jsonParser);
|
|
521
|
+
app.use(createContextMiddleware());
|
|
522
|
+
app.use(createJsonParseErrorHandler());
|
|
523
|
+
app.use(corsMiddleware);
|
|
524
|
+
app.use('/mcp', rateLimitMiddleware);
|
|
525
|
+
registerHealthRoute(app);
|
|
526
|
+
}
|
|
527
|
+
export function createCorsMiddleware() {
|
|
528
|
+
return (req, res, next) => {
|
|
529
|
+
if (req.method === 'OPTIONS') {
|
|
530
|
+
res.sendStatus(200);
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
next();
|
|
534
|
+
};
|
|
535
|
+
}
|
|
536
|
+
function isRecord(value) {
|
|
537
|
+
return typeof value === 'object' && value !== null;
|
|
538
|
+
}
|
|
539
|
+
function parseScopes(value) {
|
|
540
|
+
if (typeof value === 'string') {
|
|
541
|
+
return value
|
|
542
|
+
.split(' ')
|
|
543
|
+
.map((scope) => scope.trim())
|
|
544
|
+
.filter((scope) => scope.length > 0);
|
|
545
|
+
}
|
|
546
|
+
if (Array.isArray(value)) {
|
|
547
|
+
return value.filter((scope) => typeof scope === 'string');
|
|
548
|
+
}
|
|
549
|
+
return [];
|
|
550
|
+
}
|
|
551
|
+
function parseResourceUrl(value) {
|
|
552
|
+
if (typeof value !== 'string')
|
|
553
|
+
return undefined;
|
|
554
|
+
if (!URL.canParse(value))
|
|
555
|
+
return undefined;
|
|
556
|
+
return new URL(value);
|
|
557
|
+
}
|
|
558
|
+
function parseAudResource(aud) {
|
|
559
|
+
if (typeof aud === 'string') {
|
|
560
|
+
return parseResourceUrl(aud);
|
|
561
|
+
}
|
|
562
|
+
if (Array.isArray(aud)) {
|
|
563
|
+
for (const entry of aud) {
|
|
564
|
+
const parsed = parseResourceUrl(entry);
|
|
565
|
+
if (parsed)
|
|
566
|
+
return parsed;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
return undefined;
|
|
570
|
+
}
|
|
571
|
+
function extractResource(data) {
|
|
572
|
+
const resource = parseResourceUrl(data.resource);
|
|
573
|
+
if (resource)
|
|
574
|
+
return resource;
|
|
575
|
+
return parseAudResource(data.aud);
|
|
576
|
+
}
|
|
577
|
+
function extractScopes(data) {
|
|
578
|
+
if (data.scope !== undefined) {
|
|
579
|
+
return parseScopes(data.scope);
|
|
580
|
+
}
|
|
581
|
+
if (data.scopes !== undefined) {
|
|
582
|
+
return parseScopes(data.scopes);
|
|
583
|
+
}
|
|
584
|
+
if (data.scp !== undefined) {
|
|
585
|
+
return parseScopes(data.scp);
|
|
586
|
+
}
|
|
587
|
+
return [];
|
|
588
|
+
}
|
|
589
|
+
function readExpiresAt(data) {
|
|
590
|
+
const expiresAt = typeof data.exp === 'number' ? data.exp : Number.NaN;
|
|
591
|
+
if (!Number.isFinite(expiresAt)) {
|
|
592
|
+
throw new InvalidTokenError('Token has no expiration time');
|
|
593
|
+
}
|
|
594
|
+
return expiresAt;
|
|
595
|
+
}
|
|
596
|
+
function resolveClientId(data) {
|
|
597
|
+
if (typeof data.client_id === 'string')
|
|
598
|
+
return data.client_id;
|
|
599
|
+
if (typeof data.cid === 'string')
|
|
600
|
+
return data.cid;
|
|
601
|
+
if (typeof data.sub === 'string')
|
|
602
|
+
return data.sub;
|
|
603
|
+
return 'unknown';
|
|
604
|
+
}
|
|
605
|
+
function stripHash(url) {
|
|
606
|
+
const copy = new URL(url.href);
|
|
607
|
+
copy.hash = '';
|
|
608
|
+
return copy.href;
|
|
609
|
+
}
|
|
610
|
+
function ensureResourceMatch(resource) {
|
|
611
|
+
if (!resource) {
|
|
612
|
+
throw new InvalidTokenError('Token resource mismatch');
|
|
613
|
+
}
|
|
614
|
+
if (stripHash(resource) !== stripHash(config.auth.resourceUrl)) {
|
|
615
|
+
throw new InvalidTokenError('Token resource mismatch');
|
|
616
|
+
}
|
|
617
|
+
return resource;
|
|
618
|
+
}
|
|
619
|
+
function buildIntrospectionAuthInfo(token, data) {
|
|
620
|
+
const resource = ensureResourceMatch(extractResource(data));
|
|
621
|
+
return {
|
|
622
|
+
token,
|
|
623
|
+
clientId: resolveClientId(data),
|
|
624
|
+
scopes: extractScopes(data),
|
|
625
|
+
expiresAt: readExpiresAt(data),
|
|
626
|
+
resource,
|
|
627
|
+
extra: data,
|
|
628
|
+
};
|
|
629
|
+
}
|
|
630
|
+
function buildBasicAuthHeader(clientId, clientSecret) {
|
|
631
|
+
const secret = clientSecret ?? '';
|
|
632
|
+
const basic = Buffer.from(`${clientId}:${secret}`, 'utf8').toString('base64');
|
|
633
|
+
return `Basic ${basic}`;
|
|
634
|
+
}
|
|
635
|
+
function buildIntrospectionRequest(token, resourceUrl, clientId, clientSecret) {
|
|
636
|
+
const body = new URLSearchParams({
|
|
637
|
+
token,
|
|
638
|
+
token_type_hint: 'access_token',
|
|
639
|
+
resource: stripHash(resourceUrl),
|
|
640
|
+
}).toString();
|
|
641
|
+
const headers = {
|
|
642
|
+
'content-type': 'application/x-www-form-urlencoded',
|
|
643
|
+
};
|
|
644
|
+
if (clientId) {
|
|
645
|
+
headers.authorization = buildBasicAuthHeader(clientId, clientSecret);
|
|
646
|
+
}
|
|
647
|
+
return { body, headers };
|
|
648
|
+
}
|
|
649
|
+
async function requestIntrospection(introspectionUrl, request, timeoutMs) {
|
|
650
|
+
const response = await fetch(introspectionUrl, {
|
|
651
|
+
method: 'POST',
|
|
652
|
+
headers: request.headers,
|
|
653
|
+
body: request.body,
|
|
654
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
655
|
+
});
|
|
656
|
+
if (!response.ok) {
|
|
657
|
+
await response.body?.cancel();
|
|
658
|
+
throw new ServerError(`Token introspection failed: ${response.status}`);
|
|
659
|
+
}
|
|
660
|
+
return response.json();
|
|
661
|
+
}
|
|
662
|
+
function parseIntrospectionPayload(payload) {
|
|
663
|
+
if (!isRecord(payload) || Array.isArray(payload)) {
|
|
664
|
+
throw new ServerError('Invalid introspection response');
|
|
665
|
+
}
|
|
666
|
+
if (payload.active !== true) {
|
|
667
|
+
throw new InvalidTokenError('Token is inactive');
|
|
668
|
+
}
|
|
669
|
+
return payload;
|
|
670
|
+
}
|
|
671
|
+
async function verifyWithIntrospection(token) {
|
|
672
|
+
const { auth } = config;
|
|
673
|
+
if (!auth.introspectionUrl) {
|
|
674
|
+
throw new ServerError('Token introspection is not configured');
|
|
675
|
+
}
|
|
676
|
+
const request = buildIntrospectionRequest(token, auth.resourceUrl, auth.clientId, auth.clientSecret);
|
|
677
|
+
const payload = await requestIntrospection(auth.introspectionUrl, request, auth.introspectionTimeoutMs);
|
|
678
|
+
return buildIntrospectionAuthInfo(token, parseIntrospectionPayload(payload));
|
|
679
|
+
}
|
|
680
|
+
const STATIC_TOKEN_TTL_SECONDS = 60 * 60 * 24;
|
|
681
|
+
function buildStaticAuthInfo(token) {
|
|
682
|
+
return {
|
|
683
|
+
token,
|
|
684
|
+
clientId: 'static-token',
|
|
685
|
+
scopes: config.auth.requiredScopes,
|
|
686
|
+
expiresAt: Math.floor(Date.now() / 1000) + STATIC_TOKEN_TTL_SECONDS,
|
|
687
|
+
resource: config.auth.resourceUrl,
|
|
688
|
+
};
|
|
689
|
+
}
|
|
690
|
+
function verifyStaticToken(token) {
|
|
691
|
+
if (config.auth.staticTokens.length === 0) {
|
|
692
|
+
throw new InvalidTokenError('No static tokens configured');
|
|
693
|
+
}
|
|
694
|
+
const matched = config.auth.staticTokens.some((candidate) => timingSafeEqualUtf8(candidate, token));
|
|
695
|
+
if (!matched) {
|
|
696
|
+
throw new InvalidTokenError('Invalid token');
|
|
697
|
+
}
|
|
698
|
+
return buildStaticAuthInfo(token);
|
|
699
|
+
}
|
|
700
|
+
function normalizeHeaderValue(header) {
|
|
701
|
+
return Array.isArray(header) ? header[0] : header;
|
|
702
|
+
}
|
|
703
|
+
function getApiKeyHeader(req) {
|
|
704
|
+
const apiKeyHeader = normalizeHeaderValue(req.headers['x-api-key']);
|
|
705
|
+
return apiKeyHeader ? apiKeyHeader.trim() : null;
|
|
706
|
+
}
|
|
707
|
+
function createLegacyApiKeyMiddleware() {
|
|
708
|
+
return (req, _res, next) => {
|
|
709
|
+
if (config.auth.mode !== 'static') {
|
|
710
|
+
next();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
if (!req.headers.authorization) {
|
|
714
|
+
const apiKey = getApiKeyHeader(req);
|
|
715
|
+
if (apiKey) {
|
|
716
|
+
req.headers.authorization = `Bearer ${apiKey}`;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
next();
|
|
720
|
+
};
|
|
721
|
+
}
|
|
722
|
+
async function verifyAccessToken(token) {
|
|
723
|
+
if (config.auth.mode === 'oauth') {
|
|
724
|
+
return verifyWithIntrospection(token);
|
|
725
|
+
}
|
|
726
|
+
return verifyStaticToken(token);
|
|
727
|
+
}
|
|
728
|
+
function resolveMetadataUrl() {
|
|
729
|
+
if (config.auth.mode !== 'oauth')
|
|
730
|
+
return null;
|
|
731
|
+
return getOAuthProtectedResourceMetadataUrl(new URL(config.auth.resourceUrl));
|
|
732
|
+
}
|
|
733
|
+
function resolveOptionalScopes(requiredScopes) {
|
|
734
|
+
return requiredScopes.length > 0 ? [...requiredScopes] : undefined;
|
|
735
|
+
}
|
|
736
|
+
function resolveOAuthMetadataParams(authConfig) {
|
|
737
|
+
const { issuerUrl, authorizationUrl, tokenUrl, revocationUrl, registrationUrl, requiredScopes, } = authConfig;
|
|
738
|
+
if (!issuerUrl || !authorizationUrl || !tokenUrl)
|
|
739
|
+
return null;
|
|
740
|
+
return {
|
|
741
|
+
issuerUrl,
|
|
742
|
+
authorizationUrl,
|
|
743
|
+
tokenUrl,
|
|
744
|
+
revocationUrl,
|
|
745
|
+
registrationUrl,
|
|
746
|
+
requiredScopes,
|
|
747
|
+
};
|
|
748
|
+
}
|
|
749
|
+
function buildBaseOAuthMetadata(params) {
|
|
750
|
+
return {
|
|
751
|
+
issuer: params.issuerUrl.href,
|
|
752
|
+
authorization_endpoint: params.authorizationUrl.href,
|
|
753
|
+
response_types_supported: ['code'],
|
|
754
|
+
code_challenge_methods_supported: ['S256'],
|
|
755
|
+
token_endpoint: params.tokenUrl.href,
|
|
756
|
+
token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
|
|
757
|
+
grant_types_supported: ['authorization_code', 'refresh_token'],
|
|
758
|
+
};
|
|
759
|
+
}
|
|
760
|
+
function applyOptionalScopes(metadata, requiredScopes) {
|
|
761
|
+
const scopesSupported = resolveOptionalScopes(requiredScopes);
|
|
762
|
+
if (scopesSupported !== undefined) {
|
|
763
|
+
metadata.scopes_supported = scopesSupported;
|
|
764
|
+
}
|
|
765
|
+
}
|
|
766
|
+
function applyOptionalEndpoint(metadata, key, url) {
|
|
767
|
+
if (!url)
|
|
768
|
+
return;
|
|
769
|
+
metadata[key] = url.href;
|
|
770
|
+
}
|
|
771
|
+
function buildOAuthMetadata(params) {
|
|
772
|
+
const oauthMetadata = buildBaseOAuthMetadata(params);
|
|
773
|
+
applyOptionalScopes(oauthMetadata, params.requiredScopes);
|
|
774
|
+
applyOptionalEndpoint(oauthMetadata, 'revocation_endpoint', params.revocationUrl);
|
|
775
|
+
applyOptionalEndpoint(oauthMetadata, 'registration_endpoint', params.registrationUrl);
|
|
776
|
+
return oauthMetadata;
|
|
777
|
+
}
|
|
778
|
+
function createAuthMiddleware() {
|
|
779
|
+
const metadataUrl = resolveMetadataUrl();
|
|
780
|
+
const authHandler = requireBearerAuth({
|
|
781
|
+
verifier: { verifyAccessToken },
|
|
782
|
+
requiredScopes: config.auth.requiredScopes,
|
|
783
|
+
...(metadataUrl ? { resourceMetadataUrl: metadataUrl } : {}),
|
|
784
|
+
});
|
|
785
|
+
const legacyHandler = createLegacyApiKeyMiddleware();
|
|
786
|
+
return (req, res, next) => {
|
|
787
|
+
legacyHandler(req, res, () => {
|
|
788
|
+
authHandler(req, res, next);
|
|
789
|
+
});
|
|
790
|
+
};
|
|
791
|
+
}
|
|
792
|
+
function createAuthMetadataRouter() {
|
|
793
|
+
if (config.auth.mode !== 'oauth')
|
|
794
|
+
return null;
|
|
795
|
+
const oauthMetadataParams = resolveOAuthMetadataParams(config.auth);
|
|
796
|
+
if (!oauthMetadataParams)
|
|
797
|
+
return null;
|
|
798
|
+
return mcpAuthMetadataRouter({
|
|
799
|
+
oauthMetadata: buildOAuthMetadata(oauthMetadataParams),
|
|
800
|
+
resourceServerUrl: config.auth.resourceUrl,
|
|
801
|
+
scopesSupported: config.auth.requiredScopes,
|
|
802
|
+
resourceName: config.server.name,
|
|
803
|
+
});
|
|
804
|
+
}
|
|
805
|
+
function sendJsonRpcError(res, code, message, status = 400, id = null) {
|
|
806
|
+
res.status(status).json({
|
|
807
|
+
jsonrpc: '2.0',
|
|
808
|
+
error: {
|
|
809
|
+
code,
|
|
810
|
+
message,
|
|
811
|
+
},
|
|
812
|
+
id,
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
function getSessionId(req) {
|
|
816
|
+
const header = req.headers['mcp-session-id'];
|
|
817
|
+
return Array.isArray(header) ? header[0] : header;
|
|
818
|
+
}
|
|
819
|
+
export function createSessionStore(sessionTtlMs) {
|
|
820
|
+
const sessions = new Map();
|
|
821
|
+
return {
|
|
822
|
+
get: (sessionId) => sessions.get(sessionId),
|
|
823
|
+
touch: (sessionId) => {
|
|
824
|
+
touchSession(sessions, sessionId);
|
|
825
|
+
},
|
|
826
|
+
set: (sessionId, entry) => {
|
|
827
|
+
sessions.set(sessionId, entry);
|
|
828
|
+
},
|
|
829
|
+
remove: (sessionId) => removeSession(sessions, sessionId),
|
|
830
|
+
size: () => sessions.size,
|
|
831
|
+
clear: () => clearSessions(sessions),
|
|
832
|
+
evictExpired: () => evictExpiredSessions(sessions, sessionTtlMs),
|
|
833
|
+
evictOldest: () => evictOldestSession(sessions),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
function touchSession(sessions, sessionId) {
|
|
837
|
+
const session = sessions.get(sessionId);
|
|
838
|
+
if (session) {
|
|
839
|
+
session.lastSeen = Date.now();
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
function removeSession(sessions, sessionId) {
|
|
843
|
+
const session = sessions.get(sessionId);
|
|
844
|
+
sessions.delete(sessionId);
|
|
845
|
+
return session;
|
|
846
|
+
}
|
|
847
|
+
function clearSessions(sessions) {
|
|
848
|
+
const entries = Array.from(sessions.values());
|
|
849
|
+
sessions.clear();
|
|
850
|
+
return entries;
|
|
851
|
+
}
|
|
852
|
+
function evictExpiredSessions(sessions, sessionTtlMs) {
|
|
853
|
+
const now = Date.now();
|
|
854
|
+
const evicted = [];
|
|
855
|
+
for (const [id, session] of sessions.entries()) {
|
|
856
|
+
if (now - session.lastSeen > sessionTtlMs) {
|
|
857
|
+
sessions.delete(id);
|
|
858
|
+
evicted.push(session);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
return evicted;
|
|
862
|
+
}
|
|
863
|
+
function evictOldestSession(sessions) {
|
|
864
|
+
let oldestId;
|
|
865
|
+
let oldestSeen = Number.POSITIVE_INFINITY;
|
|
866
|
+
for (const [id, session] of sessions.entries()) {
|
|
867
|
+
if (session.lastSeen < oldestSeen) {
|
|
868
|
+
oldestSeen = session.lastSeen;
|
|
869
|
+
oldestId = id;
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
if (!oldestId)
|
|
873
|
+
return undefined;
|
|
874
|
+
const session = sessions.get(oldestId);
|
|
875
|
+
sessions.delete(oldestId);
|
|
876
|
+
return session;
|
|
877
|
+
}
|
|
878
|
+
let inFlightSessions = 0;
|
|
879
|
+
export function reserveSessionSlot(store, maxSessions) {
|
|
880
|
+
if (store.size() + inFlightSessions >= maxSessions) {
|
|
881
|
+
return false;
|
|
882
|
+
}
|
|
883
|
+
inFlightSessions += 1;
|
|
884
|
+
return true;
|
|
885
|
+
}
|
|
886
|
+
function releaseSessionSlot() {
|
|
887
|
+
if (inFlightSessions > 0) {
|
|
888
|
+
inFlightSessions -= 1;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
export function createSlotTracker() {
|
|
892
|
+
let slotReleased = false;
|
|
893
|
+
let initialized = false;
|
|
894
|
+
return {
|
|
895
|
+
releaseSlot: () => {
|
|
896
|
+
if (slotReleased)
|
|
897
|
+
return;
|
|
898
|
+
slotReleased = true;
|
|
899
|
+
releaseSessionSlot();
|
|
900
|
+
},
|
|
901
|
+
markInitialized: () => {
|
|
902
|
+
initialized = true;
|
|
903
|
+
},
|
|
904
|
+
isInitialized: () => initialized,
|
|
905
|
+
};
|
|
906
|
+
}
|
|
907
|
+
function isServerAtCapacity(store, maxSessions) {
|
|
908
|
+
return store.size() + inFlightSessions >= maxSessions;
|
|
909
|
+
}
|
|
910
|
+
function tryEvictSlot(store, maxSessions, evictOldest) {
|
|
911
|
+
const currentSize = store.size();
|
|
912
|
+
const canFreeSlot = currentSize >= maxSessions &&
|
|
913
|
+
currentSize - 1 + inFlightSessions < maxSessions;
|
|
914
|
+
return canFreeSlot && evictOldest(store);
|
|
915
|
+
}
|
|
916
|
+
export function ensureSessionCapacity({ store, maxSessions, res, evictOldest, }) {
|
|
917
|
+
if (!isServerAtCapacity(store, maxSessions)) {
|
|
918
|
+
return true;
|
|
919
|
+
}
|
|
920
|
+
if (tryEvictSlot(store, maxSessions, evictOldest)) {
|
|
921
|
+
return !isServerAtCapacity(store, maxSessions);
|
|
922
|
+
}
|
|
923
|
+
respondServerBusy(res);
|
|
924
|
+
return false;
|
|
925
|
+
}
|
|
926
|
+
function respondServerBusy(res) {
|
|
927
|
+
sendJsonRpcError(res, -32000, 'Server busy: maximum sessions reached', 503, null);
|
|
928
|
+
}
|
|
929
|
+
function respondBadRequest(res, id) {
|
|
930
|
+
sendJsonRpcError(res, -32000, 'Bad Request: Missing session ID or not an initialize request', 400, id);
|
|
931
|
+
}
|
|
932
|
+
function createTimeoutController() {
|
|
933
|
+
let initTimeout = null;
|
|
934
|
+
return {
|
|
935
|
+
clear: () => {
|
|
936
|
+
if (!initTimeout)
|
|
937
|
+
return;
|
|
938
|
+
clearTimeout(initTimeout);
|
|
939
|
+
initTimeout = null;
|
|
940
|
+
},
|
|
941
|
+
set: (timeout) => {
|
|
942
|
+
initTimeout = timeout;
|
|
943
|
+
},
|
|
944
|
+
};
|
|
945
|
+
}
|
|
946
|
+
function createTransportAdapter(transport) {
|
|
947
|
+
const adapter = buildTransportAdapter(transport);
|
|
948
|
+
attachTransportAccessors(adapter, transport);
|
|
949
|
+
return adapter;
|
|
950
|
+
}
|
|
951
|
+
function buildTransportAdapter(transport) {
|
|
952
|
+
return {
|
|
953
|
+
start: () => transport.start(),
|
|
954
|
+
send: (message, options) => transport.send(message, options),
|
|
955
|
+
close: () => transport.close(),
|
|
956
|
+
};
|
|
957
|
+
}
|
|
958
|
+
function createAccessorDescriptor(getter, setter) {
|
|
959
|
+
return {
|
|
960
|
+
get: getter,
|
|
961
|
+
...(setter ? { set: setter } : {}),
|
|
962
|
+
enumerable: true,
|
|
963
|
+
configurable: true,
|
|
964
|
+
};
|
|
965
|
+
}
|
|
966
|
+
function createOnCloseDescriptor(transport) {
|
|
967
|
+
return createAccessorDescriptor(() => transport.onclose, (handler) => {
|
|
968
|
+
transport.onclose = handler;
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
function createOnErrorDescriptor(transport) {
|
|
972
|
+
return createAccessorDescriptor(() => transport.onerror, (handler) => {
|
|
973
|
+
transport.onerror = handler;
|
|
974
|
+
});
|
|
975
|
+
}
|
|
976
|
+
function createOnMessageDescriptor(transport) {
|
|
977
|
+
return createAccessorDescriptor(() => transport.onmessage, (handler) => {
|
|
978
|
+
transport.onmessage = handler;
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
function attachTransportAccessors(adapter, transport) {
|
|
982
|
+
Object.defineProperties(adapter, {
|
|
983
|
+
onclose: createOnCloseDescriptor(transport),
|
|
984
|
+
onerror: createOnErrorDescriptor(transport),
|
|
985
|
+
onmessage: createOnMessageDescriptor(transport),
|
|
986
|
+
sessionId: createAccessorDescriptor(() => transport.sessionId),
|
|
987
|
+
});
|
|
988
|
+
}
|
|
989
|
+
function startSessionInitTimeout({ transport, tracker, clearInitTimeout, timeoutMs, }) {
|
|
990
|
+
if (timeoutMs <= 0)
|
|
991
|
+
return null;
|
|
992
|
+
const timeout = setTimeout(() => {
|
|
993
|
+
clearInitTimeout();
|
|
994
|
+
if (tracker.isInitialized())
|
|
995
|
+
return;
|
|
996
|
+
tracker.releaseSlot();
|
|
997
|
+
void transport.close().catch((error) => {
|
|
998
|
+
logWarn('Failed to close stalled session', {
|
|
999
|
+
error: getErrorMessage(error),
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
logWarn('Session initialization timed out', { timeoutMs });
|
|
1003
|
+
}, timeoutMs);
|
|
1004
|
+
timeout.unref();
|
|
1005
|
+
return timeout;
|
|
1006
|
+
}
|
|
1007
|
+
function createSessionTransport({ tracker, timeoutController, }) {
|
|
1008
|
+
const transport = new StreamableHTTPServerTransport({
|
|
1009
|
+
sessionIdGenerator: () => randomUUID(),
|
|
1010
|
+
});
|
|
1011
|
+
transport.onclose = () => {
|
|
1012
|
+
timeoutController.clear();
|
|
1013
|
+
if (!tracker.isInitialized()) {
|
|
1014
|
+
tracker.releaseSlot();
|
|
1015
|
+
}
|
|
1016
|
+
};
|
|
1017
|
+
timeoutController.set(startSessionInitTimeout({
|
|
1018
|
+
transport,
|
|
1019
|
+
tracker,
|
|
1020
|
+
clearInitTimeout: timeoutController.clear,
|
|
1021
|
+
timeoutMs: config.server.sessionInitTimeoutMs,
|
|
1022
|
+
}));
|
|
1023
|
+
return transport;
|
|
1024
|
+
}
|
|
1025
|
+
async function connectTransportOrThrow({ transport, clearInitTimeout, releaseSlot, }) {
|
|
1026
|
+
const mcpServer = createMcpServer();
|
|
1027
|
+
const transportAdapter = createTransportAdapter(transport);
|
|
1028
|
+
try {
|
|
1029
|
+
await mcpServer.connect(transportAdapter);
|
|
1030
|
+
}
|
|
1031
|
+
catch (error) {
|
|
1032
|
+
clearInitTimeout();
|
|
1033
|
+
releaseSlot();
|
|
1034
|
+
void transport.close().catch((closeError) => {
|
|
1035
|
+
logWarn('Failed to close transport after connect error', {
|
|
1036
|
+
error: getErrorMessage(closeError),
|
|
1037
|
+
});
|
|
1038
|
+
});
|
|
1039
|
+
logError('Failed to initialize MCP session', error instanceof Error ? error : undefined);
|
|
1040
|
+
throw error;
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
function evictExpiredSessionsWithClose(store) {
|
|
1044
|
+
const evicted = store.evictExpired();
|
|
1045
|
+
for (const session of evicted) {
|
|
1046
|
+
void session.transport.close().catch((error) => {
|
|
1047
|
+
logWarn('Failed to close expired session', {
|
|
1048
|
+
error: getErrorMessage(error),
|
|
1049
|
+
});
|
|
1050
|
+
});
|
|
1051
|
+
}
|
|
1052
|
+
return evicted.length;
|
|
1053
|
+
}
|
|
1054
|
+
function evictOldestSessionWithClose(store) {
|
|
1055
|
+
const session = store.evictOldest();
|
|
1056
|
+
if (!session)
|
|
1057
|
+
return false;
|
|
1058
|
+
void session.transport.close().catch((error) => {
|
|
1059
|
+
logWarn('Failed to close evicted session', {
|
|
1060
|
+
error: getErrorMessage(error),
|
|
1061
|
+
});
|
|
1062
|
+
});
|
|
1063
|
+
return true;
|
|
1064
|
+
}
|
|
1065
|
+
function reserveSessionIfPossible({ options, res, }) {
|
|
1066
|
+
if (!ensureSessionCapacity({
|
|
1067
|
+
store: options.sessionStore,
|
|
1068
|
+
maxSessions: options.maxSessions,
|
|
1069
|
+
res,
|
|
1070
|
+
evictOldest: evictOldestSessionWithClose,
|
|
1071
|
+
})) {
|
|
1072
|
+
return false;
|
|
1073
|
+
}
|
|
1074
|
+
if (!reserveSessionSlot(options.sessionStore, options.maxSessions)) {
|
|
1075
|
+
respondServerBusy(res);
|
|
1076
|
+
return false;
|
|
1077
|
+
}
|
|
1078
|
+
return true;
|
|
1079
|
+
}
|
|
1080
|
+
function resolveSessionId({ transport, res, tracker, clearInitTimeout, }) {
|
|
1081
|
+
const { sessionId } = transport;
|
|
1082
|
+
if (typeof sessionId !== 'string') {
|
|
1083
|
+
clearInitTimeout();
|
|
1084
|
+
tracker.releaseSlot();
|
|
1085
|
+
respondBadRequest(res, null);
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
return sessionId;
|
|
1089
|
+
}
|
|
1090
|
+
function finalizeSession({ store, transport, sessionId, tracker, clearInitTimeout, }) {
|
|
1091
|
+
clearInitTimeout();
|
|
1092
|
+
tracker.markInitialized();
|
|
1093
|
+
tracker.releaseSlot();
|
|
1094
|
+
const now = Date.now();
|
|
1095
|
+
store.set(sessionId, {
|
|
1096
|
+
transport,
|
|
1097
|
+
createdAt: now,
|
|
1098
|
+
lastSeen: now,
|
|
1099
|
+
});
|
|
1100
|
+
transport.onclose = () => {
|
|
1101
|
+
store.remove(sessionId);
|
|
1102
|
+
logInfo('Session closed');
|
|
1103
|
+
};
|
|
1104
|
+
logInfo('Session initialized');
|
|
1105
|
+
}
|
|
1106
|
+
async function createAndConnectTransport({ options, res, }) {
|
|
1107
|
+
if (!reserveSessionIfPossible({ options, res }))
|
|
1108
|
+
return null;
|
|
1109
|
+
const tracker = createSlotTracker();
|
|
1110
|
+
const timeoutController = createTimeoutController();
|
|
1111
|
+
const transport = createSessionTransport({ tracker, timeoutController });
|
|
1112
|
+
await connectTransportOrThrow({
|
|
1113
|
+
transport,
|
|
1114
|
+
clearInitTimeout: timeoutController.clear,
|
|
1115
|
+
releaseSlot: tracker.releaseSlot,
|
|
1116
|
+
});
|
|
1117
|
+
const sessionId = resolveSessionId({
|
|
1118
|
+
transport,
|
|
1119
|
+
res,
|
|
1120
|
+
tracker,
|
|
1121
|
+
clearInitTimeout: timeoutController.clear,
|
|
1122
|
+
});
|
|
1123
|
+
if (!sessionId)
|
|
1124
|
+
return null;
|
|
1125
|
+
finalizeSession({
|
|
1126
|
+
store: options.sessionStore,
|
|
1127
|
+
transport,
|
|
1128
|
+
sessionId,
|
|
1129
|
+
tracker,
|
|
1130
|
+
clearInitTimeout: timeoutController.clear,
|
|
1131
|
+
});
|
|
1132
|
+
return transport;
|
|
1133
|
+
}
|
|
1134
|
+
export async function resolveTransportForPost({ res, body, sessionId, options, }) {
|
|
1135
|
+
if (sessionId) {
|
|
1136
|
+
const existingSession = options.sessionStore.get(sessionId);
|
|
1137
|
+
if (existingSession) {
|
|
1138
|
+
options.sessionStore.touch(sessionId);
|
|
1139
|
+
return existingSession.transport;
|
|
1140
|
+
}
|
|
1141
|
+
// Client supplied a session id but it doesn't exist; Streamable HTTP: invalid session IDs => 404.
|
|
1142
|
+
sendJsonRpcError(res, -32600, 'Session not found', 404, body.id ?? null);
|
|
1143
|
+
return null;
|
|
1144
|
+
}
|
|
1145
|
+
if (!isInitializeRequest(body)) {
|
|
1146
|
+
respondBadRequest(res, body.id ?? null);
|
|
1147
|
+
return null;
|
|
1148
|
+
}
|
|
1149
|
+
evictExpiredSessionsWithClose(options.sessionStore);
|
|
1150
|
+
return createAndConnectTransport({ options, res });
|
|
1151
|
+
}
|
|
1152
|
+
function startSessionCleanupLoop(store, sessionTtlMs) {
|
|
1153
|
+
const controller = new AbortController();
|
|
1154
|
+
void runSessionCleanupLoop(store, sessionTtlMs, controller.signal).catch(handleSessionCleanupError);
|
|
1155
|
+
return controller;
|
|
1156
|
+
}
|
|
1157
|
+
async function runSessionCleanupLoop(store, sessionTtlMs, signal) {
|
|
1158
|
+
const intervalMs = getCleanupIntervalMs(sessionTtlMs);
|
|
1159
|
+
for await (const getNow of setIntervalPromise(intervalMs, Date.now, {
|
|
1160
|
+
signal,
|
|
1161
|
+
ref: false,
|
|
1162
|
+
})) {
|
|
1163
|
+
handleSessionEvictions(store, getNow());
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
function getCleanupIntervalMs(sessionTtlMs) {
|
|
1167
|
+
return Math.min(Math.max(Math.floor(sessionTtlMs / 2), 10000), 60000);
|
|
1168
|
+
}
|
|
1169
|
+
function isAbortError(error) {
|
|
1170
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
1171
|
+
}
|
|
1172
|
+
function handleSessionEvictions(store, now) {
|
|
1173
|
+
const evicted = evictExpiredSessionsWithClose(store);
|
|
1174
|
+
if (evicted > 0) {
|
|
1175
|
+
logInfo('Expired sessions evicted', {
|
|
1176
|
+
evicted,
|
|
1177
|
+
timestamp: new Date(now).toISOString(),
|
|
1178
|
+
});
|
|
1179
|
+
}
|
|
1180
|
+
}
|
|
1181
|
+
function handleSessionCleanupError(error) {
|
|
1182
|
+
if (isSessionAbortError(error)) {
|
|
1183
|
+
return;
|
|
1184
|
+
}
|
|
1185
|
+
logWarn('Session cleanup loop failed', {
|
|
1186
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
1187
|
+
});
|
|
1188
|
+
}
|
|
1189
|
+
const paramsSchema = z.looseObject({});
|
|
1190
|
+
const mcpRequestSchema = z.looseObject({
|
|
1191
|
+
jsonrpc: z.literal('2.0'),
|
|
1192
|
+
method: z.string().min(1),
|
|
1193
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
1194
|
+
params: paramsSchema.optional(),
|
|
1195
|
+
});
|
|
1196
|
+
function wrapAsync(fn) {
|
|
1197
|
+
return (req, res, next) => {
|
|
1198
|
+
Promise.resolve(fn(req, res)).catch(next);
|
|
1199
|
+
};
|
|
1200
|
+
}
|
|
1201
|
+
export function isJsonRpcBatchRequest(body) {
|
|
1202
|
+
return Array.isArray(body);
|
|
1203
|
+
}
|
|
1204
|
+
export function isMcpRequestBody(body) {
|
|
1205
|
+
return mcpRequestSchema.safeParse(body).success;
|
|
1206
|
+
}
|
|
1207
|
+
function respondInvalidRequestBody(res) {
|
|
1208
|
+
sendJsonRpcError(res, -32600, 'Invalid Request: Malformed request body', 400);
|
|
1209
|
+
}
|
|
1210
|
+
function respondMissingSession(res) {
|
|
1211
|
+
sendJsonRpcError(res, -32600, 'Missing mcp-session-id header', 400);
|
|
1212
|
+
}
|
|
1213
|
+
function respondSessionNotFound(res) {
|
|
1214
|
+
sendJsonRpcError(res, -32600, 'Session not found', 404);
|
|
1215
|
+
}
|
|
1216
|
+
function validatePostPayload(payload, res) {
|
|
1217
|
+
if (isJsonRpcBatchRequest(payload)) {
|
|
1218
|
+
sendJsonRpcError(res, -32600, 'Batch requests are not supported', 400);
|
|
1219
|
+
return null;
|
|
1220
|
+
}
|
|
1221
|
+
if (!isMcpRequestBody(payload)) {
|
|
1222
|
+
respondInvalidRequestBody(res);
|
|
1223
|
+
return null;
|
|
1224
|
+
}
|
|
1225
|
+
return payload;
|
|
1226
|
+
}
|
|
1227
|
+
function logPostRequest(body, sessionId, options) {
|
|
1228
|
+
logInfo('[MCP POST]', {
|
|
1229
|
+
method: body.method,
|
|
1230
|
+
id: body.id,
|
|
1231
|
+
isInitialize: body.method === 'initialize',
|
|
1232
|
+
sessionCount: options.sessionStore.size(),
|
|
1233
|
+
});
|
|
1234
|
+
}
|
|
1235
|
+
async function handleTransportRequest(transport, req, res, body) {
|
|
1236
|
+
try {
|
|
1237
|
+
await dispatchTransportRequest(transport, req, res, body);
|
|
1238
|
+
}
|
|
1239
|
+
catch (error) {
|
|
1240
|
+
logError('MCP request handling failed', error instanceof Error ? error : undefined);
|
|
1241
|
+
handleTransportError(res);
|
|
1242
|
+
}
|
|
1243
|
+
}
|
|
1244
|
+
function handleTransportError(res) {
|
|
1245
|
+
if (res.headersSent)
|
|
1246
|
+
return;
|
|
1247
|
+
res.status(500).json({ error: 'Internal Server Error' });
|
|
1248
|
+
}
|
|
1249
|
+
function dispatchTransportRequest(transport, req, res, body) {
|
|
1250
|
+
return body
|
|
1251
|
+
? transport.handleRequest(req, res, body)
|
|
1252
|
+
: transport.handleRequest(req, res);
|
|
1253
|
+
}
|
|
1254
|
+
function resolveSessionTransport(sessionId, options, res) {
|
|
1255
|
+
const { sessionStore } = options;
|
|
1256
|
+
if (!sessionId) {
|
|
1257
|
+
respondMissingSession(res);
|
|
1258
|
+
return null;
|
|
1259
|
+
}
|
|
1260
|
+
const session = sessionStore.get(sessionId);
|
|
1261
|
+
if (!session) {
|
|
1262
|
+
respondSessionNotFound(res);
|
|
1263
|
+
return null;
|
|
1264
|
+
}
|
|
1265
|
+
sessionStore.touch(sessionId);
|
|
1266
|
+
return session.transport;
|
|
1267
|
+
}
|
|
1268
|
+
const MCP_PROTOCOL_VERSION_HEADER = 'mcp-protocol-version';
|
|
1269
|
+
const MCP_PROTOCOL_VERSIONS = {
|
|
1270
|
+
defaultVersion: '2025-03-26',
|
|
1271
|
+
supported: new Set(['2025-11-25']),
|
|
1272
|
+
};
|
|
1273
|
+
function getHeaderValue(req, headerNameLower) {
|
|
1274
|
+
const value = req.headers[headerNameLower];
|
|
1275
|
+
if (typeof value === 'string')
|
|
1276
|
+
return value;
|
|
1277
|
+
if (Array.isArray(value))
|
|
1278
|
+
return value[0] ?? null;
|
|
1279
|
+
return null;
|
|
1280
|
+
}
|
|
1281
|
+
function setHeaderValue(req, headerNameLower, value) {
|
|
1282
|
+
// Express exposes req.headers as a plain object, but the type is readonly-ish.
|
|
1283
|
+
req.headers[headerNameLower] = value;
|
|
1284
|
+
}
|
|
1285
|
+
export function ensureMcpProtocolVersionHeader(req, res) {
|
|
1286
|
+
const raw = getHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER);
|
|
1287
|
+
const version = raw?.trim();
|
|
1288
|
+
if (!version) {
|
|
1289
|
+
const assumed = MCP_PROTOCOL_VERSIONS.defaultVersion;
|
|
1290
|
+
setHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER, assumed);
|
|
1291
|
+
if (!MCP_PROTOCOL_VERSIONS.supported.has(assumed)) {
|
|
1292
|
+
sendJsonRpcError(res, -32600, `Unsupported MCP-Protocol-Version: ${assumed}`, 400);
|
|
1293
|
+
return false;
|
|
1294
|
+
}
|
|
1295
|
+
return true;
|
|
1296
|
+
}
|
|
1297
|
+
if (!MCP_PROTOCOL_VERSIONS.supported.has(version)) {
|
|
1298
|
+
sendJsonRpcError(res, -32600, `Unsupported MCP-Protocol-Version: ${version}`, 400);
|
|
1299
|
+
return false;
|
|
1300
|
+
}
|
|
1301
|
+
return true;
|
|
1302
|
+
}
|
|
1303
|
+
function getAcceptHeader(req) {
|
|
1304
|
+
const value = req.headers.accept;
|
|
1305
|
+
if (typeof value === 'string')
|
|
1306
|
+
return value;
|
|
1307
|
+
return '';
|
|
1308
|
+
}
|
|
1309
|
+
function setAcceptHeader(req, value) {
|
|
1310
|
+
req.headers.accept = value;
|
|
1311
|
+
const { rawHeaders } = req;
|
|
1312
|
+
if (!Array.isArray(rawHeaders))
|
|
1313
|
+
return;
|
|
1314
|
+
for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
|
|
1315
|
+
const key = rawHeaders[i];
|
|
1316
|
+
if (typeof key === 'string' && key.toLowerCase() === 'accept') {
|
|
1317
|
+
rawHeaders[i + 1] = value;
|
|
1318
|
+
return;
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
rawHeaders.push('Accept', value);
|
|
1322
|
+
}
|
|
1323
|
+
function hasToken(header, token) {
|
|
1324
|
+
return header
|
|
1325
|
+
.split(',')
|
|
1326
|
+
.map((part) => part.trim().toLowerCase())
|
|
1327
|
+
.some((part) => part === token || part.startsWith(`${token};`));
|
|
1328
|
+
}
|
|
1329
|
+
export function ensurePostAcceptHeader(req) {
|
|
1330
|
+
const accept = getAcceptHeader(req);
|
|
1331
|
+
// Some clients send */* or omit Accept; the SDK transport is picky.
|
|
1332
|
+
if (!accept || hasToken(accept, '*/*')) {
|
|
1333
|
+
setAcceptHeader(req, 'application/json, text/event-stream');
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
const hasJson = hasToken(accept, 'application/json');
|
|
1337
|
+
const hasSse = hasToken(accept, 'text/event-stream');
|
|
1338
|
+
if (!hasJson || !hasSse) {
|
|
1339
|
+
setAcceptHeader(req, 'application/json, text/event-stream');
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
export function acceptsEventStream(req) {
|
|
1343
|
+
const accept = getAcceptHeader(req);
|
|
1344
|
+
if (!accept)
|
|
1345
|
+
return false;
|
|
1346
|
+
return hasToken(accept, 'text/event-stream');
|
|
1347
|
+
}
|
|
1348
|
+
async function handlePost(req, res, options) {
|
|
1349
|
+
ensurePostAcceptHeader(req);
|
|
1350
|
+
if (!ensureMcpProtocolVersionHeader(req, res))
|
|
1351
|
+
return;
|
|
1352
|
+
const sessionId = getSessionId(req);
|
|
1353
|
+
const payload = validatePostPayload(req.body, res);
|
|
1354
|
+
if (!payload)
|
|
1355
|
+
return;
|
|
1356
|
+
logPostRequest(payload, sessionId, options);
|
|
1357
|
+
const transport = await resolveTransportForPost({
|
|
1358
|
+
res,
|
|
1359
|
+
body: payload,
|
|
1360
|
+
sessionId,
|
|
1361
|
+
options,
|
|
1362
|
+
});
|
|
1363
|
+
if (!transport)
|
|
1364
|
+
return;
|
|
1365
|
+
await handleTransportRequest(transport, req, res, payload);
|
|
1366
|
+
}
|
|
1367
|
+
async function handleGet(req, res, options) {
|
|
1368
|
+
if (!ensureMcpProtocolVersionHeader(req, res))
|
|
1369
|
+
return;
|
|
1370
|
+
if (!acceptsEventStream(req)) {
|
|
1371
|
+
res.status(406).json({
|
|
1372
|
+
error: 'Not Acceptable',
|
|
1373
|
+
code: 'ACCEPT_NOT_SUPPORTED',
|
|
1374
|
+
});
|
|
1375
|
+
return;
|
|
1376
|
+
}
|
|
1377
|
+
const transport = resolveSessionTransport(getSessionId(req), options, res);
|
|
1378
|
+
if (!transport)
|
|
1379
|
+
return;
|
|
1380
|
+
await handleTransportRequest(transport, req, res);
|
|
1381
|
+
}
|
|
1382
|
+
async function handleDelete(req, res, options) {
|
|
1383
|
+
if (!ensureMcpProtocolVersionHeader(req, res))
|
|
1384
|
+
return;
|
|
1385
|
+
const transport = resolveSessionTransport(getSessionId(req), options, res);
|
|
1386
|
+
if (!transport)
|
|
1387
|
+
return;
|
|
1388
|
+
await handleTransportRequest(transport, req, res);
|
|
1389
|
+
}
|
|
1390
|
+
function registerMcpRoutes(app, options) {
|
|
1391
|
+
app.post('/mcp', wrapAsync((req, res) => handlePost(req, res, options)));
|
|
1392
|
+
app.get('/mcp', wrapAsync((req, res) => handleGet(req, res, options)));
|
|
1393
|
+
app.delete('/mcp', wrapAsync((req, res) => handleDelete(req, res, options)));
|
|
1394
|
+
}
|
|
1395
|
+
export function applyHttpServerTuning(server) {
|
|
1396
|
+
const { headersTimeoutMs, requestTimeoutMs, keepAliveTimeoutMs } = config.server.http;
|
|
1397
|
+
if (headersTimeoutMs !== undefined) {
|
|
1398
|
+
server.headersTimeout = headersTimeoutMs;
|
|
1399
|
+
}
|
|
1400
|
+
if (requestTimeoutMs !== undefined) {
|
|
1401
|
+
server.requestTimeout = requestTimeoutMs;
|
|
1402
|
+
}
|
|
1403
|
+
if (keepAliveTimeoutMs !== undefined) {
|
|
1404
|
+
server.keepAliveTimeout = keepAliveTimeoutMs;
|
|
1405
|
+
}
|
|
1406
|
+
if (headersTimeoutMs !== undefined ||
|
|
1407
|
+
requestTimeoutMs !== undefined ||
|
|
1408
|
+
keepAliveTimeoutMs !== undefined) {
|
|
1409
|
+
logDebug('Applied HTTP server tuning', {
|
|
1410
|
+
headersTimeoutMs,
|
|
1411
|
+
requestTimeoutMs,
|
|
1412
|
+
keepAliveTimeoutMs,
|
|
1413
|
+
});
|
|
1414
|
+
}
|
|
1415
|
+
}
|
|
1416
|
+
export function drainConnectionsOnShutdown(server) {
|
|
1417
|
+
const { shutdownCloseAllConnections, shutdownCloseIdleConnections } = config.server.http;
|
|
1418
|
+
if (shutdownCloseAllConnections) {
|
|
1419
|
+
if (typeof server.closeAllConnections === 'function') {
|
|
1420
|
+
server.closeAllConnections();
|
|
1421
|
+
logDebug('Closed all HTTP connections during shutdown');
|
|
1422
|
+
}
|
|
1423
|
+
else {
|
|
1424
|
+
logDebug('HTTP server does not support closeAllConnections()');
|
|
1425
|
+
}
|
|
1426
|
+
return;
|
|
1427
|
+
}
|
|
1428
|
+
if (shutdownCloseIdleConnections) {
|
|
1429
|
+
if (typeof server.closeIdleConnections === 'function') {
|
|
1430
|
+
server.closeIdleConnections();
|
|
1431
|
+
logDebug('Closed idle HTTP connections during shutdown');
|
|
1432
|
+
}
|
|
1433
|
+
else {
|
|
1434
|
+
logDebug('HTTP server does not support closeIdleConnections()');
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|