@j0hanz/superfetch 1.2.5 → 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.
Files changed (173) hide show
  1. package/README.md +131 -156
  2. package/dist/config/auth-config.d.ts +16 -0
  3. package/dist/config/auth-config.js +53 -0
  4. package/dist/config/constants.d.ts +11 -13
  5. package/dist/config/constants.js +1 -3
  6. package/dist/config/env-parsers.d.ts +7 -0
  7. package/dist/config/env-parsers.js +84 -0
  8. package/dist/config/formatting.d.ts +2 -2
  9. package/dist/config/index.d.ts +47 -53
  10. package/dist/config/index.js +35 -64
  11. package/dist/config/types/content.d.ts +1 -49
  12. package/dist/config/types/runtime.d.ts +8 -16
  13. package/dist/config/types/tools.d.ts +2 -28
  14. package/dist/http/accept-policy.d.ts +3 -0
  15. package/dist/http/accept-policy.js +45 -0
  16. package/dist/http/async-handler.d.ts +2 -0
  17. package/dist/http/async-handler.js +5 -0
  18. package/dist/http/auth-introspection.d.ts +2 -0
  19. package/dist/http/auth-introspection.js +141 -0
  20. package/dist/http/auth-static.d.ts +2 -0
  21. package/dist/http/auth-static.js +23 -0
  22. package/dist/http/auth.d.ts +3 -2
  23. package/dist/http/auth.js +254 -23
  24. package/dist/http/cors.d.ts +6 -6
  25. package/dist/http/cors.js +7 -42
  26. package/dist/http/download-routes.d.ts +0 -12
  27. package/dist/http/download-routes.js +21 -58
  28. package/dist/http/host-allowlist.d.ts +3 -0
  29. package/dist/http/host-allowlist.js +117 -0
  30. package/dist/http/jsonrpc-http.d.ts +2 -0
  31. package/dist/http/jsonrpc-http.js +10 -0
  32. package/dist/http/mcp-routes.d.ts +8 -3
  33. package/dist/http/mcp-routes.js +137 -31
  34. package/dist/http/mcp-session-eviction.d.ts +3 -0
  35. package/dist/http/mcp-session-eviction.js +24 -0
  36. package/dist/http/mcp-session-helpers.d.ts +0 -1
  37. package/dist/http/mcp-session-helpers.js +1 -1
  38. package/dist/http/mcp-session-init.d.ts +7 -0
  39. package/dist/http/mcp-session-init.js +94 -0
  40. package/dist/http/mcp-session-slots.d.ts +17 -0
  41. package/dist/http/mcp-session-slots.js +55 -0
  42. package/dist/http/mcp-session-transport-init.d.ts +7 -0
  43. package/dist/http/mcp-session-transport-init.js +41 -0
  44. package/dist/http/mcp-session-transport.d.ts +7 -0
  45. package/dist/http/mcp-session-transport.js +57 -0
  46. package/dist/http/mcp-session-types.d.ts +5 -0
  47. package/dist/http/mcp-session-types.js +1 -0
  48. package/dist/http/mcp-session.d.ts +9 -9
  49. package/dist/http/mcp-session.js +15 -137
  50. package/dist/http/mcp-sessions.d.ts +43 -0
  51. package/dist/http/mcp-sessions.js +392 -0
  52. package/dist/http/mcp-validation.d.ts +1 -0
  53. package/dist/http/mcp-validation.js +11 -10
  54. package/dist/http/protocol-policy.d.ts +2 -0
  55. package/dist/http/protocol-policy.js +31 -0
  56. package/dist/http/rate-limit.js +7 -4
  57. package/dist/http/server-config.d.ts +1 -0
  58. package/dist/http/server-config.js +40 -0
  59. package/dist/http/server-middleware.d.ts +7 -9
  60. package/dist/http/server-middleware.js +9 -70
  61. package/dist/http/server-shutdown.d.ts +4 -0
  62. package/dist/http/server-shutdown.js +43 -0
  63. package/dist/http/server.d.ts +10 -0
  64. package/dist/http/server.js +546 -61
  65. package/dist/http/session-cleanup.js +8 -5
  66. package/dist/middleware/error-handler.d.ts +1 -1
  67. package/dist/middleware/error-handler.js +32 -33
  68. package/dist/resources/cached-content-params.d.ts +5 -0
  69. package/dist/resources/cached-content-params.js +36 -0
  70. package/dist/resources/cached-content.js +67 -125
  71. package/dist/resources/index.js +0 -82
  72. package/dist/server.js +50 -29
  73. package/dist/services/cache-events.d.ts +8 -0
  74. package/dist/services/cache-events.js +19 -0
  75. package/dist/services/cache-keys.d.ts +7 -0
  76. package/dist/services/cache-keys.js +57 -0
  77. package/dist/services/cache.d.ts +4 -9
  78. package/dist/services/cache.js +77 -139
  79. package/dist/services/context.d.ts +0 -1
  80. package/dist/services/context.js +0 -7
  81. package/dist/services/extractor.js +55 -116
  82. package/dist/services/fetcher/agents.d.ts +2 -2
  83. package/dist/services/fetcher/agents.js +35 -96
  84. package/dist/services/fetcher/dns-selection.d.ts +2 -0
  85. package/dist/services/fetcher/dns-selection.js +72 -0
  86. package/dist/services/fetcher/interceptors.d.ts +0 -22
  87. package/dist/services/fetcher/interceptors.js +18 -32
  88. package/dist/services/fetcher/redirects.js +16 -7
  89. package/dist/services/fetcher/response.js +79 -34
  90. package/dist/services/fetcher.d.ts +22 -3
  91. package/dist/services/fetcher.js +544 -44
  92. package/dist/services/fifo-queue.d.ts +8 -0
  93. package/dist/services/fifo-queue.js +25 -0
  94. package/dist/services/logger.js +2 -2
  95. package/dist/services/metadata-collector.d.ts +1 -9
  96. package/dist/services/metadata-collector.js +71 -2
  97. package/dist/services/transform-worker-pool.d.ts +4 -14
  98. package/dist/services/transform-worker-pool.js +177 -129
  99. package/dist/services/transform-worker-types.d.ts +32 -0
  100. package/dist/services/transform-worker-types.js +14 -0
  101. package/dist/tools/handlers/fetch-markdown.tool.d.ts +3 -4
  102. package/dist/tools/handlers/fetch-markdown.tool.js +20 -72
  103. package/dist/tools/handlers/fetch-single.shared.d.ts +11 -22
  104. package/dist/tools/handlers/fetch-single.shared.js +175 -89
  105. package/dist/tools/handlers/fetch-url.tool.d.ts +7 -1
  106. package/dist/tools/handlers/fetch-url.tool.js +84 -119
  107. package/dist/tools/index.js +21 -40
  108. package/dist/tools/schemas.d.ts +1 -51
  109. package/dist/tools/schemas.js +1 -107
  110. package/dist/tools/utils/cached-markdown.d.ts +5 -0
  111. package/dist/tools/utils/cached-markdown.js +46 -0
  112. package/dist/tools/utils/content-shaping.d.ts +4 -0
  113. package/dist/tools/utils/content-shaping.js +67 -0
  114. package/dist/tools/utils/content-transform.d.ts +5 -17
  115. package/dist/tools/utils/content-transform.js +134 -114
  116. package/dist/tools/utils/fetch-pipeline.d.ts +0 -8
  117. package/dist/tools/utils/fetch-pipeline.js +57 -63
  118. package/dist/tools/utils/frontmatter.d.ts +3 -0
  119. package/dist/tools/utils/frontmatter.js +73 -0
  120. package/dist/tools/utils/inline-content.d.ts +1 -2
  121. package/dist/tools/utils/inline-content.js +4 -7
  122. package/dist/tools/utils/markdown-heuristics.d.ts +1 -0
  123. package/dist/tools/utils/markdown-heuristics.js +19 -0
  124. package/dist/tools/utils/markdown-signals.d.ts +1 -0
  125. package/dist/tools/utils/markdown-signals.js +19 -0
  126. package/dist/tools/utils/raw-markdown-frontmatter.d.ts +3 -0
  127. package/dist/tools/utils/raw-markdown-frontmatter.js +73 -0
  128. package/dist/tools/utils/raw-markdown.d.ts +6 -0
  129. package/dist/tools/utils/raw-markdown.js +135 -0
  130. package/dist/transformers/markdown/fenced-code-rule.d.ts +2 -0
  131. package/dist/transformers/markdown/fenced-code-rule.js +38 -0
  132. package/dist/transformers/markdown/frontmatter.d.ts +2 -0
  133. package/dist/transformers/markdown/frontmatter.js +45 -0
  134. package/dist/transformers/markdown/noise-rule.d.ts +2 -0
  135. package/dist/transformers/markdown/noise-rule.js +80 -0
  136. package/dist/transformers/markdown/turndown-instance.d.ts +2 -0
  137. package/dist/transformers/markdown/turndown-instance.js +19 -0
  138. package/dist/transformers/markdown.d.ts +2 -0
  139. package/dist/transformers/markdown.js +185 -0
  140. package/dist/transformers/markdown.transformer.js +5 -117
  141. package/dist/utils/cached-payload.d.ts +7 -0
  142. package/dist/utils/cached-payload.js +36 -0
  143. package/dist/utils/code-language-bash.d.ts +1 -0
  144. package/dist/utils/code-language-bash.js +48 -0
  145. package/dist/utils/code-language-core.d.ts +2 -0
  146. package/dist/utils/code-language-core.js +13 -0
  147. package/dist/utils/code-language-detectors.d.ts +5 -0
  148. package/dist/utils/code-language-detectors.js +142 -0
  149. package/dist/utils/code-language-helpers.d.ts +5 -0
  150. package/dist/utils/code-language-helpers.js +62 -0
  151. package/dist/utils/code-language-parsing.d.ts +5 -0
  152. package/dist/utils/code-language-parsing.js +62 -0
  153. package/dist/utils/code-language.d.ts +9 -0
  154. package/dist/utils/code-language.js +250 -46
  155. package/dist/utils/error-details.d.ts +3 -0
  156. package/dist/utils/error-details.js +12 -0
  157. package/dist/utils/error-utils.js +1 -1
  158. package/dist/utils/filename-generator.js +34 -12
  159. package/dist/utils/guards.d.ts +1 -0
  160. package/dist/utils/guards.js +3 -0
  161. package/dist/utils/header-normalizer.d.ts +0 -3
  162. package/dist/utils/header-normalizer.js +3 -3
  163. package/dist/utils/ip-address.d.ts +4 -0
  164. package/dist/utils/ip-address.js +6 -0
  165. package/dist/utils/tool-error-handler.d.ts +2 -2
  166. package/dist/utils/tool-error-handler.js +14 -46
  167. package/dist/utils/url-transformer.d.ts +7 -0
  168. package/dist/utils/url-transformer.js +147 -0
  169. package/dist/utils/url-validator.d.ts +1 -2
  170. package/dist/utils/url-validator.js +53 -114
  171. package/dist/workers/content-transform.worker.d.ts +1 -0
  172. package/dist/workers/content-transform.worker.js +40 -0
  173. package/package.json +17 -18
package/dist/http/auth.js CHANGED
@@ -1,38 +1,269 @@
1
+ import { InvalidTokenError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
2
+ import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
3
+ import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, } from '@modelcontextprotocol/sdk/server/auth/router.js';
4
+ import { config } from '../config/index.js';
1
5
  import { timingSafeEqualUtf8 } from '../utils/crypto.js';
2
- function normalizeHeaderValue(header) {
3
- return Array.isArray(header) ? header[0] : header;
6
+ import { isRecord } from '../utils/guards.js';
7
+ const STATIC_TOKEN_TTL_SECONDS = 60 * 60 * 24;
8
+ function stripHash(url) {
9
+ const copy = new URL(url.href);
10
+ copy.hash = '';
11
+ return copy.href;
12
+ }
13
+ function parseScopes(value) {
14
+ if (typeof value === 'string') {
15
+ return value
16
+ .split(' ')
17
+ .map((scope) => scope.trim())
18
+ .filter((scope) => scope.length > 0);
19
+ }
20
+ if (Array.isArray(value)) {
21
+ return value.filter((scope) => typeof scope === 'string');
22
+ }
23
+ return [];
4
24
  }
5
- function timingSafeEquals(a, b) {
6
- return timingSafeEqualUtf8(a, b);
25
+ function parseResourceUrl(value) {
26
+ if (typeof value !== 'string')
27
+ return undefined;
28
+ if (!URL.canParse(value))
29
+ return undefined;
30
+ return new URL(value);
7
31
  }
8
- function isAuthorizedRequest(req, authToken) {
9
- if (!authToken)
10
- return false;
11
- const bearerToken = getBearerToken(req);
12
- if (bearerToken) {
13
- return timingSafeEquals(bearerToken, authToken);
32
+ function parseAudResource(aud) {
33
+ if (typeof aud === 'string') {
34
+ return parseResourceUrl(aud);
35
+ }
36
+ if (Array.isArray(aud)) {
37
+ for (const entry of aud) {
38
+ const parsed = parseResourceUrl(entry);
39
+ if (parsed)
40
+ return parsed;
41
+ }
14
42
  }
15
- const apiKeyHeader = getApiKeyHeader(req);
16
- return apiKeyHeader ? timingSafeEquals(apiKeyHeader, authToken) : false;
43
+ return undefined;
17
44
  }
18
- function getBearerToken(req) {
19
- const authHeader = normalizeHeaderValue(req.headers.authorization);
20
- if (!authHeader?.startsWith('Bearer '))
21
- return null;
22
- const token = authHeader.slice('Bearer '.length).trim();
23
- return token.length > 0 ? token : null;
45
+ function extractResource(data) {
46
+ const resource = parseResourceUrl(data.resource);
47
+ if (resource)
48
+ return resource;
49
+ return parseAudResource(data.aud);
50
+ }
51
+ function extractScopes(data) {
52
+ if (data.scope !== undefined) {
53
+ return parseScopes(data.scope);
54
+ }
55
+ if (data.scopes !== undefined) {
56
+ return parseScopes(data.scopes);
57
+ }
58
+ if (data.scp !== undefined) {
59
+ return parseScopes(data.scp);
60
+ }
61
+ return [];
62
+ }
63
+ function readExpiresAt(data) {
64
+ const expiresAt = typeof data.exp === 'number' ? data.exp : Number.NaN;
65
+ if (!Number.isFinite(expiresAt)) {
66
+ throw new InvalidTokenError('Token has no expiration time');
67
+ }
68
+ return expiresAt;
69
+ }
70
+ function resolveClientId(data) {
71
+ if (typeof data.client_id === 'string')
72
+ return data.client_id;
73
+ if (typeof data.cid === 'string')
74
+ return data.cid;
75
+ if (typeof data.sub === 'string')
76
+ return data.sub;
77
+ return 'unknown';
78
+ }
79
+ function ensureResourceMatch(resource) {
80
+ if (resource && stripHash(resource) !== stripHash(config.auth.resourceUrl)) {
81
+ throw new InvalidTokenError('Token resource mismatch');
82
+ }
83
+ return resource;
84
+ }
85
+ function buildIntrospectionAuthInfo(token, data) {
86
+ const resource = ensureResourceMatch(extractResource(data));
87
+ return {
88
+ token,
89
+ clientId: resolveClientId(data),
90
+ scopes: extractScopes(data),
91
+ expiresAt: readExpiresAt(data),
92
+ resource: resource ?? config.auth.resourceUrl,
93
+ extra: data,
94
+ };
95
+ }
96
+ function buildBasicAuthHeader(clientId, clientSecret) {
97
+ const secret = clientSecret ?? '';
98
+ const basic = Buffer.from(`${clientId}:${secret}`, 'utf8').toString('base64');
99
+ return `Basic ${basic}`;
100
+ }
101
+ function buildIntrospectionRequest(token, resourceUrl, clientId, clientSecret) {
102
+ const body = new URLSearchParams({
103
+ token,
104
+ token_type_hint: 'access_token',
105
+ resource: stripHash(resourceUrl),
106
+ }).toString();
107
+ const headers = {
108
+ 'content-type': 'application/x-www-form-urlencoded',
109
+ };
110
+ if (clientId) {
111
+ headers.authorization = buildBasicAuthHeader(clientId, clientSecret);
112
+ }
113
+ return { body, headers };
114
+ }
115
+ async function requestIntrospection(introspectionUrl, request, timeoutMs) {
116
+ const response = await fetch(introspectionUrl, {
117
+ method: 'POST',
118
+ headers: request.headers,
119
+ body: request.body,
120
+ signal: AbortSignal.timeout(timeoutMs),
121
+ });
122
+ if (!response.ok) {
123
+ await response.body?.cancel();
124
+ throw new ServerError(`Token introspection failed: ${response.status}`);
125
+ }
126
+ return response.json();
127
+ }
128
+ function parseIntrospectionPayload(payload) {
129
+ if (!isRecord(payload) || Array.isArray(payload)) {
130
+ throw new ServerError('Invalid introspection response');
131
+ }
132
+ if (payload.active !== true) {
133
+ throw new InvalidTokenError('Token is inactive');
134
+ }
135
+ return payload;
136
+ }
137
+ async function verifyWithIntrospection(token) {
138
+ const { auth } = config;
139
+ if (!auth.introspectionUrl) {
140
+ throw new ServerError('Token introspection is not configured');
141
+ }
142
+ const request = buildIntrospectionRequest(token, auth.resourceUrl, auth.clientId, auth.clientSecret);
143
+ const payload = await requestIntrospection(auth.introspectionUrl, request, auth.introspectionTimeoutMs);
144
+ return buildIntrospectionAuthInfo(token, parseIntrospectionPayload(payload));
145
+ }
146
+ function buildStaticAuthInfo(token) {
147
+ return {
148
+ token,
149
+ clientId: 'static-token',
150
+ scopes: config.auth.requiredScopes,
151
+ expiresAt: Math.floor(Date.now() / 1000) + STATIC_TOKEN_TTL_SECONDS,
152
+ resource: config.auth.resourceUrl,
153
+ };
154
+ }
155
+ function verifyStaticToken(token) {
156
+ if (config.auth.staticTokens.length === 0) {
157
+ throw new InvalidTokenError('No static tokens configured');
158
+ }
159
+ const matched = config.auth.staticTokens.some((candidate) => timingSafeEqualUtf8(candidate, token));
160
+ if (!matched) {
161
+ throw new InvalidTokenError('Invalid token');
162
+ }
163
+ return buildStaticAuthInfo(token);
164
+ }
165
+ function normalizeHeaderValue(header) {
166
+ return Array.isArray(header) ? header[0] : header;
24
167
  }
25
168
  function getApiKeyHeader(req) {
26
169
  const apiKeyHeader = normalizeHeaderValue(req.headers['x-api-key']);
27
170
  return apiKeyHeader ? apiKeyHeader.trim() : null;
28
171
  }
29
- export function createAuthMiddleware(authToken) {
30
- return (req, res, next) => {
31
- if (isAuthorizedRequest(req, authToken)) {
172
+ function createLegacyApiKeyMiddleware() {
173
+ return (req, _res, next) => {
174
+ if (config.auth.mode !== 'static') {
32
175
  next();
33
176
  return;
34
177
  }
35
- res.set('WWW-Authenticate', 'Bearer realm="mcp", error="invalid_token", error_description="Missing or invalid credentials"');
36
- res.status(401).json({ error: 'Unauthorized' });
178
+ if (!req.headers.authorization) {
179
+ const apiKey = getApiKeyHeader(req);
180
+ if (apiKey) {
181
+ req.headers.authorization = `Bearer ${apiKey}`;
182
+ }
183
+ }
184
+ next();
185
+ };
186
+ }
187
+ async function verifyAccessToken(token) {
188
+ if (config.auth.mode === 'oauth') {
189
+ return verifyWithIntrospection(token);
190
+ }
191
+ return verifyStaticToken(token);
192
+ }
193
+ function resolveMetadataUrl() {
194
+ if (config.auth.mode !== 'oauth')
195
+ return null;
196
+ return getOAuthProtectedResourceMetadataUrl(new URL(config.auth.resourceUrl));
197
+ }
198
+ function resolveOptionalScopes(requiredScopes) {
199
+ return requiredScopes.length > 0 ? [...requiredScopes] : undefined;
200
+ }
201
+ function resolveOAuthMetadataParams(authConfig) {
202
+ const { issuerUrl, authorizationUrl, tokenUrl, revocationUrl, registrationUrl, requiredScopes, } = authConfig;
203
+ if (!issuerUrl || !authorizationUrl || !tokenUrl)
204
+ return null;
205
+ return {
206
+ issuerUrl,
207
+ authorizationUrl,
208
+ tokenUrl,
209
+ revocationUrl,
210
+ registrationUrl,
211
+ requiredScopes,
212
+ };
213
+ }
214
+ function buildBaseOAuthMetadata(params) {
215
+ return {
216
+ issuer: params.issuerUrl.href,
217
+ authorization_endpoint: params.authorizationUrl.href,
218
+ response_types_supported: ['code'],
219
+ code_challenge_methods_supported: ['S256'],
220
+ token_endpoint: params.tokenUrl.href,
221
+ token_endpoint_auth_methods_supported: ['client_secret_post', 'none'],
222
+ grant_types_supported: ['authorization_code', 'refresh_token'],
37
223
  };
38
224
  }
225
+ function applyOptionalScopes(metadata, requiredScopes) {
226
+ const scopesSupported = resolveOptionalScopes(requiredScopes);
227
+ if (scopesSupported !== undefined) {
228
+ metadata.scopes_supported = scopesSupported;
229
+ }
230
+ }
231
+ function applyOptionalEndpoint(metadata, key, url) {
232
+ if (!url)
233
+ return;
234
+ metadata[key] = url.href;
235
+ }
236
+ function buildOAuthMetadata(params) {
237
+ const oauthMetadata = buildBaseOAuthMetadata(params);
238
+ applyOptionalScopes(oauthMetadata, params.requiredScopes);
239
+ applyOptionalEndpoint(oauthMetadata, 'revocation_endpoint', params.revocationUrl);
240
+ applyOptionalEndpoint(oauthMetadata, 'registration_endpoint', params.registrationUrl);
241
+ return oauthMetadata;
242
+ }
243
+ export function createAuthMiddleware() {
244
+ const metadataUrl = resolveMetadataUrl();
245
+ const authHandler = requireBearerAuth({
246
+ verifier: { verifyAccessToken },
247
+ requiredScopes: config.auth.requiredScopes,
248
+ ...(metadataUrl ? { resourceMetadataUrl: metadataUrl } : {}),
249
+ });
250
+ const legacyHandler = createLegacyApiKeyMiddleware();
251
+ return (req, res, next) => {
252
+ legacyHandler(req, res, () => {
253
+ authHandler(req, res, next);
254
+ });
255
+ };
256
+ }
257
+ export function createAuthMetadataRouter() {
258
+ if (config.auth.mode !== 'oauth')
259
+ return null;
260
+ const oauthMetadataParams = resolveOAuthMetadataParams(config.auth);
261
+ if (!oauthMetadataParams)
262
+ return null;
263
+ return mcpAuthMetadataRouter({
264
+ oauthMetadata: buildOAuthMetadata(oauthMetadataParams),
265
+ resourceServerUrl: config.auth.resourceUrl,
266
+ scopesSupported: config.auth.requiredScopes,
267
+ resourceName: config.server.name,
268
+ });
269
+ }
@@ -1,7 +1,7 @@
1
1
  import type { NextFunction, Request, Response } from 'express';
2
- interface CorsOptions {
3
- readonly allowedOrigins: string[];
4
- readonly allowAllOrigins: boolean;
5
- }
6
- export declare function createCorsMiddleware(options: CorsOptions): (req: Request, res: Response, next: NextFunction) => void;
7
- export {};
2
+ /**
3
+ * Creates a minimal CORS middleware.
4
+ * MCP clients are not browser-based, so CORS is not needed.
5
+ * This just handles OPTIONS preflight requests.
6
+ */
7
+ export declare function createCorsMiddleware(): (req: Request, res: Response, next: NextFunction) => void;
package/dist/http/cors.js CHANGED
@@ -1,35 +1,11 @@
1
- function isOriginAllowed(origin, options) {
2
- if (!origin)
3
- return true;
4
- if (options.allowAllOrigins)
5
- return true;
6
- if (options.allowedOrigins.length === 0)
7
- return false;
8
- return options.allowedOrigins.includes(origin);
9
- }
10
- function isValidOrigin(origin) {
11
- return URL.canParse(origin);
12
- }
13
- export function createCorsMiddleware(options) {
1
+ /**
2
+ * Creates a minimal CORS middleware.
3
+ * MCP clients are not browser-based, so CORS is not needed.
4
+ * This just handles OPTIONS preflight requests.
5
+ */
6
+ export function createCorsMiddleware() {
14
7
  return (req, res, next) => {
15
- const origin = resolveOrigin(req);
16
- if (origin) {
17
- if (!isValidOrigin(origin)) {
18
- res.status(403).json({
19
- error: 'Origin not allowed',
20
- code: 'ORIGIN_NOT_ALLOWED',
21
- });
22
- return;
23
- }
24
- if (!isOriginAllowed(origin, options)) {
25
- res.status(403).json({
26
- error: 'Origin not allowed',
27
- code: 'ORIGIN_NOT_ALLOWED',
28
- });
29
- return;
30
- }
31
- applyCorsHeaders(res, origin);
32
- }
8
+ // Handle OPTIONS preflight
33
9
  if (req.method === 'OPTIONS') {
34
10
  res.sendStatus(200);
35
11
  return;
@@ -37,14 +13,3 @@ export function createCorsMiddleware(options) {
37
13
  next();
38
14
  };
39
15
  }
40
- function resolveOrigin(req) {
41
- return req.headers.origin;
42
- }
43
- function applyCorsHeaders(res, origin) {
44
- res.vary('Origin');
45
- res.header('Access-Control-Allow-Origin', origin);
46
- res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
47
- res.header('Access-Control-Allow-Headers', 'Content-Type, mcp-session-id, Authorization, X-API-Key');
48
- res.header('Access-Control-Expose-Headers', 'mcp-session-id');
49
- res.header('Access-Control-Max-Age', '86400');
50
- }
@@ -1,14 +1,2 @@
1
1
  import type { Express } from 'express';
2
- import type { CacheEntry } from '../config/types/content.js';
3
- interface DownloadParams {
4
- namespace: string;
5
- hash: string;
6
- }
7
- interface DownloadPayload {
8
- content: string;
9
- contentType: string;
10
- fileName: string;
11
- }
12
- export declare function resolveDownloadPayload(params: DownloadParams, cacheEntry: CacheEntry): DownloadPayload | null;
13
2
  export declare function registerDownloadRoutes(app: Express): void;
14
- export {};
@@ -1,11 +1,12 @@
1
1
  import { config } from '../config/index.js';
2
2
  import * as cache from '../services/cache.js';
3
3
  import { logDebug } from '../services/logger.js';
4
+ import { parseCachedPayload, resolveCachedPayloadContent, } from '../utils/cached-payload.js';
4
5
  import { generateSafeFilename } from '../utils/filename-generator.js';
5
- const VALID_NAMESPACES = new Set(['markdown', 'url']);
6
+ import { wrapAsync } from './async-handler.js';
6
7
  const HASH_PATTERN = /^[a-f0-9.]+$/i;
7
8
  function validateNamespace(namespace) {
8
- return VALID_NAMESPACES.has(namespace);
9
+ return namespace === 'markdown';
9
10
  }
10
11
  function validateHash(hash) {
11
12
  return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
@@ -41,55 +42,18 @@ function respondServiceUnavailable(res) {
41
42
  code: 'SERVICE_UNAVAILABLE',
42
43
  });
43
44
  }
44
- function resolveContentType(namespace) {
45
- return namespace === 'markdown'
46
- ? 'text/markdown; charset=utf-8'
47
- : 'application/x-ndjson; charset=utf-8';
48
- }
49
- function resolveExtension(namespace) {
50
- return namespace === 'markdown' ? '.md' : '.jsonl';
51
- }
52
- function parseCachedPayload(raw) {
53
- try {
54
- const parsed = JSON.parse(raw);
55
- return isCachedPayload(parsed) ? parsed : null;
56
- }
57
- catch {
58
- return null;
59
- }
60
- }
61
- function isCachedPayload(value) {
62
- if (!value || typeof value !== 'object')
63
- return false;
64
- const record = value;
65
- return ((record.content === undefined || typeof record.content === 'string') &&
66
- (record.markdown === undefined || typeof record.markdown === 'string') &&
67
- (record.title === undefined || typeof record.title === 'string'));
68
- }
69
- function resolvePayloadContent(payload, namespace) {
70
- if (namespace === 'markdown') {
71
- if (typeof payload.markdown === 'string') {
72
- return payload.markdown;
73
- }
74
- if (typeof payload.content === 'string') {
75
- return payload.content;
76
- }
77
- return null;
78
- }
79
- return typeof payload.content === 'string' ? payload.content : null;
80
- }
81
- export function resolveDownloadPayload(params, cacheEntry) {
45
+ function resolveDownloadPayload(params, cacheEntry) {
82
46
  const payload = parseCachedPayload(cacheEntry.content);
83
47
  if (!payload)
84
48
  return null;
85
- const content = resolvePayloadContent(payload, params.namespace);
49
+ const content = resolveCachedPayloadContent(payload);
86
50
  if (!content)
87
51
  return null;
88
52
  const safeTitle = typeof payload.title === 'string' ? payload.title : undefined;
89
- const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, resolveExtension(params.namespace));
53
+ const fileName = generateSafeFilename(cacheEntry.url, cacheEntry.title ?? safeTitle, params.hash, '.md');
90
54
  return {
91
55
  content,
92
- contentType: resolveContentType(params.namespace),
56
+ contentType: 'text/markdown; charset=utf-8',
93
57
  fileName,
94
58
  };
95
59
  }
@@ -97,41 +61,40 @@ function buildContentDisposition(fileName) {
97
61
  const encodedName = encodeURIComponent(fileName).replace(/'/g, '%27');
98
62
  return `attachment; filename="${fileName}"; filename*=UTF-8''${encodedName}`;
99
63
  }
64
+ function sendDownloadPayload(res, payload) {
65
+ const disposition = buildContentDisposition(payload.fileName);
66
+ res.setHeader('Content-Type', payload.contentType);
67
+ res.setHeader('Content-Disposition', disposition);
68
+ res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
69
+ res.setHeader('X-Content-Type-Options', 'nosniff');
70
+ res.send(payload.content);
71
+ }
100
72
  function handleDownload(req, res) {
101
73
  if (!config.cache.enabled) {
102
74
  respondServiceUnavailable(res);
103
- return Promise.resolve();
75
+ return;
104
76
  }
105
77
  const params = parseDownloadParams(req);
106
78
  if (!params) {
107
79
  respondBadRequest(res, 'Invalid namespace or hash format');
108
- return Promise.resolve();
80
+ return;
109
81
  }
110
82
  const cacheKey = buildCacheKeyFromParams(params);
111
83
  const cacheEntry = cache.get(cacheKey);
112
84
  if (!cacheEntry) {
113
85
  logDebug('Download request for missing cache key', { cacheKey });
114
86
  respondNotFound(res);
115
- return Promise.resolve();
87
+ return;
116
88
  }
117
89
  const payload = resolveDownloadPayload(params, cacheEntry);
118
90
  if (!payload) {
119
91
  logDebug('Download payload unavailable', { cacheKey });
120
92
  respondNotFound(res);
121
- return Promise.resolve();
93
+ return;
122
94
  }
123
- const disposition = buildContentDisposition(payload.fileName);
124
- res.setHeader('Content-Type', payload.contentType);
125
- res.setHeader('Content-Disposition', disposition);
126
- res.setHeader('Cache-Control', `private, max-age=${config.cache.ttl}`);
127
- res.setHeader('X-Content-Type-Options', 'nosniff');
128
95
  logDebug('Serving download', { cacheKey, fileName: payload.fileName });
129
- res.send(payload.content);
130
- return Promise.resolve();
96
+ sendDownloadPayload(res, payload);
131
97
  }
132
98
  export function registerDownloadRoutes(app) {
133
- const asyncHandler = (fn) => (req, res, next) => {
134
- Promise.resolve(fn(req, res)).catch(next);
135
- };
136
- app.get('/mcp/downloads/:namespace/:hash', asyncHandler(handleDownload));
99
+ app.get('/mcp/downloads/:namespace/:hash', wrapAsync(handleDownload));
137
100
  }
@@ -0,0 +1,3 @@
1
+ import type { RequestHandler } from 'express';
2
+ export declare function createHostValidationMiddleware(): RequestHandler;
3
+ export declare function createOriginValidationMiddleware(): RequestHandler;
@@ -0,0 +1,117 @@
1
+ import { config } from '../config/index.js';
2
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
3
+ function getNonEmptyStringHeader(value) {
4
+ if (typeof value !== 'string')
5
+ return null;
6
+ const trimmed = value.trim();
7
+ return trimmed === '' ? null : trimmed;
8
+ }
9
+ function respondHostNotAllowed(res) {
10
+ res.status(403).json({
11
+ error: 'Host not allowed',
12
+ code: 'HOST_NOT_ALLOWED',
13
+ });
14
+ }
15
+ function respondOriginNotAllowed(res) {
16
+ res.status(403).json({
17
+ error: 'Origin not allowed',
18
+ code: 'ORIGIN_NOT_ALLOWED',
19
+ });
20
+ }
21
+ function tryParseOriginHostname(originHeader) {
22
+ try {
23
+ return new URL(originHeader).hostname.toLowerCase();
24
+ }
25
+ catch {
26
+ return null;
27
+ }
28
+ }
29
+ function takeFirstHostValue(value) {
30
+ const first = value.split(',')[0];
31
+ if (!first)
32
+ return null;
33
+ const trimmed = first.trim();
34
+ return trimmed ? trimmed : null;
35
+ }
36
+ function stripIpv6Brackets(value) {
37
+ if (!value.startsWith('['))
38
+ return null;
39
+ const end = value.indexOf(']');
40
+ if (end === -1)
41
+ return null;
42
+ return value.slice(1, end);
43
+ }
44
+ function stripPortIfPresent(value) {
45
+ const colonIndex = value.indexOf(':');
46
+ if (colonIndex === -1)
47
+ return value;
48
+ return value.slice(0, colonIndex);
49
+ }
50
+ function normalizeHost(value) {
51
+ const trimmed = value.trim().toLowerCase();
52
+ if (!trimmed)
53
+ return null;
54
+ const first = takeFirstHostValue(trimmed);
55
+ if (!first)
56
+ return null;
57
+ const ipv6 = stripIpv6Brackets(first);
58
+ if (ipv6)
59
+ return ipv6;
60
+ return stripPortIfPresent(first);
61
+ }
62
+ function isWildcardHost(host) {
63
+ return host === '0.0.0.0' || host === '::';
64
+ }
65
+ function addLoopbackHosts(allowedHosts) {
66
+ for (const host of LOOPBACK_HOSTS) {
67
+ allowedHosts.add(host);
68
+ }
69
+ }
70
+ function addConfiguredHost(allowedHosts) {
71
+ const configuredHost = normalizeHost(config.server.host);
72
+ if (!configuredHost)
73
+ return;
74
+ if (isWildcardHost(configuredHost))
75
+ return;
76
+ allowedHosts.add(configuredHost);
77
+ }
78
+ function addExplicitAllowedHosts(allowedHosts) {
79
+ for (const host of config.security.allowedHosts) {
80
+ allowedHosts.add(host);
81
+ }
82
+ }
83
+ function buildAllowedHosts() {
84
+ const allowedHosts = new Set();
85
+ addLoopbackHosts(allowedHosts);
86
+ addConfiguredHost(allowedHosts);
87
+ addExplicitAllowedHosts(allowedHosts);
88
+ return allowedHosts;
89
+ }
90
+ export function createHostValidationMiddleware() {
91
+ const allowedHosts = buildAllowedHosts();
92
+ return (req, res, next) => {
93
+ const hostHeader = typeof req.headers.host === 'string' ? req.headers.host : '';
94
+ const normalized = normalizeHost(hostHeader);
95
+ if (!normalized || !allowedHosts.has(normalized)) {
96
+ respondHostNotAllowed(res);
97
+ return;
98
+ }
99
+ next();
100
+ };
101
+ }
102
+ export function createOriginValidationMiddleware() {
103
+ const allowedHosts = buildAllowedHosts();
104
+ return (req, res, next) => {
105
+ const originHeader = getNonEmptyStringHeader(req.headers.origin);
106
+ if (!originHeader) {
107
+ next();
108
+ return;
109
+ }
110
+ const originHostname = tryParseOriginHostname(originHeader);
111
+ if (!originHostname || !allowedHosts.has(originHostname)) {
112
+ respondOriginNotAllowed(res);
113
+ return;
114
+ }
115
+ next();
116
+ };
117
+ }
@@ -0,0 +1,2 @@
1
+ import type { Response } from 'express';
2
+ export declare function sendJsonRpcError(res: Response, code: number, message: string, status?: number): void;
@@ -0,0 +1,10 @@
1
+ export function sendJsonRpcError(res, code, message, status = 400) {
2
+ res.status(status).json({
3
+ jsonrpc: '2.0',
4
+ error: {
5
+ code,
6
+ message,
7
+ },
8
+ id: null,
9
+ });
10
+ }
@@ -1,4 +1,9 @@
1
- import type { Express } from 'express';
2
- import { type McpSessionOptions } from './mcp-session.js';
1
+ import type { Express, Request, Response } from 'express';
2
+ import type { McpRequestBody } from '../config/types/runtime.js';
3
+ import { type McpSessionOptions } from './mcp-sessions.js';
4
+ export declare function isJsonRpcBatchRequest(body: unknown): boolean;
5
+ export declare function isMcpRequestBody(body: unknown): body is McpRequestBody;
6
+ export declare function ensureMcpProtocolVersionHeader(req: Request, res: Response): boolean;
7
+ export declare function ensurePostAcceptHeader(req: Request): void;
8
+ export declare function acceptsEventStream(req: Request): boolean;
3
9
  export declare function registerMcpRoutes(app: Express, options: McpSessionOptions): void;
4
- export { evictExpiredSessions } from './mcp-session.js';