@j0hanz/superfetch 2.0.0 → 2.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (150) hide show
  1. package/README.md +139 -46
  2. package/dist/cache.d.ts +42 -0
  3. package/dist/cache.js +565 -0
  4. package/dist/config/env-parsers.d.ts +1 -0
  5. package/dist/config/env-parsers.js +12 -0
  6. package/dist/config/index.d.ts +7 -0
  7. package/dist/config/index.js +20 -8
  8. package/dist/config/types/content.d.ts +1 -0
  9. package/dist/config.d.ts +77 -0
  10. package/dist/config.js +261 -0
  11. package/dist/crypto.d.ts +2 -0
  12. package/dist/crypto.js +32 -0
  13. package/dist/errors.d.ts +10 -0
  14. package/dist/errors.js +28 -0
  15. package/dist/fetch.d.ts +40 -0
  16. package/dist/fetch.js +910 -0
  17. package/dist/http/auth.js +161 -2
  18. package/dist/http/base-middleware.d.ts +7 -0
  19. package/dist/http/base-middleware.js +143 -0
  20. package/dist/http/cors.d.ts +0 -5
  21. package/dist/http/cors.js +0 -6
  22. package/dist/http/download-routes.js +6 -2
  23. package/dist/http/error-handler.d.ts +2 -0
  24. package/dist/http/error-handler.js +55 -0
  25. package/dist/http/host-allowlist.d.ts +3 -0
  26. package/dist/http/host-allowlist.js +117 -0
  27. package/dist/http/mcp-routes.d.ts +8 -2
  28. package/dist/http/mcp-routes.js +101 -8
  29. package/dist/http/mcp-session-eviction.d.ts +3 -0
  30. package/dist/http/mcp-session-eviction.js +24 -0
  31. package/dist/http/mcp-session-init.d.ts +7 -0
  32. package/dist/http/mcp-session-init.js +94 -0
  33. package/dist/http/mcp-session-slots.d.ts +17 -0
  34. package/dist/http/mcp-session-slots.js +55 -0
  35. package/dist/http/mcp-session-transport-init.d.ts +7 -0
  36. package/dist/http/mcp-session-transport-init.js +41 -0
  37. package/dist/http/mcp-session-types.d.ts +5 -0
  38. package/dist/http/mcp-session-types.js +1 -0
  39. package/dist/http/mcp-session.d.ts +9 -9
  40. package/dist/http/mcp-session.js +5 -114
  41. package/dist/http/mcp-sessions.d.ts +41 -0
  42. package/dist/http/mcp-sessions.js +392 -0
  43. package/dist/http/rate-limit.js +2 -2
  44. package/dist/http/server-middleware.d.ts +6 -1
  45. package/dist/http/server-middleware.js +3 -117
  46. package/dist/http/server-shutdown.js +1 -1
  47. package/dist/http/server-tuning.d.ts +9 -0
  48. package/dist/http/server-tuning.js +45 -0
  49. package/dist/http/server.js +206 -9
  50. package/dist/http/session-cleanup.js +8 -5
  51. package/dist/http.d.ts +78 -0
  52. package/dist/http.js +1437 -0
  53. package/dist/index.js +3 -3
  54. package/dist/mcp.d.ts +3 -0
  55. package/dist/mcp.js +94 -0
  56. package/dist/middleware/error-handler.d.ts +1 -1
  57. package/dist/middleware/error-handler.js +31 -30
  58. package/dist/observability.d.ts +16 -0
  59. package/dist/observability.js +78 -0
  60. package/dist/resources/cached-content-params.d.ts +5 -0
  61. package/dist/resources/cached-content-params.js +36 -0
  62. package/dist/resources/cached-content.js +33 -33
  63. package/dist/server.js +21 -6
  64. package/dist/services/cache-events.d.ts +8 -0
  65. package/dist/services/cache-events.js +19 -0
  66. package/dist/services/cache.d.ts +5 -4
  67. package/dist/services/cache.js +49 -45
  68. package/dist/services/context.d.ts +2 -0
  69. package/dist/services/context.js +3 -0
  70. package/dist/services/extractor.d.ts +1 -0
  71. package/dist/services/extractor.js +77 -40
  72. package/dist/services/fetcher/agents.js +1 -1
  73. package/dist/services/fetcher/dns-selection.js +1 -1
  74. package/dist/services/fetcher/interceptors.js +29 -60
  75. package/dist/services/fetcher/redirects.js +12 -4
  76. package/dist/services/fetcher/response.js +18 -8
  77. package/dist/services/fetcher.d.ts +23 -0
  78. package/dist/services/fetcher.js +553 -13
  79. package/dist/services/logger.js +4 -1
  80. package/dist/services/telemetry.d.ts +19 -0
  81. package/dist/services/telemetry.js +43 -0
  82. package/dist/services/transform-worker-pool.d.ts +10 -3
  83. package/dist/services/transform-worker-pool.js +213 -184
  84. package/dist/tools/handlers/fetch-single.shared.d.ts +11 -3
  85. package/dist/tools/handlers/fetch-single.shared.js +131 -2
  86. package/dist/tools/handlers/fetch-url.tool.d.ts +6 -0
  87. package/dist/tools/handlers/fetch-url.tool.js +56 -12
  88. package/dist/tools/index.d.ts +1 -0
  89. package/dist/tools/index.js +13 -1
  90. package/dist/tools/schemas.d.ts +2 -0
  91. package/dist/tools/schemas.js +8 -0
  92. package/dist/tools/utils/content-shaping.js +19 -4
  93. package/dist/tools/utils/content-transform-core.d.ts +5 -0
  94. package/dist/tools/utils/content-transform-core.js +180 -0
  95. package/dist/tools/utils/content-transform-workers.d.ts +1 -0
  96. package/dist/tools/utils/content-transform-workers.js +1 -0
  97. package/dist/tools/utils/content-transform.d.ts +2 -1
  98. package/dist/tools/utils/content-transform.js +37 -136
  99. package/dist/tools/utils/fetch-pipeline.js +47 -56
  100. package/dist/tools/utils/frontmatter.d.ts +3 -0
  101. package/dist/tools/utils/frontmatter.js +73 -0
  102. package/dist/tools/utils/markdown-heuristics.d.ts +1 -0
  103. package/dist/tools/utils/markdown-heuristics.js +19 -0
  104. package/dist/tools/utils/markdown-signals.d.ts +1 -0
  105. package/dist/tools/utils/markdown-signals.js +19 -0
  106. package/dist/tools/utils/raw-markdown-frontmatter.d.ts +3 -0
  107. package/dist/tools/utils/raw-markdown-frontmatter.js +73 -0
  108. package/dist/tools/utils/raw-markdown.d.ts +6 -0
  109. package/dist/tools/utils/raw-markdown.js +149 -0
  110. package/dist/tools.d.ts +104 -0
  111. package/dist/tools.js +421 -0
  112. package/dist/transform.d.ts +69 -0
  113. package/dist/transform.js +1509 -0
  114. package/dist/transformers/markdown/fenced-code-rule.d.ts +2 -0
  115. package/dist/transformers/markdown/fenced-code-rule.js +38 -0
  116. package/dist/transformers/markdown/frontmatter.d.ts +2 -0
  117. package/dist/transformers/markdown/frontmatter.js +45 -0
  118. package/dist/transformers/markdown/noise-rule.d.ts +2 -0
  119. package/dist/transformers/markdown/noise-rule.js +80 -0
  120. package/dist/transformers/markdown/turndown-instance.d.ts +2 -0
  121. package/dist/transformers/markdown/turndown-instance.js +19 -0
  122. package/dist/transformers/markdown.d.ts +5 -0
  123. package/dist/transformers/markdown.js +314 -0
  124. package/dist/transformers/markdown.transformer.js +2 -189
  125. package/dist/utils/cancellation.d.ts +1 -0
  126. package/dist/utils/cancellation.js +18 -0
  127. package/dist/utils/code-language-bash.d.ts +1 -0
  128. package/dist/utils/code-language-bash.js +48 -0
  129. package/dist/utils/code-language-core.d.ts +2 -0
  130. package/dist/utils/code-language-core.js +13 -0
  131. package/dist/utils/code-language-detectors.d.ts +5 -0
  132. package/dist/utils/code-language-detectors.js +142 -0
  133. package/dist/utils/code-language-helpers.d.ts +5 -0
  134. package/dist/utils/code-language-helpers.js +62 -0
  135. package/dist/utils/code-language-parsing.d.ts +5 -0
  136. package/dist/utils/code-language-parsing.js +62 -0
  137. package/dist/utils/code-language.js +250 -46
  138. package/dist/utils/error-details.d.ts +3 -0
  139. package/dist/utils/error-details.js +12 -0
  140. package/dist/utils/filename-generator.js +14 -3
  141. package/dist/utils/host-normalizer.d.ts +1 -0
  142. package/dist/utils/host-normalizer.js +37 -0
  143. package/dist/utils/ip-address.d.ts +4 -0
  144. package/dist/utils/ip-address.js +6 -0
  145. package/dist/utils/tool-error-handler.js +12 -17
  146. package/dist/utils/url-redactor.d.ts +1 -0
  147. package/dist/utils/url-redactor.js +13 -0
  148. package/dist/utils/url-validator.js +35 -20
  149. package/dist/workers/transform-worker.js +82 -38
  150. package/package.json +13 -10
package/dist/http/auth.js CHANGED
@@ -1,8 +1,167 @@
1
+ import { InvalidTokenError, ServerError, } from '@modelcontextprotocol/sdk/server/auth/errors.js';
1
2
  import { requireBearerAuth } from '@modelcontextprotocol/sdk/server/auth/middleware/bearerAuth.js';
2
3
  import { getOAuthProtectedResourceMetadataUrl, mcpAuthMetadataRouter, } from '@modelcontextprotocol/sdk/server/auth/router.js';
3
4
  import { config } from '../config/index.js';
4
- import { verifyWithIntrospection } from './auth-introspection.js';
5
- import { verifyStaticToken } from './auth-static.js';
5
+ import { timingSafeEqualUtf8 } from '../utils/crypto.js';
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 [];
24
+ }
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);
31
+ }
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
+ }
42
+ }
43
+ return undefined;
44
+ }
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
+ }
6
165
  function normalizeHeaderValue(header) {
7
166
  return Array.isArray(header) ? header[0] : header;
8
167
  }
@@ -0,0 +1,7 @@
1
+ import type { Express, RequestHandler } from 'express';
2
+ export declare function attachBaseMiddleware(options: {
3
+ app: Express;
4
+ jsonParser: RequestHandler;
5
+ rateLimitMiddleware: RequestHandler;
6
+ corsMiddleware: RequestHandler;
7
+ }): void;
@@ -0,0 +1,143 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { config } from '../config/index.js';
3
+ import { runWithRequestContext } from '../services/context.js';
4
+ import { logDebug } from '../services/logger.js';
5
+ import { normalizeHost } from '../utils/host-normalizer.js';
6
+ import { getSessionId } from './mcp-sessions.js';
7
+ const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '::1']);
8
+ function getNonEmptyStringHeader(value) {
9
+ if (typeof value !== 'string')
10
+ return null;
11
+ const trimmed = value.trim();
12
+ return trimmed === '' ? null : trimmed;
13
+ }
14
+ function respondHostNotAllowed(res) {
15
+ res.status(403).json({
16
+ error: 'Host not allowed',
17
+ code: 'HOST_NOT_ALLOWED',
18
+ });
19
+ }
20
+ function respondOriginNotAllowed(res) {
21
+ res.status(403).json({
22
+ error: 'Origin not allowed',
23
+ code: 'ORIGIN_NOT_ALLOWED',
24
+ });
25
+ }
26
+ function tryParseOriginHostname(originHeader) {
27
+ try {
28
+ return new URL(originHeader).hostname.toLowerCase();
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ function isWildcardHost(host) {
35
+ return host === '0.0.0.0' || host === '::';
36
+ }
37
+ function addLoopbackHosts(allowedHosts) {
38
+ for (const host of LOOPBACK_HOSTS) {
39
+ allowedHosts.add(host);
40
+ }
41
+ }
42
+ function addConfiguredHost(allowedHosts) {
43
+ const configuredHost = normalizeHost(config.server.host);
44
+ if (!configuredHost)
45
+ return;
46
+ if (isWildcardHost(configuredHost))
47
+ return;
48
+ allowedHosts.add(configuredHost);
49
+ }
50
+ function addExplicitAllowedHosts(allowedHosts) {
51
+ for (const host of config.security.allowedHosts) {
52
+ const normalized = normalizeHost(host);
53
+ if (!normalized) {
54
+ logDebug('Ignoring invalid allowed host entry', { host });
55
+ continue;
56
+ }
57
+ allowedHosts.add(normalized);
58
+ }
59
+ }
60
+ function buildAllowedHosts() {
61
+ const allowedHosts = new Set();
62
+ addLoopbackHosts(allowedHosts);
63
+ addConfiguredHost(allowedHosts);
64
+ addExplicitAllowedHosts(allowedHosts);
65
+ return allowedHosts;
66
+ }
67
+ function createHostValidationMiddleware() {
68
+ const allowedHosts = buildAllowedHosts();
69
+ return (req, res, next) => {
70
+ const hostHeader = typeof req.headers.host === 'string' ? req.headers.host : '';
71
+ const normalized = normalizeHost(hostHeader);
72
+ if (!normalized || !allowedHosts.has(normalized)) {
73
+ respondHostNotAllowed(res);
74
+ return;
75
+ }
76
+ next();
77
+ };
78
+ }
79
+ function createOriginValidationMiddleware() {
80
+ const allowedHosts = buildAllowedHosts();
81
+ return (req, res, next) => {
82
+ const originHeader = getNonEmptyStringHeader(req.headers.origin);
83
+ if (!originHeader) {
84
+ next();
85
+ return;
86
+ }
87
+ const originHostname = tryParseOriginHostname(originHeader);
88
+ if (!originHostname || !allowedHosts.has(originHostname)) {
89
+ respondOriginNotAllowed(res);
90
+ return;
91
+ }
92
+ next();
93
+ };
94
+ }
95
+ function createJsonParseErrorHandler() {
96
+ return (err, _req, res, next) => {
97
+ if (err instanceof SyntaxError && 'body' in err) {
98
+ res.status(400).json({
99
+ jsonrpc: '2.0',
100
+ error: {
101
+ code: -32700,
102
+ message: 'Parse error: Invalid JSON',
103
+ },
104
+ id: null,
105
+ });
106
+ return;
107
+ }
108
+ next();
109
+ };
110
+ }
111
+ function createContextMiddleware() {
112
+ return (req, _res, next) => {
113
+ const requestId = randomUUID();
114
+ const sessionId = getSessionId(req);
115
+ const context = sessionId === undefined
116
+ ? { requestId, operationId: requestId }
117
+ : { requestId, operationId: requestId, sessionId };
118
+ runWithRequestContext(context, () => {
119
+ next();
120
+ });
121
+ };
122
+ }
123
+ function registerHealthRoute(app) {
124
+ app.get('/health', (_req, res) => {
125
+ res.json({
126
+ status: 'healthy',
127
+ name: config.server.name,
128
+ version: config.server.version,
129
+ uptime: process.uptime(),
130
+ });
131
+ });
132
+ }
133
+ export function attachBaseMiddleware(options) {
134
+ const { app, jsonParser, rateLimitMiddleware, corsMiddleware } = options;
135
+ app.use(createHostValidationMiddleware());
136
+ app.use(createOriginValidationMiddleware());
137
+ app.use(jsonParser);
138
+ app.use(createContextMiddleware());
139
+ app.use(createJsonParseErrorHandler());
140
+ app.use(corsMiddleware);
141
+ app.use('/mcp', rateLimitMiddleware);
142
+ registerHealthRoute(app);
143
+ }
@@ -1,7 +1,2 @@
1
1
  import type { NextFunction, Request, Response } from 'express';
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
2
  export declare function createCorsMiddleware(): (req: Request, res: Response, next: NextFunction) => void;
package/dist/http/cors.js CHANGED
@@ -1,11 +1,5 @@
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
1
  export function createCorsMiddleware() {
7
2
  return (req, res, next) => {
8
- // Handle OPTIONS preflight
9
3
  if (req.method === 'OPTIONS') {
10
4
  res.sendStatus(200);
11
5
  return;
@@ -3,7 +3,6 @@ import * as cache from '../services/cache.js';
3
3
  import { logDebug } from '../services/logger.js';
4
4
  import { parseCachedPayload, resolveCachedPayloadContent, } from '../utils/cached-payload.js';
5
5
  import { generateSafeFilename } from '../utils/filename-generator.js';
6
- import { wrapAsync } from './async-handler.js';
7
6
  const HASH_PATTERN = /^[a-f0-9.]+$/i;
8
7
  function validateNamespace(namespace) {
9
8
  return namespace === 'markdown';
@@ -11,8 +10,13 @@ function validateNamespace(namespace) {
11
10
  function validateHash(hash) {
12
11
  return HASH_PATTERN.test(hash) && hash.length >= 8 && hash.length <= 64;
13
12
  }
13
+ function isSingleParam(value) {
14
+ return typeof value === 'string';
15
+ }
14
16
  function parseDownloadParams(req) {
15
17
  const { namespace, hash } = req.params;
18
+ if (!isSingleParam(namespace) || !isSingleParam(hash))
19
+ return null;
16
20
  if (!namespace || !hash)
17
21
  return null;
18
22
  if (!validateNamespace(namespace))
@@ -96,5 +100,5 @@ function handleDownload(req, res) {
96
100
  sendDownloadPayload(res, payload);
97
101
  }
98
102
  export function registerDownloadRoutes(app) {
99
- app.get('/mcp/downloads/:namespace/:hash', wrapAsync(handleDownload));
103
+ app.get('/mcp/downloads/:namespace/:hash', handleDownload);
100
104
  }
@@ -0,0 +1,2 @@
1
+ import type { NextFunction, Request, Response } from 'express';
2
+ export declare function errorHandler(err: Error, req: Request, res: Response, next: NextFunction): void;
@@ -0,0 +1,55 @@
1
+ import { FetchError } from '../errors/app-error.js';
2
+ import { logError } from '../services/logger.js';
3
+ function getStatusCode(fetchError) {
4
+ return fetchError ? fetchError.statusCode : 500;
5
+ }
6
+ function getErrorCode(fetchError) {
7
+ return fetchError ? fetchError.code : 'INTERNAL_ERROR';
8
+ }
9
+ function getFetchErrorMessage(fetchError) {
10
+ return fetchError ? fetchError.message : 'Internal Server Error';
11
+ }
12
+ function getErrorDetails(fetchError) {
13
+ if (fetchError && Object.keys(fetchError.details).length > 0) {
14
+ return fetchError.details;
15
+ }
16
+ return undefined;
17
+ }
18
+ function resolveRetryAfter(fetchError) {
19
+ if (fetchError?.statusCode !== 429)
20
+ return undefined;
21
+ const { retryAfter } = fetchError.details;
22
+ return isRetryAfterValue(retryAfter) ? String(retryAfter) : undefined;
23
+ }
24
+ function isRetryAfterValue(value) {
25
+ return typeof value === 'number' || typeof value === 'string';
26
+ }
27
+ function setRetryAfterHeader(res, fetchError) {
28
+ const retryAfter = resolveRetryAfter(fetchError);
29
+ if (retryAfter === undefined)
30
+ return;
31
+ res.set('Retry-After', retryAfter);
32
+ }
33
+ function buildErrorResponse(fetchError) {
34
+ const details = getErrorDetails(fetchError);
35
+ const response = {
36
+ error: {
37
+ message: getFetchErrorMessage(fetchError),
38
+ code: getErrorCode(fetchError),
39
+ statusCode: getStatusCode(fetchError),
40
+ ...(details && { details }),
41
+ },
42
+ };
43
+ return response;
44
+ }
45
+ export function errorHandler(err, req, res, next) {
46
+ if (res.headersSent) {
47
+ next(err);
48
+ return;
49
+ }
50
+ const fetchError = err instanceof FetchError ? err : null;
51
+ const statusCode = getStatusCode(fetchError);
52
+ logError(`HTTP ${statusCode}: ${err.message} - ${req.method} ${req.path}`, err);
53
+ setRetryAfterHeader(res, fetchError);
54
+ res.status(statusCode).json(buildErrorResponse(fetchError));
55
+ }
@@ -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
+ }
@@ -1,3 +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;
@@ -1,11 +1,24 @@
1
+ import { z } from 'zod';
1
2
  import { logError, logInfo } from '../services/logger.js';
2
- import { acceptsEventStream, ensurePostAcceptHeader } from './accept-policy.js';
3
- import { wrapAsync } from './async-handler.js';
4
- import { sendJsonRpcError } from './jsonrpc-http.js';
5
- import { resolveTransportForPost, } from './mcp-session.js';
6
- import { isJsonRpcBatchRequest, isMcpRequestBody } from './mcp-validation.js';
7
- import { ensureMcpProtocolVersionHeader } from './protocol-policy.js';
8
- import { getSessionId } from './sessions.js';
3
+ import { getSessionId, resolveTransportForPost, sendJsonRpcError, } from './mcp-sessions.js';
4
+ const paramsSchema = z.looseObject({});
5
+ const mcpRequestSchema = z.looseObject({
6
+ jsonrpc: z.literal('2.0'),
7
+ method: z.string().min(1),
8
+ id: z.union([z.string(), z.number()]).optional(),
9
+ params: paramsSchema.optional(),
10
+ });
11
+ function wrapAsync(fn) {
12
+ return (req, res, next) => {
13
+ Promise.resolve(fn(req, res)).catch(next);
14
+ };
15
+ }
16
+ export function isJsonRpcBatchRequest(body) {
17
+ return Array.isArray(body);
18
+ }
19
+ export function isMcpRequestBody(body) {
20
+ return mcpRequestSchema.safeParse(body).success;
21
+ }
9
22
  function respondInvalidRequestBody(res) {
10
23
  sendJsonRpcError(res, -32600, 'Invalid Request: Malformed request body', 400);
11
24
  }
@@ -67,6 +80,81 @@ function resolveSessionTransport(sessionId, options, res) {
67
80
  sessionStore.touch(sessionId);
68
81
  return session.transport;
69
82
  }
83
+ const MCP_PROTOCOL_VERSION_HEADER = 'mcp-protocol-version';
84
+ const MCP_PROTOCOL_VERSIONS = {
85
+ defaultVersion: '2025-11-25',
86
+ supported: new Set(['2025-11-25']),
87
+ };
88
+ function getHeaderValue(req, headerNameLower) {
89
+ const value = req.headers[headerNameLower];
90
+ if (typeof value === 'string')
91
+ return value;
92
+ if (Array.isArray(value))
93
+ return value[0] ?? null;
94
+ return null;
95
+ }
96
+ function setHeaderValue(req, headerNameLower, value) {
97
+ // Express exposes req.headers as a plain object, but the type is readonly-ish.
98
+ req.headers[headerNameLower] = value;
99
+ }
100
+ export function ensureMcpProtocolVersionHeader(req, res) {
101
+ const raw = getHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER);
102
+ const version = raw?.trim();
103
+ if (!version) {
104
+ setHeaderValue(req, MCP_PROTOCOL_VERSION_HEADER, MCP_PROTOCOL_VERSIONS.defaultVersion);
105
+ return true;
106
+ }
107
+ if (!MCP_PROTOCOL_VERSIONS.supported.has(version)) {
108
+ sendJsonRpcError(res, -32600, `Unsupported MCP-Protocol-Version: ${version}`, 400);
109
+ return false;
110
+ }
111
+ return true;
112
+ }
113
+ function getAcceptHeader(req) {
114
+ const value = req.headers.accept;
115
+ if (typeof value === 'string')
116
+ return value;
117
+ return '';
118
+ }
119
+ function setAcceptHeader(req, value) {
120
+ req.headers.accept = value;
121
+ const { rawHeaders } = req;
122
+ if (!Array.isArray(rawHeaders))
123
+ return;
124
+ for (let i = 0; i + 1 < rawHeaders.length; i += 2) {
125
+ const key = rawHeaders[i];
126
+ if (typeof key === 'string' && key.toLowerCase() === 'accept') {
127
+ rawHeaders[i + 1] = value;
128
+ return;
129
+ }
130
+ }
131
+ rawHeaders.push('Accept', value);
132
+ }
133
+ function hasToken(header, token) {
134
+ return header
135
+ .split(',')
136
+ .map((part) => part.trim().toLowerCase())
137
+ .some((part) => part === token || part.startsWith(`${token};`));
138
+ }
139
+ export function ensurePostAcceptHeader(req) {
140
+ const accept = getAcceptHeader(req);
141
+ // Some clients send */* or omit Accept; the SDK transport is picky.
142
+ if (!accept || hasToken(accept, '*/*')) {
143
+ setAcceptHeader(req, 'application/json, text/event-stream');
144
+ return;
145
+ }
146
+ const hasJson = hasToken(accept, 'application/json');
147
+ const hasSse = hasToken(accept, 'text/event-stream');
148
+ if (!hasJson || !hasSse) {
149
+ setAcceptHeader(req, 'application/json, text/event-stream');
150
+ }
151
+ }
152
+ export function acceptsEventStream(req) {
153
+ const accept = getAcceptHeader(req);
154
+ if (!accept)
155
+ return false;
156
+ return hasToken(accept, 'text/event-stream');
157
+ }
70
158
  async function handlePost(req, res, options) {
71
159
  ensurePostAcceptHeader(req);
72
160
  if (!ensureMcpProtocolVersionHeader(req, res))
@@ -76,7 +164,12 @@ async function handlePost(req, res, options) {
76
164
  if (!payload)
77
165
  return;
78
166
  logPostRequest(payload, sessionId, options);
79
- const transport = await resolveTransportForPost(req, res, payload, sessionId, options);
167
+ const transport = await resolveTransportForPost({
168
+ res,
169
+ body: payload,
170
+ sessionId,
171
+ options,
172
+ });
80
173
  if (!transport)
81
174
  return;
82
175
  await handleTransportRequest(transport, req, res, payload);