@j0hanz/superfetch 1.2.5 → 2.0.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 +116 -152
- package/dist/config/auth-config.d.ts +16 -0
- package/dist/config/auth-config.js +53 -0
- package/dist/config/constants.d.ts +11 -13
- package/dist/config/constants.js +1 -3
- package/dist/config/env-parsers.d.ts +7 -0
- package/dist/config/env-parsers.js +84 -0
- package/dist/config/formatting.d.ts +2 -2
- package/dist/config/index.d.ts +47 -53
- package/dist/config/index.js +25 -59
- package/dist/config/types/content.d.ts +1 -49
- package/dist/config/types/runtime.d.ts +8 -16
- package/dist/config/types/tools.d.ts +2 -28
- package/dist/http/accept-policy.d.ts +3 -0
- package/dist/http/accept-policy.js +45 -0
- package/dist/http/async-handler.d.ts +2 -0
- package/dist/http/async-handler.js +5 -0
- package/dist/http/auth-introspection.d.ts +2 -0
- package/dist/http/auth-introspection.js +141 -0
- package/dist/http/auth-static.d.ts +2 -0
- package/dist/http/auth-static.js +23 -0
- package/dist/http/auth.d.ts +3 -2
- package/dist/http/auth.js +98 -26
- package/dist/http/cors.d.ts +6 -6
- package/dist/http/cors.js +7 -42
- package/dist/http/download-routes.d.ts +0 -12
- package/dist/http/download-routes.js +21 -58
- package/dist/http/jsonrpc-http.d.ts +2 -0
- package/dist/http/jsonrpc-http.js +10 -0
- package/dist/http/mcp-routes.d.ts +0 -1
- package/dist/http/mcp-routes.js +43 -30
- package/dist/http/mcp-session-helpers.d.ts +0 -1
- package/dist/http/mcp-session-helpers.js +1 -1
- package/dist/http/mcp-session-transport.d.ts +7 -0
- package/dist/http/mcp-session-transport.js +57 -0
- package/dist/http/mcp-session.js +60 -73
- package/dist/http/mcp-validation.d.ts +1 -0
- package/dist/http/mcp-validation.js +11 -10
- package/dist/http/protocol-policy.d.ts +2 -0
- package/dist/http/protocol-policy.js +31 -0
- package/dist/http/rate-limit.js +5 -2
- package/dist/http/server-config.d.ts +1 -0
- package/dist/http/server-config.js +40 -0
- package/dist/http/server-middleware.d.ts +2 -9
- package/dist/http/server-middleware.js +96 -43
- package/dist/http/server-shutdown.d.ts +4 -0
- package/dist/http/server-shutdown.js +43 -0
- package/dist/http/server.js +52 -64
- package/dist/http/session-cleanup.js +1 -1
- package/dist/middleware/error-handler.js +1 -3
- package/dist/resources/cached-content.js +50 -108
- package/dist/resources/index.js +0 -82
- package/dist/server.js +51 -30
- package/dist/services/cache-keys.d.ts +7 -0
- package/dist/services/cache-keys.js +57 -0
- package/dist/services/cache.d.ts +1 -7
- package/dist/services/cache.js +53 -119
- package/dist/services/context.d.ts +0 -1
- package/dist/services/context.js +0 -7
- package/dist/services/extractor.js +10 -82
- package/dist/services/fetcher/agents.d.ts +2 -2
- package/dist/services/fetcher/agents.js +34 -95
- package/dist/services/fetcher/dns-selection.d.ts +2 -0
- package/dist/services/fetcher/dns-selection.js +72 -0
- package/dist/services/fetcher/interceptors.d.ts +0 -22
- package/dist/services/fetcher/interceptors.js +30 -13
- package/dist/services/fetcher/redirects.js +4 -3
- package/dist/services/fetcher/response.js +66 -31
- package/dist/services/fetcher.d.ts +1 -3
- package/dist/services/fetcher.js +14 -33
- package/dist/services/fifo-queue.d.ts +8 -0
- package/dist/services/fifo-queue.js +25 -0
- package/dist/services/logger.js +2 -2
- package/dist/services/metadata-collector.d.ts +1 -9
- package/dist/services/metadata-collector.js +71 -2
- package/dist/services/transform-worker-pool.d.ts +4 -14
- package/dist/services/transform-worker-pool.js +177 -129
- package/dist/services/transform-worker-types.d.ts +32 -0
- package/dist/services/transform-worker-types.js +14 -0
- package/dist/tools/handlers/fetch-markdown.tool.d.ts +3 -4
- package/dist/tools/handlers/fetch-markdown.tool.js +20 -72
- package/dist/tools/handlers/fetch-single.shared.d.ts +1 -20
- package/dist/tools/handlers/fetch-single.shared.js +44 -87
- package/dist/tools/handlers/fetch-url.tool.d.ts +1 -1
- package/dist/tools/handlers/fetch-url.tool.js +46 -123
- package/dist/tools/index.js +21 -40
- package/dist/tools/schemas.d.ts +1 -51
- package/dist/tools/schemas.js +1 -107
- package/dist/tools/utils/cached-markdown.d.ts +5 -0
- package/dist/tools/utils/cached-markdown.js +46 -0
- package/dist/tools/utils/content-shaping.d.ts +4 -0
- package/dist/tools/utils/content-shaping.js +52 -0
- package/dist/tools/utils/content-transform.d.ts +2 -17
- package/dist/tools/utils/content-transform.js +120 -114
- package/dist/tools/utils/fetch-pipeline.d.ts +0 -8
- package/dist/tools/utils/fetch-pipeline.js +65 -62
- package/dist/tools/utils/inline-content.d.ts +1 -2
- package/dist/tools/utils/inline-content.js +4 -7
- package/dist/transformers/markdown.transformer.js +109 -34
- package/dist/utils/cached-payload.d.ts +7 -0
- package/dist/utils/cached-payload.js +36 -0
- package/dist/utils/error-utils.js +1 -1
- package/dist/utils/filename-generator.js +21 -10
- package/dist/utils/guards.d.ts +1 -0
- package/dist/utils/guards.js +3 -0
- package/dist/utils/header-normalizer.d.ts +0 -3
- package/dist/utils/header-normalizer.js +3 -3
- package/dist/utils/tool-error-handler.d.ts +2 -2
- package/dist/utils/tool-error-handler.js +11 -38
- package/dist/utils/url-transformer.d.ts +7 -0
- package/dist/utils/url-transformer.js +147 -0
- package/dist/utils/url-validator.d.ts +1 -2
- package/dist/utils/url-validator.js +20 -93
- package/dist/workers/content-transform.worker.d.ts +1 -0
- package/dist/workers/content-transform.worker.js +40 -0
- package/package.json +13 -16
package/dist/http/mcp-session.js
CHANGED
|
@@ -5,7 +5,9 @@ import { config } from '../config/index.js';
|
|
|
5
5
|
import { logError, logInfo, logWarn } from '../services/logger.js';
|
|
6
6
|
import { getErrorMessage } from '../utils/error-utils.js';
|
|
7
7
|
import { createMcpServer } from '../server.js';
|
|
8
|
+
import { sendJsonRpcError } from './jsonrpc-http.js';
|
|
8
9
|
import { createSlotTracker, ensureSessionCapacity, reserveSessionSlot, respondBadRequest, respondServerBusy, } from './mcp-session-helpers.js';
|
|
10
|
+
import { createTimeoutController, createTransportAdapter, } from './mcp-session-transport.js';
|
|
9
11
|
import {} from './sessions.js';
|
|
10
12
|
function startSessionInitTimeout(transport, tracker, clearInitTimeout, timeoutMs) {
|
|
11
13
|
if (timeoutMs <= 0)
|
|
@@ -25,68 +27,23 @@ function startSessionInitTimeout(transport, tracker, clearInitTimeout, timeoutMs
|
|
|
25
27
|
timeout.unref();
|
|
26
28
|
return timeout;
|
|
27
29
|
}
|
|
28
|
-
function
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
options.sessionStore.set(id, {
|
|
34
|
-
transport,
|
|
35
|
-
createdAt: now,
|
|
36
|
-
lastSeen: now,
|
|
37
|
-
});
|
|
38
|
-
logInfo('Session initialized', { sessionId: id });
|
|
39
|
-
}
|
|
40
|
-
function handleSessionClosed(id, options) {
|
|
41
|
-
options.sessionStore.remove(id);
|
|
42
|
-
logInfo('Session closed', { sessionId: id });
|
|
43
|
-
}
|
|
44
|
-
function handleTransportClose(transport, options, tracker, clearInitTimeout) {
|
|
45
|
-
clearInitTimeout();
|
|
46
|
-
if (!tracker.isInitialized()) {
|
|
47
|
-
tracker.releaseSlot();
|
|
48
|
-
}
|
|
49
|
-
if (transport.sessionId) {
|
|
50
|
-
options.sessionStore.remove(transport.sessionId);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
function createTransportForNewSession(options) {
|
|
54
|
-
const tracker = createSlotTracker();
|
|
55
|
-
let initTimeout = null;
|
|
56
|
-
const clearInitTimeout = () => {
|
|
57
|
-
if (!initTimeout)
|
|
58
|
-
return;
|
|
59
|
-
clearTimeout(initTimeout);
|
|
60
|
-
initTimeout = null;
|
|
61
|
-
};
|
|
62
|
-
const transport = new StreamableHTTPServerTransport({
|
|
63
|
-
sessionIdGenerator: () => randomUUID(),
|
|
64
|
-
onsessioninitialized: (id) => {
|
|
65
|
-
handleSessionInitialized(id, transport, options, tracker, clearInitTimeout);
|
|
66
|
-
},
|
|
67
|
-
onsessionclosed: (id) => {
|
|
68
|
-
handleSessionClosed(id, options);
|
|
69
|
-
},
|
|
70
|
-
});
|
|
71
|
-
transport.onclose = () => {
|
|
72
|
-
handleTransportClose(transport, options, tracker, clearInitTimeout);
|
|
73
|
-
};
|
|
74
|
-
initTimeout = startSessionInitTimeout(transport, tracker, clearInitTimeout, config.server.sessionInitTimeoutMs);
|
|
75
|
-
return { transport, releaseSlot: tracker.releaseSlot, clearInitTimeout };
|
|
76
|
-
}
|
|
77
|
-
function findExistingTransport(sessionId, options) {
|
|
78
|
-
if (!sessionId) {
|
|
79
|
-
return null;
|
|
30
|
+
async function connectTransportOrThrow(transport, clearInitTimeout, releaseSlot) {
|
|
31
|
+
const mcpServer = createMcpServer();
|
|
32
|
+
const transportAdapter = createTransportAdapter(transport);
|
|
33
|
+
try {
|
|
34
|
+
await mcpServer.connect(transportAdapter);
|
|
80
35
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
36
|
+
catch (error) {
|
|
37
|
+
clearInitTimeout();
|
|
38
|
+
releaseSlot();
|
|
39
|
+
void transport.close().catch((closeError) => {
|
|
40
|
+
logWarn('Failed to close transport after connect error', {
|
|
41
|
+
error: getErrorMessage(closeError),
|
|
42
|
+
});
|
|
43
|
+
});
|
|
44
|
+
logError('Failed to initialize MCP session', error instanceof Error ? error : undefined);
|
|
45
|
+
throw error;
|
|
84
46
|
}
|
|
85
|
-
options.sessionStore.touch(sessionId);
|
|
86
|
-
return existingSession.transport;
|
|
87
|
-
}
|
|
88
|
-
function shouldInitializeSession(sessionId, body) {
|
|
89
|
-
return !sessionId && isInitializeRequest(body);
|
|
90
47
|
}
|
|
91
48
|
async function createAndConnectTransport(options, res) {
|
|
92
49
|
if (!ensureSessionCapacity(options.sessionStore, options.maxSessions, res, evictOldestSession)) {
|
|
@@ -96,25 +53,55 @@ async function createAndConnectTransport(options, res) {
|
|
|
96
53
|
respondServerBusy(res);
|
|
97
54
|
return null;
|
|
98
55
|
}
|
|
99
|
-
const
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
56
|
+
const tracker = createSlotTracker();
|
|
57
|
+
const timeoutController = createTimeoutController();
|
|
58
|
+
const { clear: clearInitTimeout } = timeoutController;
|
|
59
|
+
const transport = new StreamableHTTPServerTransport({
|
|
60
|
+
sessionIdGenerator: () => randomUUID(),
|
|
61
|
+
});
|
|
62
|
+
transport.onclose = () => {
|
|
105
63
|
clearInitTimeout();
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
64
|
+
if (!tracker.isInitialized()) {
|
|
65
|
+
tracker.releaseSlot();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
timeoutController.set(startSessionInitTimeout(transport, tracker, clearInitTimeout, config.server.sessionInitTimeoutMs));
|
|
69
|
+
await connectTransportOrThrow(transport, clearInitTimeout, tracker.releaseSlot);
|
|
70
|
+
const { sessionId } = transport;
|
|
71
|
+
if (typeof sessionId !== 'string') {
|
|
72
|
+
clearInitTimeout();
|
|
73
|
+
tracker.releaseSlot();
|
|
74
|
+
respondBadRequest(res);
|
|
75
|
+
return null;
|
|
109
76
|
}
|
|
77
|
+
clearInitTimeout();
|
|
78
|
+
tracker.markInitialized();
|
|
79
|
+
tracker.releaseSlot();
|
|
80
|
+
const now = Date.now();
|
|
81
|
+
options.sessionStore.set(sessionId, {
|
|
82
|
+
transport,
|
|
83
|
+
createdAt: now,
|
|
84
|
+
lastSeen: now,
|
|
85
|
+
});
|
|
86
|
+
transport.onclose = () => {
|
|
87
|
+
options.sessionStore.remove(sessionId);
|
|
88
|
+
logInfo('Session closed');
|
|
89
|
+
};
|
|
90
|
+
logInfo('Session initialized');
|
|
110
91
|
return transport;
|
|
111
92
|
}
|
|
112
93
|
export async function resolveTransportForPost(_req, res, body, sessionId, options) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
94
|
+
if (sessionId) {
|
|
95
|
+
const existingSession = options.sessionStore.get(sessionId);
|
|
96
|
+
if (existingSession) {
|
|
97
|
+
options.sessionStore.touch(sessionId);
|
|
98
|
+
return existingSession.transport;
|
|
99
|
+
}
|
|
100
|
+
// Client supplied a session id but it doesn't exist; Streamable HTTP: invalid session IDs => 404.
|
|
101
|
+
sendJsonRpcError(res, -32600, 'Session not found', 404);
|
|
102
|
+
return null;
|
|
116
103
|
}
|
|
117
|
-
if (!
|
|
104
|
+
if (!isInitializeRequest(body)) {
|
|
118
105
|
respondBadRequest(res);
|
|
119
106
|
return null;
|
|
120
107
|
}
|
|
@@ -1,13 +1,14 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
const paramsSchema = z.looseObject({});
|
|
3
|
+
const mcpRequestSchema = z.looseObject({
|
|
4
|
+
jsonrpc: z.literal('2.0'),
|
|
5
|
+
method: z.string().min(1),
|
|
6
|
+
id: z.union([z.string(), z.number()]).optional(),
|
|
7
|
+
params: paramsSchema.optional(),
|
|
8
|
+
});
|
|
9
|
+
export function isJsonRpcBatchRequest(body) {
|
|
10
|
+
return Array.isArray(body);
|
|
3
11
|
}
|
|
4
12
|
export function isMcpRequestBody(body) {
|
|
5
|
-
|
|
6
|
-
return false;
|
|
7
|
-
const { method, id, jsonrpc, params } = body;
|
|
8
|
-
const methodValid = method === undefined || typeof method === 'string';
|
|
9
|
-
const idValid = id === undefined || typeof id === 'string' || typeof id === 'number';
|
|
10
|
-
const jsonrpcValid = jsonrpc === undefined || jsonrpc === '2.0';
|
|
11
|
-
const paramsValid = params === undefined || typeof params === 'object';
|
|
12
|
-
return methodValid && idValid && jsonrpcValid && paramsValid;
|
|
13
|
+
return mcpRequestSchema.safeParse(body).success;
|
|
13
14
|
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { sendJsonRpcError } from './jsonrpc-http.js';
|
|
2
|
+
const MCP_PROTOCOL_VERSION_HEADER = 'mcp-protocol-version';
|
|
3
|
+
const MCP_PROTOCOL_VERSIONS = {
|
|
4
|
+
defaultVersion: '2025-03-26',
|
|
5
|
+
supported: new Set(['2025-03-26', '2025-11-25']),
|
|
6
|
+
};
|
|
7
|
+
function getHeaderValue(req, headerNameLower) {
|
|
8
|
+
const value = req.headers[headerNameLower];
|
|
9
|
+
if (typeof value === 'string')
|
|
10
|
+
return value;
|
|
11
|
+
if (Array.isArray(value))
|
|
12
|
+
return value[0] ?? null;
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
function setHeaderValue(req, headerNameLower, value) {
|
|
16
|
+
// Express exposes req.headers as a plain object, but the type is readonly-ish.
|
|
17
|
+
req.headers[headerNameLower] = value;
|
|
18
|
+
}
|
|
19
|
+
export function ensureMcpProtocolVersionHeader(req, res) {
|
|
20
|
+
const raw = getHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER);
|
|
21
|
+
const version = raw?.trim();
|
|
22
|
+
if (!version) {
|
|
23
|
+
setHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER, MCP_PROTOCOL_VERSIONS.defaultVersion);
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
if (!MCP_PROTOCOL_VERSIONS.supported.has(version)) {
|
|
27
|
+
sendJsonRpcError(res, -32600, `Unsupported MCP-Protocol-Version: ${version}`, 400);
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return true;
|
|
31
|
+
}
|
package/dist/http/rate-limit.js
CHANGED
|
@@ -13,7 +13,11 @@ export function createRateLimitMiddleware(options) {
|
|
|
13
13
|
const stop = () => {
|
|
14
14
|
cleanupController.abort();
|
|
15
15
|
};
|
|
16
|
-
const middleware = (
|
|
16
|
+
const middleware = createRateLimitHandler(store, options);
|
|
17
|
+
return { middleware, stop, store };
|
|
18
|
+
}
|
|
19
|
+
function createRateLimitHandler(store, options) {
|
|
20
|
+
return (req, res, next) => {
|
|
17
21
|
if (shouldSkipRateLimit(req, options)) {
|
|
18
22
|
next();
|
|
19
23
|
return;
|
|
@@ -30,7 +34,6 @@ export function createRateLimitMiddleware(options) {
|
|
|
30
34
|
}
|
|
31
35
|
next();
|
|
32
36
|
};
|
|
33
|
-
return { middleware, stop, store };
|
|
34
37
|
}
|
|
35
38
|
async function startCleanupLoop(store, options, signal) {
|
|
36
39
|
for await (const _ of setIntervalPromise(options.cleanupIntervalMs, undefined, { signal, ref: false })) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function assertHttpConfiguration(): void;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { config } from '../config/index.js';
|
|
2
|
+
import { logError } from '../services/logger.js';
|
|
3
|
+
export function assertHttpConfiguration() {
|
|
4
|
+
ensureBindAllowed();
|
|
5
|
+
ensureStaticTokens();
|
|
6
|
+
if (config.auth.mode === 'oauth') {
|
|
7
|
+
ensureOauthConfiguration();
|
|
8
|
+
}
|
|
9
|
+
}
|
|
10
|
+
function ensureBindAllowed() {
|
|
11
|
+
const isLoopback = ['127.0.0.1', '::1', 'localhost'].includes(config.server.host);
|
|
12
|
+
if (!config.security.allowRemote && !isLoopback) {
|
|
13
|
+
logError('Refusing to bind to non-loopback host without ALLOW_REMOTE=true', { host: config.server.host });
|
|
14
|
+
process.exit(1);
|
|
15
|
+
}
|
|
16
|
+
if (config.security.allowRemote && config.auth.mode !== 'oauth') {
|
|
17
|
+
logError('Remote HTTP mode requires OAuth configuration; refusing to start');
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function ensureStaticTokens() {
|
|
22
|
+
if (config.auth.mode === 'static' && config.auth.staticTokens.length === 0) {
|
|
23
|
+
logError('At least one static access token is required for HTTP mode');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
function ensureOauthConfiguration() {
|
|
28
|
+
if (!config.auth.issuerUrl || !config.auth.authorizationUrl) {
|
|
29
|
+
logError('OAUTH_ISSUER_URL and OAUTH_AUTHORIZATION_URL are required for OAuth mode');
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
if (!config.auth.tokenUrl) {
|
|
33
|
+
logError('OAUTH_TOKEN_URL is required for OAuth mode');
|
|
34
|
+
process.exit(1);
|
|
35
|
+
}
|
|
36
|
+
if (!config.auth.introspectionUrl) {
|
|
37
|
+
logError('OAUTH_INTROSPECTION_URL is required for OAuth mode');
|
|
38
|
+
process.exit(1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -1,9 +1,2 @@
|
|
|
1
|
-
import type { Express,
|
|
2
|
-
export declare function
|
|
3
|
-
allowedOrigins: string[];
|
|
4
|
-
allowAllOrigins: boolean;
|
|
5
|
-
};
|
|
6
|
-
export declare function createJsonParseErrorHandler(): (err: Error, _req: Request, res: Response, next: NextFunction) => void;
|
|
7
|
-
export declare function createContextMiddleware(): (req: Request, _res: Response, next: NextFunction) => void;
|
|
8
|
-
export declare function registerHealthRoute(app: Express): void;
|
|
9
|
-
export declare function attachBaseMiddleware(app: Express, jsonParser: RequestHandler, rateLimitMiddleware: RequestHandler, authMiddleware: RequestHandler, corsMiddleware: RequestHandler): void;
|
|
1
|
+
import type { Express, RequestHandler } from 'express';
|
|
2
|
+
export declare function attachBaseMiddleware(app: Express, jsonParser: RequestHandler, rateLimitMiddleware: RequestHandler, corsMiddleware: RequestHandler): void;
|
|
@@ -1,45 +1,93 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { config } from '../config/index.js';
|
|
3
|
-
import {
|
|
3
|
+
import { runWithRequestContext } from '../services/context.js';
|
|
4
4
|
import { getSessionId } from './sessions.js';
|
|
5
5
|
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
|
|
6
|
+
function getNonEmptyStringHeader(value) {
|
|
7
|
+
if (typeof value !== 'string')
|
|
8
|
+
return null;
|
|
9
|
+
const trimmed = value.trim();
|
|
10
|
+
return trimmed === '' ? null : trimmed;
|
|
11
|
+
}
|
|
12
|
+
function respondHostNotAllowed(res) {
|
|
13
|
+
res.status(403).json({
|
|
14
|
+
error: 'Host not allowed',
|
|
15
|
+
code: 'HOST_NOT_ALLOWED',
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
function respondOriginNotAllowed(res) {
|
|
19
|
+
res.status(403).json({
|
|
20
|
+
error: 'Origin not allowed',
|
|
21
|
+
code: 'ORIGIN_NOT_ALLOWED',
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
function tryParseOriginHostname(originHeader) {
|
|
25
|
+
try {
|
|
26
|
+
return new URL(originHeader).hostname.toLowerCase();
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
function takeFirstHostValue(value) {
|
|
33
|
+
const first = value.split(',')[0];
|
|
34
|
+
if (!first)
|
|
35
|
+
return null;
|
|
36
|
+
const trimmed = first.trim();
|
|
37
|
+
return trimmed ? trimmed : null;
|
|
38
|
+
}
|
|
39
|
+
function stripIpv6Brackets(value) {
|
|
40
|
+
if (!value.startsWith('['))
|
|
41
|
+
return null;
|
|
42
|
+
const end = value.indexOf(']');
|
|
43
|
+
if (end === -1)
|
|
44
|
+
return null;
|
|
45
|
+
return value.slice(1, end);
|
|
46
|
+
}
|
|
47
|
+
function stripPortIfPresent(value) {
|
|
48
|
+
const colonIndex = value.indexOf(':');
|
|
49
|
+
if (colonIndex === -1)
|
|
50
|
+
return value;
|
|
51
|
+
return value.slice(0, colonIndex);
|
|
52
|
+
}
|
|
6
53
|
function normalizeHost(value) {
|
|
7
54
|
const trimmed = value.trim().toLowerCase();
|
|
8
55
|
if (!trimmed)
|
|
9
56
|
return null;
|
|
10
|
-
const first = trimmed
|
|
57
|
+
const first = takeFirstHostValue(trimmed);
|
|
11
58
|
if (!first)
|
|
12
59
|
return null;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
return first.slice(1, end);
|
|
18
|
-
}
|
|
19
|
-
const colonIndex = first.indexOf(':');
|
|
20
|
-
if (colonIndex !== -1) {
|
|
21
|
-
return first.slice(0, colonIndex);
|
|
22
|
-
}
|
|
23
|
-
return first;
|
|
60
|
+
const ipv6 = stripIpv6Brackets(first);
|
|
61
|
+
if (ipv6)
|
|
62
|
+
return ipv6;
|
|
63
|
+
return stripPortIfPresent(first);
|
|
24
64
|
}
|
|
25
|
-
function
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
const normalized = normalizeHost(entry);
|
|
30
|
-
if (normalized) {
|
|
31
|
-
allowedHosts.add(normalized);
|
|
32
|
-
}
|
|
33
|
-
}
|
|
65
|
+
function isWildcardHost(host) {
|
|
66
|
+
return host === '0.0.0.0' || host === '::';
|
|
67
|
+
}
|
|
68
|
+
function addLoopbackHosts(allowedHosts) {
|
|
34
69
|
for (const host of LOOPBACK_HOSTS) {
|
|
35
70
|
allowedHosts.add(host);
|
|
36
71
|
}
|
|
72
|
+
}
|
|
73
|
+
function addConfiguredHost(allowedHosts) {
|
|
37
74
|
const configuredHost = normalizeHost(config.server.host);
|
|
38
|
-
if (configuredHost
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
75
|
+
if (!configuredHost)
|
|
76
|
+
return;
|
|
77
|
+
if (isWildcardHost(configuredHost))
|
|
78
|
+
return;
|
|
79
|
+
allowedHosts.add(configuredHost);
|
|
80
|
+
}
|
|
81
|
+
function addExplicitAllowedHosts(allowedHosts) {
|
|
82
|
+
for (const host of config.security.allowedHosts) {
|
|
83
|
+
allowedHosts.add(host);
|
|
42
84
|
}
|
|
85
|
+
}
|
|
86
|
+
function buildAllowedHosts() {
|
|
87
|
+
const allowedHosts = new Set();
|
|
88
|
+
addLoopbackHosts(allowedHosts);
|
|
89
|
+
addConfiguredHost(allowedHosts);
|
|
90
|
+
addExplicitAllowedHosts(allowedHosts);
|
|
43
91
|
return allowedHosts;
|
|
44
92
|
}
|
|
45
93
|
function createHostValidationMiddleware() {
|
|
@@ -48,23 +96,29 @@ function createHostValidationMiddleware() {
|
|
|
48
96
|
const hostHeader = typeof req.headers.host === 'string' ? req.headers.host : '';
|
|
49
97
|
const normalized = normalizeHost(hostHeader);
|
|
50
98
|
if (!normalized || !allowedHosts.has(normalized)) {
|
|
51
|
-
res
|
|
52
|
-
error: 'Host not allowed',
|
|
53
|
-
code: 'HOST_NOT_ALLOWED',
|
|
54
|
-
});
|
|
99
|
+
respondHostNotAllowed(res);
|
|
55
100
|
return;
|
|
56
101
|
}
|
|
57
102
|
next();
|
|
58
103
|
};
|
|
59
104
|
}
|
|
60
|
-
|
|
61
|
-
const
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
105
|
+
function createOriginValidationMiddleware() {
|
|
106
|
+
const allowedHosts = buildAllowedHosts();
|
|
107
|
+
return (req, res, next) => {
|
|
108
|
+
const originHeader = getNonEmptyStringHeader(req.headers.origin);
|
|
109
|
+
if (!originHeader) {
|
|
110
|
+
next();
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
const originHostname = tryParseOriginHostname(originHeader);
|
|
114
|
+
if (!originHostname || !allowedHosts.has(originHostname)) {
|
|
115
|
+
respondOriginNotAllowed(res);
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
next();
|
|
119
|
+
};
|
|
66
120
|
}
|
|
67
|
-
|
|
121
|
+
function createJsonParseErrorHandler() {
|
|
68
122
|
return (err, _req, res, next) => {
|
|
69
123
|
if (err instanceof SyntaxError && 'body' in err) {
|
|
70
124
|
res.status(400).json({
|
|
@@ -80,18 +134,17 @@ export function createJsonParseErrorHandler() {
|
|
|
80
134
|
next();
|
|
81
135
|
};
|
|
82
136
|
}
|
|
83
|
-
|
|
137
|
+
function createContextMiddleware() {
|
|
84
138
|
return (req, _res, next) => {
|
|
85
139
|
const requestId = randomUUID();
|
|
86
140
|
const sessionId = getSessionId(req);
|
|
87
141
|
const context = sessionId === undefined ? { requestId } : { requestId, sessionId };
|
|
88
142
|
runWithRequestContext(context, () => {
|
|
89
|
-
|
|
90
|
-
boundNext();
|
|
143
|
+
next();
|
|
91
144
|
});
|
|
92
145
|
};
|
|
93
146
|
}
|
|
94
|
-
|
|
147
|
+
function registerHealthRoute(app) {
|
|
95
148
|
app.get('/health', (_req, res) => {
|
|
96
149
|
res.json({
|
|
97
150
|
status: 'healthy',
|
|
@@ -101,13 +154,13 @@ export function registerHealthRoute(app) {
|
|
|
101
154
|
});
|
|
102
155
|
});
|
|
103
156
|
}
|
|
104
|
-
export function attachBaseMiddleware(app, jsonParser, rateLimitMiddleware,
|
|
157
|
+
export function attachBaseMiddleware(app, jsonParser, rateLimitMiddleware, corsMiddleware) {
|
|
105
158
|
app.use(createHostValidationMiddleware());
|
|
159
|
+
app.use(createOriginValidationMiddleware());
|
|
106
160
|
app.use(jsonParser);
|
|
107
161
|
app.use(createContextMiddleware());
|
|
108
162
|
app.use(createJsonParseErrorHandler());
|
|
109
163
|
app.use(corsMiddleware);
|
|
110
164
|
app.use('/mcp', rateLimitMiddleware);
|
|
111
|
-
app.use(authMiddleware);
|
|
112
165
|
registerHealthRoute(app);
|
|
113
166
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
import type { Express } from 'express';
|
|
2
|
+
import type { SessionStore } from './sessions.js';
|
|
3
|
+
export declare function createShutdownHandler(server: ReturnType<Express['listen']>, sessionStore: SessionStore, sessionCleanupController: AbortController, stopRateLimitCleanup: () => void): (signal: string) => Promise<void>;
|
|
4
|
+
export declare function registerSignalHandlers(shutdown: (signal: string) => Promise<void>): void;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { destroyAgents } from '../services/fetcher/agents.js';
|
|
2
|
+
import { logError, logInfo, logWarn } from '../services/logger.js';
|
|
3
|
+
import { getErrorMessage } from '../utils/error-utils.js';
|
|
4
|
+
export function createShutdownHandler(server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
5
|
+
return (signal) => shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup);
|
|
6
|
+
}
|
|
7
|
+
async function shutdownServer(signal, server, sessionStore, sessionCleanupController, stopRateLimitCleanup) {
|
|
8
|
+
logInfo(`${signal} received, shutting down gracefully...`);
|
|
9
|
+
stopRateLimitCleanup();
|
|
10
|
+
sessionCleanupController.abort();
|
|
11
|
+
await closeSessions(sessionStore);
|
|
12
|
+
destroyAgents();
|
|
13
|
+
closeServer(server);
|
|
14
|
+
scheduleForcedShutdown(10000);
|
|
15
|
+
}
|
|
16
|
+
async function closeSessions(sessionStore) {
|
|
17
|
+
const sessions = sessionStore.clear();
|
|
18
|
+
await Promise.allSettled(sessions.map((session) => session.transport.close().catch((error) => {
|
|
19
|
+
logWarn('Failed to close session during shutdown', {
|
|
20
|
+
error: getErrorMessage(error),
|
|
21
|
+
});
|
|
22
|
+
})));
|
|
23
|
+
}
|
|
24
|
+
function closeServer(server) {
|
|
25
|
+
server.close(() => {
|
|
26
|
+
logInfo('HTTP server closed');
|
|
27
|
+
process.exit(0);
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
function scheduleForcedShutdown(timeoutMs) {
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
logError('Forced shutdown after timeout');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}, timeoutMs).unref();
|
|
35
|
+
}
|
|
36
|
+
export function registerSignalHandlers(shutdown) {
|
|
37
|
+
process.on('SIGINT', () => {
|
|
38
|
+
void shutdown('SIGINT');
|
|
39
|
+
});
|
|
40
|
+
process.on('SIGTERM', () => {
|
|
41
|
+
void shutdown('SIGTERM');
|
|
42
|
+
});
|
|
43
|
+
}
|