@j0hanz/superfetch 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (95) hide show
  1. package/README.md +28 -17
  2. package/dist/config/index.js +11 -6
  3. package/dist/http/auth.js +161 -2
  4. package/dist/http/host-allowlist.d.ts +3 -0
  5. package/dist/http/host-allowlist.js +117 -0
  6. package/dist/http/mcp-routes.d.ts +8 -2
  7. package/dist/http/mcp-routes.js +101 -8
  8. package/dist/http/mcp-session-eviction.d.ts +3 -0
  9. package/dist/http/mcp-session-eviction.js +24 -0
  10. package/dist/http/mcp-session-init.d.ts +7 -0
  11. package/dist/http/mcp-session-init.js +94 -0
  12. package/dist/http/mcp-session-slots.d.ts +17 -0
  13. package/dist/http/mcp-session-slots.js +55 -0
  14. package/dist/http/mcp-session-transport-init.d.ts +7 -0
  15. package/dist/http/mcp-session-transport-init.js +41 -0
  16. package/dist/http/mcp-session-types.d.ts +5 -0
  17. package/dist/http/mcp-session-types.js +1 -0
  18. package/dist/http/mcp-session.d.ts +9 -9
  19. package/dist/http/mcp-session.js +5 -114
  20. package/dist/http/mcp-sessions.d.ts +43 -0
  21. package/dist/http/mcp-sessions.js +392 -0
  22. package/dist/http/rate-limit.js +2 -2
  23. package/dist/http/server-middleware.d.ts +6 -1
  24. package/dist/http/server-middleware.js +3 -117
  25. package/dist/http/server-shutdown.js +1 -1
  26. package/dist/http/server.d.ts +10 -0
  27. package/dist/http/server.js +508 -11
  28. package/dist/http/session-cleanup.js +8 -5
  29. package/dist/middleware/error-handler.d.ts +1 -1
  30. package/dist/middleware/error-handler.js +31 -30
  31. package/dist/resources/cached-content-params.d.ts +5 -0
  32. package/dist/resources/cached-content-params.js +36 -0
  33. package/dist/resources/cached-content.js +33 -33
  34. package/dist/server.js +1 -1
  35. package/dist/services/cache-events.d.ts +8 -0
  36. package/dist/services/cache-events.js +19 -0
  37. package/dist/services/cache.d.ts +5 -4
  38. package/dist/services/cache.js +49 -45
  39. package/dist/services/extractor.js +49 -38
  40. package/dist/services/fetcher/agents.js +1 -1
  41. package/dist/services/fetcher/dns-selection.js +1 -1
  42. package/dist/services/fetcher/interceptors.js +29 -60
  43. package/dist/services/fetcher/redirects.js +12 -4
  44. package/dist/services/fetcher/response.js +18 -8
  45. package/dist/services/fetcher.d.ts +21 -0
  46. package/dist/services/fetcher.js +532 -13
  47. package/dist/tools/handlers/fetch-single.shared.d.ts +11 -3
  48. package/dist/tools/handlers/fetch-single.shared.js +131 -2
  49. package/dist/tools/handlers/fetch-url.tool.d.ts +6 -0
  50. package/dist/tools/handlers/fetch-url.tool.js +48 -6
  51. package/dist/tools/utils/content-shaping.js +19 -4
  52. package/dist/tools/utils/content-transform.d.ts +4 -1
  53. package/dist/tools/utils/content-transform.js +110 -96
  54. package/dist/tools/utils/fetch-pipeline.js +47 -56
  55. package/dist/tools/utils/frontmatter.d.ts +3 -0
  56. package/dist/tools/utils/frontmatter.js +73 -0
  57. package/dist/tools/utils/markdown-heuristics.d.ts +1 -0
  58. package/dist/tools/utils/markdown-heuristics.js +19 -0
  59. package/dist/tools/utils/markdown-signals.d.ts +1 -0
  60. package/dist/tools/utils/markdown-signals.js +19 -0
  61. package/dist/tools/utils/raw-markdown-frontmatter.d.ts +3 -0
  62. package/dist/tools/utils/raw-markdown-frontmatter.js +73 -0
  63. package/dist/tools/utils/raw-markdown.d.ts +6 -0
  64. package/dist/tools/utils/raw-markdown.js +135 -0
  65. package/dist/transformers/markdown/fenced-code-rule.d.ts +2 -0
  66. package/dist/transformers/markdown/fenced-code-rule.js +38 -0
  67. package/dist/transformers/markdown/frontmatter.d.ts +2 -0
  68. package/dist/transformers/markdown/frontmatter.js +45 -0
  69. package/dist/transformers/markdown/noise-rule.d.ts +2 -0
  70. package/dist/transformers/markdown/noise-rule.js +80 -0
  71. package/dist/transformers/markdown/turndown-instance.d.ts +2 -0
  72. package/dist/transformers/markdown/turndown-instance.js +19 -0
  73. package/dist/transformers/markdown.d.ts +2 -0
  74. package/dist/transformers/markdown.js +185 -0
  75. package/dist/transformers/markdown.transformer.js +2 -189
  76. package/dist/utils/code-language-bash.d.ts +1 -0
  77. package/dist/utils/code-language-bash.js +48 -0
  78. package/dist/utils/code-language-core.d.ts +2 -0
  79. package/dist/utils/code-language-core.js +13 -0
  80. package/dist/utils/code-language-detectors.d.ts +5 -0
  81. package/dist/utils/code-language-detectors.js +142 -0
  82. package/dist/utils/code-language-helpers.d.ts +5 -0
  83. package/dist/utils/code-language-helpers.js +62 -0
  84. package/dist/utils/code-language-parsing.d.ts +5 -0
  85. package/dist/utils/code-language-parsing.js +62 -0
  86. package/dist/utils/code-language.d.ts +9 -0
  87. package/dist/utils/code-language.js +250 -46
  88. package/dist/utils/error-details.d.ts +3 -0
  89. package/dist/utils/error-details.js +12 -0
  90. package/dist/utils/filename-generator.js +14 -3
  91. package/dist/utils/ip-address.d.ts +4 -0
  92. package/dist/utils/ip-address.js +6 -0
  93. package/dist/utils/tool-error-handler.js +12 -17
  94. package/dist/utils/url-validator.js +33 -21
  95. package/package.json +7 -5
package/README.md CHANGED
@@ -23,15 +23,15 @@ A [Model Context Protocol](https://modelcontextprotocol.io/) (MCP) server that f
23
23
 
24
24
  ## Features
25
25
 
26
- | Feature | Description |
27
- | -------------------- | ------------------------------------------------------------------------------------- |
28
- | Smart extraction | Mozilla Readability with quality gates to strip boilerplate when it improves results |
29
- | Clean Markdown | Markdown output with optional YAML frontmatter (title + source) |
30
- | Raw content handling | Preserves raw markdown/text and rewrites GitHub/GitLab/Bitbucket blob URLs to raw |
31
- | Built-in caching | In-memory cache with TTL, max keys, and resource subscriptions |
32
- | Resilient fetching | Redirect handling with validation, timeouts, and response size limits |
33
- | Security first | URL validation plus SSRF/DNS/IP blocklists |
34
- | HTTP mode | Static token or OAuth auth, session management, rate limiting, host/origin validation |
26
+ | Feature | Description |
27
+ | -------------------- | ------------------------------------------------------------------------------------------------------------------ |
28
+ | Smart extraction | Mozilla Readability with quality gates to strip boilerplate when it improves results |
29
+ | Clean Markdown | Markdown output with optional YAML frontmatter (title + source) |
30
+ | Raw content handling | Preserves raw markdown/text, detects common text extensions, and rewrites GitHub/GitLab/Bitbucket/Gist URLs to raw |
31
+ | Built-in caching | In-memory cache with TTL, max keys, and resource subscriptions |
32
+ | Resilient fetching | Redirect handling with validation, timeouts, and response size limits |
33
+ | Security first | URL validation plus SSRF/DNS/IP blocklists |
34
+ | HTTP mode | Static token or OAuth auth, session management, rate limiting, host/origin validation |
35
35
 
36
36
  ---
37
37
 
@@ -243,13 +243,15 @@ npx -y @j0hanz/superfetch@latest
243
243
 
244
244
  For multiple static tokens, set `ACCESS_TOKENS` (comma/space separated).
245
245
 
246
- Endpoints (auth required via `Authorization: Bearer <token>`; in static token mode, `X-API-Key` is also accepted):
246
+ Auth is required for `/mcp` and `/mcp/downloads` via `Authorization: Bearer <token>` (static mode also accepts `X-API-Key`).
247
247
 
248
- - `GET /health`
249
- - `POST /mcp`
250
- - `GET /mcp` (SSE stream)
251
- - `DELETE /mcp`
252
- - `GET /mcp/downloads/:namespace/:hash`
248
+ Endpoints:
249
+
250
+ - `GET /health` (no auth; returns status, name, version, uptime)
251
+ - `POST /mcp` (auth required)
252
+ - `GET /mcp` (auth required; SSE stream; requires `Accept: text/event-stream`)
253
+ - `DELETE /mcp` (auth required)
254
+ - `GET /mcp/downloads/:namespace/:hash` (auth required)
253
255
 
254
256
  Sessions are managed via the `mcp-session-id` header (see [HTTP Mode Details](#http-mode-details)).
255
257
 
@@ -268,6 +270,7 @@ The response includes:
268
270
  - a `text` block containing JSON of `structuredContent`
269
271
  - a `resource` block embedding markdown when inline content is available (always in stdio mode)
270
272
  - when content exceeds the inline limit and cache is enabled, a `resource_link` block pointing to `superfetch://cache/...` (inline markdown may be omitted)
273
+ - error responses set `isError: true` and return `structuredContent` with `error` and `url`
271
274
 
272
275
  ---
273
276
 
@@ -415,6 +418,7 @@ Optional:
415
418
  - Inline markdown limit: 20,000 characters
416
419
  - Cache max entries: 100
417
420
  - Session TTL: 30 minutes
421
+ - Session init timeout: 10 seconds
418
422
  - Max sessions: 200
419
423
  - Rate limit: 100 req/min per IP (60s window)
420
424
 
@@ -434,6 +438,11 @@ If the `mcp-protocol-version` header is missing, the server defaults it to `2025
434
438
 
435
439
  `GET /mcp` and `DELETE /mcp` require `mcp-session-id`. `POST /mcp` without an `initialize` request will return 400.
436
440
 
441
+ Additional HTTP transport notes:
442
+
443
+ - `GET /mcp` requires `Accept: text/event-stream` (otherwise 406).
444
+ - JSON-RPC batch requests are not supported (400).
445
+
437
446
  If the server reaches its session cap (200), it evicts the oldest session when possible; otherwise it returns a 503.
438
447
 
439
448
  Host and Origin headers are always validated. Allowed values include loopback hosts, the configured `HOST` (if not a wildcard), and any entries in `ALLOWED_HOSTS`. When binding to `0.0.0.0` or `::`, set `ALLOWED_HOSTS` to the hostnames clients will send.
@@ -484,12 +493,14 @@ Rate limiting applies to `/mcp` and `/mcp/downloads` (100 req/min per IP, 60s wi
484
493
  | `npm run build` | Compile TypeScript |
485
494
  | `npm start` | Production server |
486
495
  | `npm run lint` | Run ESLint |
496
+ | `npm run lint:fix` | Auto-fix lint issues |
487
497
  | `npm run type-check` | TypeScript type checking |
488
498
  | `npm run format` | Format with Prettier |
489
499
  | `npm test` | Run Node test runner (builds dist) |
490
500
  | `npm run test:coverage` | Run tests with experimental coverage |
491
501
  | `npm run knip` | Find unused exports/dependencies |
492
502
  | `npm run knip:fix` | Auto-fix unused code |
503
+ | `npm run inspector` | Launch MCP Inspector |
493
504
 
494
505
  > **Note:** Tests run via `node --test` with `--experimental-transform-types` to execute `.ts` test files. Node will emit an experimental warning.
495
506
 
@@ -499,9 +510,9 @@ Rate limiting applies to `/mcp` and `/mcp/downloads` (100 req/min per IP, 60s wi
499
510
  | ------------------ | --------------------------------- |
500
511
  | Runtime | Node.js >=20.12 |
501
512
  | Language | TypeScript 5.9 |
502
- | MCP SDK | @modelcontextprotocol/sdk ^1.25.1 |
513
+ | MCP SDK | @modelcontextprotocol/sdk ^1.25.2 |
503
514
  | Content Extraction | @mozilla/readability ^0.6.0 |
504
- | HTML Parsing | LinkeDOM ^0.18.12 |
515
+ | HTML Parsing | linkedom ^0.18.12 |
505
516
  | Markdown | Turndown ^7.2.2 |
506
517
  | HTTP | Express ^5.2.1, undici ^6.23.0 |
507
518
  | Validation | Zod ^4.3.5 |
@@ -1,3 +1,4 @@
1
+ import { buildIpv4 } from '../utils/ip-address.js';
1
2
  import packageJson from '../../package.json' with { type: 'json' };
2
3
  import { buildAuthConfig } from './auth-config.js';
3
4
  import { SIZE_LIMITS, TIMEOUT } from './constants.js';
@@ -8,10 +9,14 @@ function formatHostForUrl(hostname) {
8
9
  }
9
10
  return hostname;
10
11
  }
11
- const host = process.env.HOST ?? '127.0.0.1';
12
+ const LOOPBACK_V4 = buildIpv4([127, 0, 0, 1]);
13
+ const ANY_V4 = buildIpv4([0, 0, 0, 0]);
14
+ const METADATA_V4_AWS = buildIpv4([169, 254, 169, 254]);
15
+ const METADATA_V4_AZURE = buildIpv4([100, 100, 100, 200]);
16
+ const host = process.env.HOST ?? LOOPBACK_V4;
12
17
  const port = parseInteger(process.env.PORT, 3000, 1024, 65535);
13
18
  const baseUrl = new URL(`http://${formatHostForUrl(host)}:${port}`);
14
- const isRemoteHost = host === '0.0.0.0' || host === '::';
19
+ const isRemoteHost = host === ANY_V4 || host === '::';
15
20
  const runtimeState = {
16
21
  httpMode: false,
17
22
  };
@@ -51,13 +56,13 @@ export const config = {
51
56
  security: {
52
57
  blockedHosts: new Set([
53
58
  'localhost',
54
- '127.0.0.1',
55
- '0.0.0.0',
59
+ LOOPBACK_V4,
60
+ ANY_V4,
56
61
  '::1',
57
- '169.254.169.254',
62
+ METADATA_V4_AWS,
58
63
  'metadata.google.internal',
59
64
  'metadata.azure.com',
60
- '100.100.100.200',
65
+ METADATA_V4_AZURE,
61
66
  'instance-data',
62
67
  ]),
63
68
  blockedIpPatterns: [
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,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-03-26',
86
+ supported: new Set(['2025-03-26', '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);
@@ -0,0 +1,3 @@
1
+ import type { SessionStore } from './sessions.js';
2
+ export declare function evictExpiredSessions(store: SessionStore): number;
3
+ export declare function evictOldestSession(store: SessionStore): boolean;
@@ -0,0 +1,24 @@
1
+ import { logWarn } from '../services/logger.js';
2
+ import { getErrorMessage } from '../utils/error-details.js';
3
+ export function evictExpiredSessions(store) {
4
+ const evicted = store.evictExpired();
5
+ for (const session of evicted) {
6
+ void session.transport.close().catch((error) => {
7
+ logWarn('Failed to close expired session', {
8
+ error: getErrorMessage(error),
9
+ });
10
+ });
11
+ }
12
+ return evicted.length;
13
+ }
14
+ export function evictOldestSession(store) {
15
+ const session = store.evictOldest();
16
+ if (!session)
17
+ return false;
18
+ void session.transport.close().catch((error) => {
19
+ logWarn('Failed to close evicted session', {
20
+ error: getErrorMessage(error),
21
+ });
22
+ });
23
+ return true;
24
+ }
@@ -0,0 +1,7 @@
1
+ import type { Response } from 'express';
2
+ import type { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
3
+ import type { McpSessionOptions } from './mcp-session-types.js';
4
+ export declare function createAndConnectTransport({ options, res, }: {
5
+ options: McpSessionOptions;
6
+ res: Response;
7
+ }): Promise<StreamableHTTPServerTransport | null>;