@j0hanz/superfetch 2.0.0 → 2.0.1
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 +28 -17
- package/dist/config/index.js +11 -6
- package/dist/http/auth.js +161 -2
- package/dist/http/host-allowlist.d.ts +3 -0
- package/dist/http/host-allowlist.js +117 -0
- package/dist/http/mcp-routes.d.ts +8 -2
- package/dist/http/mcp-routes.js +101 -8
- package/dist/http/mcp-session-eviction.d.ts +3 -0
- package/dist/http/mcp-session-eviction.js +24 -0
- package/dist/http/mcp-session-init.d.ts +7 -0
- package/dist/http/mcp-session-init.js +94 -0
- package/dist/http/mcp-session-slots.d.ts +17 -0
- package/dist/http/mcp-session-slots.js +55 -0
- package/dist/http/mcp-session-transport-init.d.ts +7 -0
- package/dist/http/mcp-session-transport-init.js +41 -0
- package/dist/http/mcp-session-types.d.ts +5 -0
- package/dist/http/mcp-session-types.js +1 -0
- package/dist/http/mcp-session.d.ts +9 -9
- package/dist/http/mcp-session.js +5 -114
- package/dist/http/mcp-sessions.d.ts +43 -0
- package/dist/http/mcp-sessions.js +392 -0
- package/dist/http/rate-limit.js +2 -2
- package/dist/http/server-middleware.d.ts +6 -1
- package/dist/http/server-middleware.js +3 -117
- package/dist/http/server-shutdown.js +1 -1
- package/dist/http/server.d.ts +10 -0
- package/dist/http/server.js +508 -11
- package/dist/http/session-cleanup.js +8 -5
- package/dist/middleware/error-handler.d.ts +1 -1
- package/dist/middleware/error-handler.js +31 -30
- package/dist/resources/cached-content-params.d.ts +5 -0
- package/dist/resources/cached-content-params.js +36 -0
- package/dist/resources/cached-content.js +33 -33
- package/dist/server.js +1 -1
- package/dist/services/cache-events.d.ts +8 -0
- package/dist/services/cache-events.js +19 -0
- package/dist/services/cache.d.ts +5 -4
- package/dist/services/cache.js +49 -45
- package/dist/services/extractor.js +49 -38
- package/dist/services/fetcher/agents.js +1 -1
- package/dist/services/fetcher/dns-selection.js +1 -1
- package/dist/services/fetcher/interceptors.js +29 -60
- package/dist/services/fetcher/redirects.js +12 -4
- package/dist/services/fetcher/response.js +18 -8
- package/dist/services/fetcher.d.ts +21 -0
- package/dist/services/fetcher.js +532 -13
- package/dist/tools/handlers/fetch-single.shared.d.ts +11 -3
- package/dist/tools/handlers/fetch-single.shared.js +131 -2
- package/dist/tools/handlers/fetch-url.tool.d.ts +6 -0
- package/dist/tools/handlers/fetch-url.tool.js +48 -6
- package/dist/tools/utils/content-shaping.js +19 -4
- package/dist/tools/utils/content-transform.d.ts +4 -1
- package/dist/tools/utils/content-transform.js +110 -96
- package/dist/tools/utils/fetch-pipeline.js +47 -56
- package/dist/tools/utils/frontmatter.d.ts +3 -0
- package/dist/tools/utils/frontmatter.js +73 -0
- package/dist/tools/utils/markdown-heuristics.d.ts +1 -0
- package/dist/tools/utils/markdown-heuristics.js +19 -0
- package/dist/tools/utils/markdown-signals.d.ts +1 -0
- package/dist/tools/utils/markdown-signals.js +19 -0
- package/dist/tools/utils/raw-markdown-frontmatter.d.ts +3 -0
- package/dist/tools/utils/raw-markdown-frontmatter.js +73 -0
- package/dist/tools/utils/raw-markdown.d.ts +6 -0
- package/dist/tools/utils/raw-markdown.js +135 -0
- package/dist/transformers/markdown/fenced-code-rule.d.ts +2 -0
- package/dist/transformers/markdown/fenced-code-rule.js +38 -0
- package/dist/transformers/markdown/frontmatter.d.ts +2 -0
- package/dist/transformers/markdown/frontmatter.js +45 -0
- package/dist/transformers/markdown/noise-rule.d.ts +2 -0
- package/dist/transformers/markdown/noise-rule.js +80 -0
- package/dist/transformers/markdown/turndown-instance.d.ts +2 -0
- package/dist/transformers/markdown/turndown-instance.js +19 -0
- package/dist/transformers/markdown.d.ts +2 -0
- package/dist/transformers/markdown.js +185 -0
- package/dist/transformers/markdown.transformer.js +2 -189
- package/dist/utils/code-language-bash.d.ts +1 -0
- package/dist/utils/code-language-bash.js +48 -0
- package/dist/utils/code-language-core.d.ts +2 -0
- package/dist/utils/code-language-core.js +13 -0
- package/dist/utils/code-language-detectors.d.ts +5 -0
- package/dist/utils/code-language-detectors.js +142 -0
- package/dist/utils/code-language-helpers.d.ts +5 -0
- package/dist/utils/code-language-helpers.js +62 -0
- package/dist/utils/code-language-parsing.d.ts +5 -0
- package/dist/utils/code-language-parsing.js +62 -0
- package/dist/utils/code-language.d.ts +9 -0
- package/dist/utils/code-language.js +250 -46
- package/dist/utils/error-details.d.ts +3 -0
- package/dist/utils/error-details.js +12 -0
- package/dist/utils/filename-generator.js +14 -3
- package/dist/utils/ip-address.d.ts +4 -0
- package/dist/utils/ip-address.js +6 -0
- package/dist/utils/tool-error-handler.js +12 -17
- package/dist/utils/url-validator.js +33 -21
- package/package.json +7 -5
package/dist/http/server.js
CHANGED
|
@@ -1,16 +1,508 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
1
3
|
import { config, enableHttpMode } from '../config/index.js';
|
|
2
|
-
import {
|
|
3
|
-
import
|
|
4
|
+
import { FetchError } from '../errors/app-error.js';
|
|
5
|
+
import * as cache from '../services/cache.js';
|
|
6
|
+
import { runWithRequestContext } from '../services/context.js';
|
|
7
|
+
import { destroyAgents } from '../services/fetcher.js';
|
|
8
|
+
import { logDebug, logError, logInfo, logWarn } from '../services/logger.js';
|
|
9
|
+
import { parseCachedPayload, resolveCachedPayloadContent, } from '../utils/cached-payload.js';
|
|
10
|
+
import { getErrorMessage } from '../utils/error-details.js';
|
|
11
|
+
import { generateSafeFilename } from '../utils/filename-generator.js';
|
|
4
12
|
import { createAuthMetadataRouter, createAuthMiddleware } from './auth.js';
|
|
5
|
-
import { createCorsMiddleware } from './cors.js';
|
|
6
|
-
import { registerDownloadRoutes } from './download-routes.js';
|
|
7
13
|
import { registerMcpRoutes } from './mcp-routes.js';
|
|
8
|
-
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
+
import { createSessionStore, getSessionId, startSessionCleanupLoop, } from './mcp-sessions.js';
|
|
15
|
+
function getRateLimitKey(req) {
|
|
16
|
+
return req.ip ?? req.socket.remoteAddress ?? 'unknown';
|
|
17
|
+
}
|
|
18
|
+
function createCleanupInterval(store, options) {
|
|
19
|
+
const controller = new AbortController();
|
|
20
|
+
void startCleanupLoop(store, options, controller.signal).catch(handleCleanupError);
|
|
21
|
+
return controller;
|
|
22
|
+
}
|
|
23
|
+
function createRateLimitMiddleware(options) {
|
|
24
|
+
const store = new Map();
|
|
25
|
+
const cleanupController = createCleanupInterval(store, options);
|
|
26
|
+
const stop = () => {
|
|
27
|
+
cleanupController.abort();
|
|
28
|
+
};
|
|
29
|
+
const middleware = createRateLimitHandler(store, options);
|
|
30
|
+
return { middleware, stop, store };
|
|
31
|
+
}
|
|
32
|
+
function createRateLimitHandler(store, options) {
|
|
33
|
+
return (req, res, next) => {
|
|
34
|
+
if (shouldSkipRateLimit(req, options)) {
|
|
35
|
+
next();
|
|
36
|
+
return;
|
|
37
|
+
}
|
|
38
|
+
const now = Date.now();
|
|
39
|
+
const key = getRateLimitKey(req);
|
|
40
|
+
const resolution = resolveRateLimitEntry(store, key, now, options);
|
|
41
|
+
if (resolution.isNew) {
|
|
42
|
+
next();
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
if (handleRateLimitExceeded(res, resolution.entry, now, options)) {
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
next();
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
async function startCleanupLoop(store, options, signal) {
|
|
52
|
+
for await (const getNow of setIntervalPromise(options.cleanupIntervalMs, Date.now, { signal, ref: false })) {
|
|
53
|
+
evictStaleEntries(store, options, getNow());
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
function evictStaleEntries(store, options, now) {
|
|
57
|
+
for (const [key, entry] of store.entries()) {
|
|
58
|
+
if (now - entry.lastAccessed > options.windowMs * 2) {
|
|
59
|
+
store.delete(key);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
function isAbortError(error) {
|
|
64
|
+
return error instanceof Error && error.name === 'AbortError';
|
|
65
|
+
}
|
|
66
|
+
function handleCleanupError(error) {
|
|
67
|
+
if (isAbortError(error)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
function shouldSkipRateLimit(req, options) {
|
|
72
|
+
return !options.enabled || req.method === 'OPTIONS';
|
|
73
|
+
}
|
|
74
|
+
function resolveRateLimitEntry(store, key, now, options) {
|
|
75
|
+
const existing = store.get(key);
|
|
76
|
+
if (!existing || now > existing.resetTime) {
|
|
77
|
+
const entry = createNewEntry(now, options);
|
|
78
|
+
store.set(key, entry);
|
|
79
|
+
return { entry, isNew: true };
|
|
80
|
+
}
|
|
81
|
+
updateEntry(existing, now);
|
|
82
|
+
return { entry: existing, isNew: false };
|
|
83
|
+
}
|
|
84
|
+
function createNewEntry(now, options) {
|
|
85
|
+
return {
|
|
86
|
+
count: 1,
|
|
87
|
+
resetTime: now + options.windowMs,
|
|
88
|
+
lastAccessed: now,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
function updateEntry(entry, now) {
|
|
92
|
+
entry.count += 1;
|
|
93
|
+
entry.lastAccessed = now;
|
|
94
|
+
}
|
|
95
|
+
function handleRateLimitExceeded(res, entry, now, options) {
|
|
96
|
+
if (entry.count <= options.maxRequests) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
const retryAfter = Math.max(1, Math.ceil((entry.resetTime - now) / 1000));
|
|
100
|
+
res.set('Retry-After', String(retryAfter));
|
|
101
|
+
res.status(429).json({
|
|
102
|
+
error: 'Rate limit exceeded',
|
|
103
|
+
retryAfter,
|
|
104
|
+
});
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
108
|
+
function getNonEmptyStringHeader(value) {
|
|
109
|
+
if (typeof value !== 'string')
|
|
110
|
+
return null;
|
|
111
|
+
const trimmed = value.trim();
|
|
112
|
+
return trimmed === '' ? null : trimmed;
|
|
113
|
+
}
|
|
114
|
+
function respondHostNotAllowed(res) {
|
|
115
|
+
res.status(403).json({
|
|
116
|
+
error: 'Host not allowed',
|
|
117
|
+
code: 'HOST_NOT_ALLOWED',
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
function respondOriginNotAllowed(res) {
|
|
121
|
+
res.status(403).json({
|
|
122
|
+
error: 'Origin not allowed',
|
|
123
|
+
code: 'ORIGIN_NOT_ALLOWED',
|
|
124
|
+
});
|
|
125
|
+
}
|
|
126
|
+
function tryParseOriginHostname(originHeader) {
|
|
127
|
+
try {
|
|
128
|
+
return new URL(originHeader).hostname.toLowerCase();
|
|
129
|
+
}
|
|
130
|
+
catch {
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
function takeFirstHostValue(value) {
|
|
135
|
+
const first = value.split(',')[0];
|
|
136
|
+
if (!first)
|
|
137
|
+
return null;
|
|
138
|
+
const trimmed = first.trim();
|
|
139
|
+
return trimmed ? trimmed : null;
|
|
140
|
+
}
|
|
141
|
+
function stripIpv6Brackets(value) {
|
|
142
|
+
if (!value.startsWith('['))
|
|
143
|
+
return null;
|
|
144
|
+
const end = value.indexOf(']');
|
|
145
|
+
if (end === -1)
|
|
146
|
+
return null;
|
|
147
|
+
return value.slice(1, end);
|
|
148
|
+
}
|
|
149
|
+
function stripPortIfPresent(value) {
|
|
150
|
+
const colonIndex = value.indexOf(':');
|
|
151
|
+
if (colonIndex === -1)
|
|
152
|
+
return value;
|
|
153
|
+
return value.slice(0, colonIndex);
|
|
154
|
+
}
|
|
155
|
+
function normalizeHost(value) {
|
|
156
|
+
const trimmed = value.trim().toLowerCase();
|
|
157
|
+
if (!trimmed)
|
|
158
|
+
return null;
|
|
159
|
+
const first = takeFirstHostValue(trimmed);
|
|
160
|
+
if (!first)
|
|
161
|
+
return null;
|
|
162
|
+
const ipv6 = stripIpv6Brackets(first);
|
|
163
|
+
if (ipv6)
|
|
164
|
+
return ipv6;
|
|
165
|
+
return stripPortIfPresent(first);
|
|
166
|
+
}
|
|
167
|
+
function isWildcardHost(host) {
|
|
168
|
+
return host === '0.0.0.0' || host === '::';
|
|
169
|
+
}
|
|
170
|
+
function addLoopbackHosts(allowedHosts) {
|
|
171
|
+
for (const host of LOOPBACK_HOSTS) {
|
|
172
|
+
allowedHosts.add(host);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
function addConfiguredHost(allowedHosts) {
|
|
176
|
+
const configuredHost = normalizeHost(config.server.host);
|
|
177
|
+
if (!configuredHost)
|
|
178
|
+
return;
|
|
179
|
+
if (isWildcardHost(configuredHost))
|
|
180
|
+
return;
|
|
181
|
+
allowedHosts.add(configuredHost);
|
|
182
|
+
}
|
|
183
|
+
function addExplicitAllowedHosts(allowedHosts) {
|
|
184
|
+
for (const host of config.security.allowedHosts) {
|
|
185
|
+
allowedHosts.add(host);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
function buildAllowedHosts() {
|
|
189
|
+
const allowedHosts = new Set();
|
|
190
|
+
addLoopbackHosts(allowedHosts);
|
|
191
|
+
addConfiguredHost(allowedHosts);
|
|
192
|
+
addExplicitAllowedHosts(allowedHosts);
|
|
193
|
+
return allowedHosts;
|
|
194
|
+
}
|
|
195
|
+
function createHostValidationMiddleware() {
|
|
196
|
+
const allowedHosts = buildAllowedHosts();
|
|
197
|
+
return (req, res, next) => {
|
|
198
|
+
const hostHeader = typeof req.headers.host === 'string' ? req.headers.host : '';
|
|
199
|
+
const normalized = normalizeHost(hostHeader);
|
|
200
|
+
if (!normalized || !allowedHosts.has(normalized)) {
|
|
201
|
+
respondHostNotAllowed(res);
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
next();
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
function createOriginValidationMiddleware() {
|
|
208
|
+
const allowedHosts = buildAllowedHosts();
|
|
209
|
+
return (req, res, next) => {
|
|
210
|
+
const originHeader = getNonEmptyStringHeader(req.headers.origin);
|
|
211
|
+
if (!originHeader) {
|
|
212
|
+
next();
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
const originHostname = tryParseOriginHostname(originHeader);
|
|
216
|
+
if (!originHostname || !allowedHosts.has(originHostname)) {
|
|
217
|
+
respondOriginNotAllowed(res);
|
|
218
|
+
return;
|
|
219
|
+
}
|
|
220
|
+
next();
|
|
221
|
+
};
|
|
222
|
+
}
|
|
223
|
+
function createJsonParseErrorHandler() {
|
|
224
|
+
return (err, _req, res, next) => {
|
|
225
|
+
if (err instanceof SyntaxError && 'body' in err) {
|
|
226
|
+
res.status(400).json({
|
|
227
|
+
jsonrpc: '2.0',
|
|
228
|
+
error: {
|
|
229
|
+
code: -32700,
|
|
230
|
+
message: 'Parse error: Invalid JSON',
|
|
231
|
+
},
|
|
232
|
+
id: null,
|
|
233
|
+
});
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
next();
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
function createContextMiddleware() {
|
|
240
|
+
return (req, _res, next) => {
|
|
241
|
+
const requestId = randomUUID();
|
|
242
|
+
const sessionId = getSessionId(req);
|
|
243
|
+
const context = sessionId === undefined ? { requestId } : { requestId, sessionId };
|
|
244
|
+
runWithRequestContext(context, () => {
|
|
245
|
+
next();
|
|
246
|
+
});
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
export function createCorsMiddleware() {
|
|
250
|
+
return (req, res, next) => {
|
|
251
|
+
// Handle OPTIONS preflight
|
|
252
|
+
if (req.method === 'OPTIONS') {
|
|
253
|
+
res.sendStatus(200);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
next();
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
function registerHealthRoute(app) {
|
|
260
|
+
app.get('/health', (_req, res) => {
|
|
261
|
+
res.json({
|
|
262
|
+
status: 'healthy',
|
|
263
|
+
name: config.server.name,
|
|
264
|
+
version: config.server.version,
|
|
265
|
+
uptime: process.uptime(),
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
export function attachBaseMiddleware(options) {
|
|
270
|
+
const { app, jsonParser, rateLimitMiddleware, corsMiddleware } = options;
|
|
271
|
+
app.use(createHostValidationMiddleware());
|
|
272
|
+
app.use(createOriginValidationMiddleware());
|
|
273
|
+
app.use(jsonParser);
|
|
274
|
+
app.use(createContextMiddleware());
|
|
275
|
+
app.use(createJsonParseErrorHandler());
|
|
276
|
+
app.use(corsMiddleware);
|
|
277
|
+
app.use('/mcp', rateLimitMiddleware);
|
|
278
|
+
registerHealthRoute(app);
|
|
279
|
+
}
|
|
280
|
+
const HASH_PATTERN = /^[a-f0-9.]+$/i;
|
|
281
|
+
function validateNamespace(namespace) {
|
|
282
|
+
return namespace === 'markdown';
|
|
283
|
+
}
|
|
284
|
+
function validateHash(hash) {
|
|
285
|
+
return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
|
|
286
|
+
}
|
|
287
|
+
function parseDownloadParams(req) {
|
|
288
|
+
const { namespace, hash } = req.params;
|
|
289
|
+
if (!namespace || !hash)
|
|
290
|
+
return null;
|
|
291
|
+
if (!validateNamespace(namespace))
|
|
292
|
+
return null;
|
|
293
|
+
if (!validateHash(hash))
|
|
294
|
+
return null;
|
|
295
|
+
return { namespace, hash };
|
|
296
|
+
}
|
|
297
|
+
function buildCacheKeyFromParams(params) {
|
|
298
|
+
return `${params.namespace}:${params.hash}`;
|
|
299
|
+
}
|
|
300
|
+
function respondBadRequest(res, message) {
|
|
301
|
+
res.status(400).json({
|
|
302
|
+
error: message,
|
|
303
|
+
code: 'BAD_REQUEST',
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
function respondNotFound(res) {
|
|
307
|
+
res.status(404).json({
|
|
308
|
+
error: 'Content not found or expired',
|
|
309
|
+
code: 'NOT_FOUND',
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
function respondServiceUnavailable(res) {
|
|
313
|
+
res.status(503).json({
|
|
314
|
+
error: 'Download service is disabled',
|
|
315
|
+
code: 'SERVICE_UNAVAILABLE',
|
|
316
|
+
});
|
|
317
|
+
}
|
|
318
|
+
function resolveDownloadPayload(params, cacheEntry) {
|
|
319
|
+
const payload = parseCachedPayload(cacheEntry.content);
|
|
320
|
+
if (!payload)
|
|
321
|
+
return null;
|
|
322
|
+
const content = resolveCachedPayloadContent(payload);
|
|
323
|
+
if (!content)
|
|
324
|
+
return null;
|
|
325
|
+
const safeTitle = typeof payload.title === 'string' ? payload.title : undefined;
|
|
326
|
+
const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, '.md');
|
|
327
|
+
return {
|
|
328
|
+
content,
|
|
329
|
+
contentType: 'text/markdown; charset=utf-8',
|
|
330
|
+
fileName,
|
|
331
|
+
};
|
|
332
|
+
}
|
|
333
|
+
function buildContentDisposition(fileName) {
|
|
334
|
+
const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
|
|
335
|
+
return `attachment; filename="${fileName}"; filename*=UTF-8''${encodedName}`;
|
|
336
|
+
}
|
|
337
|
+
function sendDownloadPayload(res, payload) {
|
|
338
|
+
const disposition = buildContentDisposition(payload.fileName);
|
|
339
|
+
res.setHeader('Content-Type', payload.contentType);
|
|
340
|
+
res.setHeader('Content-Disposition', disposition);
|
|
341
|
+
res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
|
|
342
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
343
|
+
res.send(payload.content);
|
|
344
|
+
}
|
|
345
|
+
function handleDownload(req, res) {
|
|
346
|
+
if (!config.cache.enabled) {
|
|
347
|
+
respondServiceUnavailable(res);
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
const params = parseDownloadParams(req);
|
|
351
|
+
if (!params) {
|
|
352
|
+
respondBadRequest(res, 'Invalid namespace or hash format');
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
const cacheKey = buildCacheKeyFromParams(params);
|
|
356
|
+
const cacheEntry = cache.get(cacheKey);
|
|
357
|
+
if (!cacheEntry) {
|
|
358
|
+
logDebug('Download request for missing cache key', { cacheKey });
|
|
359
|
+
respondNotFound(res);
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
const payload = resolveDownloadPayload(params, cacheEntry);
|
|
363
|
+
if (!payload) {
|
|
364
|
+
logDebug('Download payload unavailable', { cacheKey });
|
|
365
|
+
respondNotFound(res);
|
|
366
|
+
return;
|
|
367
|
+
}
|
|
368
|
+
logDebug('Serving download', { cacheKey, fileName: payload.fileName });
|
|
369
|
+
sendDownloadPayload(res, payload);
|
|
370
|
+
}
|
|
371
|
+
export function registerDownloadRoutes(app) {
|
|
372
|
+
app.get('/mcp/downloads/:namespace/:hash', handleDownload);
|
|
373
|
+
}
|
|
374
|
+
function getStatusCode(fetchError) {
|
|
375
|
+
return fetchError ? fetchError.statusCode : 500;
|
|
376
|
+
}
|
|
377
|
+
function getErrorCode(fetchError) {
|
|
378
|
+
return fetchError ? fetchError.code : 'INTERNAL_ERROR';
|
|
379
|
+
}
|
|
380
|
+
function getFetchErrorMessage(fetchError) {
|
|
381
|
+
return fetchError ? fetchError.message : 'Internal Server Error';
|
|
382
|
+
}
|
|
383
|
+
function getErrorDetails(fetchError) {
|
|
384
|
+
if (fetchError && Object.keys(fetchError.details).length > 0) {
|
|
385
|
+
return fetchError.details;
|
|
386
|
+
}
|
|
387
|
+
return undefined;
|
|
388
|
+
}
|
|
389
|
+
function setRetryAfterHeader(res, fetchError) {
|
|
390
|
+
const retryAfter = resolveRetryAfter(fetchError);
|
|
391
|
+
if (retryAfter === undefined)
|
|
392
|
+
return;
|
|
393
|
+
res.set('Retry-After', retryAfter);
|
|
394
|
+
}
|
|
395
|
+
function buildErrorResponse(fetchError) {
|
|
396
|
+
const details = getErrorDetails(fetchError);
|
|
397
|
+
const response = {
|
|
398
|
+
error: {
|
|
399
|
+
message: getFetchErrorMessage(fetchError),
|
|
400
|
+
code: getErrorCode(fetchError),
|
|
401
|
+
statusCode: getStatusCode(fetchError),
|
|
402
|
+
...(details && { details }),
|
|
403
|
+
},
|
|
404
|
+
};
|
|
405
|
+
// Never expose stack traces in production
|
|
406
|
+
return response;
|
|
407
|
+
}
|
|
408
|
+
function resolveRetryAfter(fetchError) {
|
|
409
|
+
if (fetchError?.statusCode !== 429)
|
|
410
|
+
return undefined;
|
|
411
|
+
const { retryAfter } = fetchError.details;
|
|
412
|
+
return isRetryAfterValue(retryAfter) ? String(retryAfter) : undefined;
|
|
413
|
+
}
|
|
414
|
+
function isRetryAfterValue(value) {
|
|
415
|
+
return typeof value === 'number' || typeof value === 'string';
|
|
416
|
+
}
|
|
417
|
+
export function errorHandler(err, req, res, next) {
|
|
418
|
+
if (res.headersSent) {
|
|
419
|
+
next(err);
|
|
420
|
+
return;
|
|
421
|
+
}
|
|
422
|
+
const fetchError = err instanceof FetchError ? err : null;
|
|
423
|
+
const statusCode = getStatusCode(fetchError);
|
|
424
|
+
logError(`HTTP ${statusCode}: ${err.message} - ${req.method} ${req.path}`, err);
|
|
425
|
+
setRetryAfterHeader(res, fetchError);
|
|
426
|
+
res.status(statusCode).json(buildErrorResponse(fetchError));
|
|
427
|
+
}
|
|
428
|
+
function assertHttpConfiguration() {
|
|
429
|
+
ensureBindAllowed();
|
|
430
|
+
ensureStaticTokens();
|
|
431
|
+
if (config.auth.mode === 'oauth') {
|
|
432
|
+
ensureOauthConfiguration();
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
function ensureBindAllowed() {
|
|
436
|
+
const isLoopback = ['127.0.0.1', '::1', 'localhost'].includes(config.server.host);
|
|
437
|
+
if (!config.security.allowRemote && !isLoopback) {
|
|
438
|
+
logError('Refusing to bind to non-loopback host without ALLOW_REMOTE=true', { host: config.server.host });
|
|
439
|
+
process.exit(1);
|
|
440
|
+
}
|
|
441
|
+
if (config.security.allowRemote && config.auth.mode !== 'oauth') {
|
|
442
|
+
logError('Remote HTTP mode requires OAuth configuration; refusing to start');
|
|
443
|
+
process.exit(1);
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
function ensureStaticTokens() {
|
|
447
|
+
if (config.auth.mode === 'static' && config.auth.staticTokens.length === 0) {
|
|
448
|
+
logError('At least one static access token is required for HTTP mode');
|
|
449
|
+
process.exit(1);
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
function ensureOauthConfiguration() {
|
|
453
|
+
if (!config.auth.issuerUrl || !config.auth.authorizationUrl) {
|
|
454
|
+
logError('OAUTH_ISSUER_URL and OAUTH_AUTHORIZATION_URL are required for OAuth mode');
|
|
455
|
+
process.exit(1);
|
|
456
|
+
}
|
|
457
|
+
if (!config.auth.tokenUrl) {
|
|
458
|
+
logError('OAUTH_TOKEN_URL is required for OAuth mode');
|
|
459
|
+
process.exit(1);
|
|
460
|
+
}
|
|
461
|
+
if (!config.auth.introspectionUrl) {
|
|
462
|
+
logError('OAUTH_INTROSPECTION_URL is required for OAuth mode');
|
|
463
|
+
process.exit(1);
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
function createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
467
|
+
return (signal) => shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
|
|
468
|
+
}
|
|
469
|
+
async function shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
470
|
+
logInfo(`${signal} received, shutting down gracefully...`);
|
|
471
|
+
stopRateLimitCleanup();
|
|
472
|
+
sessionCleanupController.abort();
|
|
473
|
+
await closeSessions(sessionStore);
|
|
474
|
+
destroyAgents();
|
|
475
|
+
closeServer(server);
|
|
476
|
+
scheduleForcedShutdown(10000);
|
|
477
|
+
}
|
|
478
|
+
async function closeSessions(sessionStore) {
|
|
479
|
+
const sessions = sessionStore.clear();
|
|
480
|
+
await Promise.allSettled(sessions.map((session) => session.transport.close().catch((error) => {
|
|
481
|
+
logWarn('Failed to close session during shutdown', {
|
|
482
|
+
error: getErrorMessage(error),
|
|
483
|
+
});
|
|
484
|
+
})));
|
|
485
|
+
}
|
|
486
|
+
function closeServer(server) {
|
|
487
|
+
server.close(() => {
|
|
488
|
+
logInfo('HTTP server closed');
|
|
489
|
+
process.exit(0);
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
function scheduleForcedShutdown(timeoutMs) {
|
|
493
|
+
setTimeout(() => {
|
|
494
|
+
logError('Forced shutdown after timeout');
|
|
495
|
+
process.exit(1);
|
|
496
|
+
}, timeoutMs).unref();
|
|
497
|
+
}
|
|
498
|
+
function registerSignalHandlers(shutdown) {
|
|
499
|
+
process.on('SIGINT', () => {
|
|
500
|
+
void shutdown('SIGINT');
|
|
501
|
+
});
|
|
502
|
+
process.on('SIGTERM', () => {
|
|
503
|
+
void shutdown('SIGTERM');
|
|
504
|
+
});
|
|
505
|
+
}
|
|
14
506
|
function startListening(app) {
|
|
15
507
|
return app
|
|
16
508
|
.listen(config.server.port, config.server.host, () => {
|
|
@@ -68,7 +560,12 @@ async function buildServerContext() {
|
|
|
68
560
|
async function createAppWithMiddleware() {
|
|
69
561
|
const { app, jsonParser } = await createExpressApp();
|
|
70
562
|
const { rateLimitMiddleware, stopRateLimitCleanup, authMiddleware, corsMiddleware, } = buildMiddleware();
|
|
71
|
-
attachBaseMiddleware(
|
|
563
|
+
attachBaseMiddleware({
|
|
564
|
+
app,
|
|
565
|
+
jsonParser,
|
|
566
|
+
rateLimitMiddleware,
|
|
567
|
+
corsMiddleware,
|
|
568
|
+
});
|
|
72
569
|
attachAuthMetadata(app);
|
|
73
570
|
assertHttpConfiguration();
|
|
74
571
|
return { app, authMiddleware, stopRateLimitCleanup };
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { setInterval as setIntervalPromise } from 'node:timers/promises';
|
|
2
2
|
import { logInfo, logWarn } from '../services/logger.js';
|
|
3
|
-
import { evictExpiredSessions } from './mcp-session.js';
|
|
3
|
+
import { evictExpiredSessions } from './mcp-session-eviction.js';
|
|
4
4
|
export function startSessionCleanupLoop(store, sessionTtlMs) {
|
|
5
5
|
const controller = new AbortController();
|
|
6
6
|
void runSessionCleanupLoop(store, sessionTtlMs, controller.signal).catch(handleSessionCleanupError);
|
|
@@ -8,11 +8,11 @@ export function startSessionCleanupLoop(store, sessionTtlMs) {
|
|
|
8
8
|
}
|
|
9
9
|
async function runSessionCleanupLoop(store, sessionTtlMs, signal) {
|
|
10
10
|
const intervalMs = getCleanupIntervalMs(sessionTtlMs);
|
|
11
|
-
for await (const
|
|
11
|
+
for await (const getNow of setIntervalPromise(intervalMs, Date.now, {
|
|
12
12
|
signal,
|
|
13
13
|
ref: false,
|
|
14
14
|
})) {
|
|
15
|
-
handleSessionEvictions(store);
|
|
15
|
+
handleSessionEvictions(store, getNow());
|
|
16
16
|
}
|
|
17
17
|
}
|
|
18
18
|
function getCleanupIntervalMs(sessionTtlMs) {
|
|
@@ -21,10 +21,13 @@ function getCleanupIntervalMs(sessionTtlMs) {
|
|
|
21
21
|
function isAbortError(error) {
|
|
22
22
|
return error instanceof Error && error.name === 'AbortError';
|
|
23
23
|
}
|
|
24
|
-
function handleSessionEvictions(store) {
|
|
24
|
+
function handleSessionEvictions(store, now) {
|
|
25
25
|
const evicted = evictExpiredSessions(store);
|
|
26
26
|
if (evicted > 0) {
|
|
27
|
-
logInfo('Expired sessions evicted', {
|
|
27
|
+
logInfo('Expired sessions evicted', {
|
|
28
|
+
evicted,
|
|
29
|
+
timestamp: new Date(now).toISOString(),
|
|
30
|
+
});
|
|
28
31
|
}
|
|
29
32
|
}
|
|
30
33
|
function handleSessionCleanupError(error) {
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
import type { NextFunction, Request, Response } from 'express';
|
|
2
|
-
export declare function errorHandler(err: Error, req: Request, res: Response,
|
|
2
|
+
export declare function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void;
|
|
@@ -1,55 +1,56 @@
|
|
|
1
1
|
import { FetchError } from '../errors/app-error.js';
|
|
2
2
|
import { logError } from '../services/logger.js';
|
|
3
|
-
function getStatusCode(
|
|
4
|
-
return
|
|
3
|
+
function getStatusCode(fetchError) {
|
|
4
|
+
return fetchError ? fetchError.statusCode : 500;
|
|
5
5
|
}
|
|
6
|
-
function getErrorCode(
|
|
7
|
-
return
|
|
6
|
+
function getErrorCode(fetchError) {
|
|
7
|
+
return fetchError ? fetchError.code : 'INTERNAL_ERROR';
|
|
8
8
|
}
|
|
9
|
-
function getErrorMessage(
|
|
10
|
-
return
|
|
9
|
+
function getErrorMessage(fetchError) {
|
|
10
|
+
return fetchError ? fetchError.message : 'Internal Server Error';
|
|
11
11
|
}
|
|
12
|
-
function getErrorDetails(
|
|
13
|
-
if (
|
|
14
|
-
return
|
|
12
|
+
function getErrorDetails(fetchError) {
|
|
13
|
+
if (fetchError && Object.keys(fetchError.details).length > 0) {
|
|
14
|
+
return fetchError.details;
|
|
15
15
|
}
|
|
16
16
|
return undefined;
|
|
17
17
|
}
|
|
18
|
-
function setRetryAfterHeader(res,
|
|
19
|
-
const retryAfter = resolveRetryAfter(
|
|
20
|
-
if (
|
|
18
|
+
function setRetryAfterHeader(res, fetchError) {
|
|
19
|
+
const retryAfter = resolveRetryAfter(fetchError);
|
|
20
|
+
if (retryAfter === undefined)
|
|
21
21
|
return;
|
|
22
22
|
res.set('Retry-After', retryAfter);
|
|
23
23
|
}
|
|
24
|
-
function buildErrorResponse(
|
|
25
|
-
const details = getErrorDetails(
|
|
24
|
+
function buildErrorResponse(fetchError) {
|
|
25
|
+
const details = getErrorDetails(fetchError);
|
|
26
26
|
const response = {
|
|
27
27
|
error: {
|
|
28
|
-
message: getErrorMessage(
|
|
29
|
-
code: getErrorCode(
|
|
30
|
-
statusCode: getStatusCode(
|
|
28
|
+
message: getErrorMessage(fetchError),
|
|
29
|
+
code: getErrorCode(fetchError),
|
|
30
|
+
statusCode: getStatusCode(fetchError),
|
|
31
31
|
...(details && { details }),
|
|
32
32
|
},
|
|
33
33
|
};
|
|
34
34
|
// Never expose stack traces in production
|
|
35
35
|
return response;
|
|
36
36
|
}
|
|
37
|
-
function resolveRetryAfter(
|
|
38
|
-
if (
|
|
39
|
-
return
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
const { retryAfter } = err.details;
|
|
43
|
-
if (!isRetryAfterValue(retryAfter))
|
|
44
|
-
return null;
|
|
45
|
-
return String(retryAfter);
|
|
37
|
+
function resolveRetryAfter(fetchError) {
|
|
38
|
+
if (fetchError?.statusCode !== 429)
|
|
39
|
+
return undefined;
|
|
40
|
+
const { retryAfter } = fetchError.details;
|
|
41
|
+
return isRetryAfterValue(retryAfter) ? String(retryAfter) : undefined;
|
|
46
42
|
}
|
|
47
43
|
function isRetryAfterValue(value) {
|
|
48
44
|
return typeof value === 'number' || typeof value === 'string';
|
|
49
45
|
}
|
|
50
|
-
export function errorHandler(err, req, res,
|
|
51
|
-
|
|
46
|
+
export function errorHandler(err, req, res, next) {
|
|
47
|
+
if (res.headersSent) {
|
|
48
|
+
next(err);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
const fetchError = err instanceof FetchError ? err : null;
|
|
52
|
+
const statusCode = getStatusCode(fetchError);
|
|
52
53
|
logError(`HTTP ${statusCode}: ${err.message} - ${req.method} ${req.path}`, err);
|
|
53
|
-
setRetryAfterHeader(res,
|
|
54
|
-
res.status(statusCode).json(buildErrorResponse(
|
|
54
|
+
setRetryAfterHeader(res, fetchError);
|
|
55
|
+
res.status(statusCode).json(buildErrorResponse(fetchError));
|
|
55
56
|
}
|