fa-mcp-sdk 0.4.142 → 0.11.2

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 (200) hide show
  1. package/README.md +5 -0
  2. package/cli-template/.dockerignore +16 -0
  3. package/cli-template/.gitlab-ci.yml +135 -0
  4. package/cli-template/AGENTS.md +1 -0
  5. package/cli-template/CHANGELOG.md +64 -0
  6. package/cli-template/FA-MCP-SDK-DOC/00-FA-MCP-SDK-index.md +27 -4
  7. package/cli-template/FA-MCP-SDK-DOC/02-1-tools-and-api.md +195 -0
  8. package/cli-template/FA-MCP-SDK-DOC/02-2-prompts-and-resources.md +172 -9
  9. package/cli-template/FA-MCP-SDK-DOC/03-configuration.md +170 -12
  10. package/cli-template/FA-MCP-SDK-DOC/04-authentication.md +158 -8
  11. package/cli-template/FA-MCP-SDK-DOC/06-utilities.md +67 -6
  12. package/cli-template/FA-MCP-SDK-DOC/07-testing-and-operations.md +31 -15
  13. package/cli-template/FA-MCP-SDK-DOC/10-mcp-apps.md +1 -1
  14. package/cli-template/FA-MCP-SDK-DOC/11-public-contract.md +342 -0
  15. package/cli-template/README.md +37 -0
  16. package/cli-template/deploy/docker/.env.example +10 -0
  17. package/cli-template/deploy/docker/Dockerfile +44 -0
  18. package/cli-template/deploy/docker/Dockerfile.local +29 -0
  19. package/cli-template/deploy/docker/README.md +94 -0
  20. package/cli-template/deploy/docker/config/local.docker.yaml +14 -0
  21. package/cli-template/deploy/docker/docker-compose.yml +31 -0
  22. package/cli-template/deploy/gitlab-runner/.env.example +16 -0
  23. package/cli-template/deploy/gitlab-runner/README.md +65 -0
  24. package/cli-template/deploy/gitlab-runner/config/config.toml.template +26 -0
  25. package/cli-template/deploy/gitlab-runner/docker-compose.yml +39 -0
  26. package/cli-template/deploy/gitlab-runner/entrypoint.sh +27 -0
  27. package/cli-template/deploy/gitlab-runner/start.sh +47 -0
  28. package/cli-template/gitignore +96 -95
  29. package/cli-template/package.json +1 -1
  30. package/config/_local.yaml +73 -11
  31. package/config/custom-environment-variables.yaml +102 -0
  32. package/config/default.yaml +164 -11
  33. package/config/local.yaml +20 -19
  34. package/dist/core/_types_/config.d.ts +119 -0
  35. package/dist/core/_types_/config.d.ts.map +1 -1
  36. package/dist/core/_types_/types.d.ts +137 -4
  37. package/dist/core/_types_/types.d.ts.map +1 -1
  38. package/dist/core/agent-tester/agent-tester-router.d.ts.map +1 -1
  39. package/dist/core/agent-tester/agent-tester-router.js +25 -11
  40. package/dist/core/agent-tester/agent-tester-router.js.map +1 -1
  41. package/dist/core/agent-tester/services/TesterMcpClientService.d.ts.map +1 -1
  42. package/dist/core/agent-tester/services/TesterMcpClientService.js +6 -4
  43. package/dist/core/agent-tester/services/TesterMcpClientService.js.map +1 -1
  44. package/dist/core/auth/admin-auth.js +4 -4
  45. package/dist/core/auth/admin-auth.js.map +1 -1
  46. package/dist/core/auth/agent-tester-auth.d.ts +1 -1
  47. package/dist/core/auth/agent-tester-auth.d.ts.map +1 -1
  48. package/dist/core/auth/agent-tester-auth.js +8 -4
  49. package/dist/core/auth/agent-tester-auth.js.map +1 -1
  50. package/dist/core/auth/auth-profile.d.ts +38 -0
  51. package/dist/core/auth/auth-profile.d.ts.map +1 -0
  52. package/dist/core/auth/auth-profile.js +101 -0
  53. package/dist/core/auth/auth-profile.js.map +1 -0
  54. package/dist/core/auth/jwt-v2.d.ts +27 -0
  55. package/dist/core/auth/jwt-v2.d.ts.map +1 -0
  56. package/dist/core/auth/jwt-v2.js +180 -0
  57. package/dist/core/auth/jwt-v2.js.map +1 -0
  58. package/dist/core/auth/jwt.d.ts +27 -13
  59. package/dist/core/auth/jwt.d.ts.map +1 -1
  60. package/dist/core/auth/jwt.js +36 -13
  61. package/dist/core/auth/jwt.js.map +1 -1
  62. package/dist/core/auth/key-resolver.d.ts +74 -0
  63. package/dist/core/auth/key-resolver.d.ts.map +1 -0
  64. package/dist/core/auth/key-resolver.js +330 -0
  65. package/dist/core/auth/key-resolver.js.map +1 -0
  66. package/dist/core/auth/middleware.d.ts.map +1 -1
  67. package/dist/core/auth/middleware.js +66 -0
  68. package/dist/core/auth/middleware.js.map +1 -1
  69. package/dist/core/auth/multi-auth.d.ts +1 -1
  70. package/dist/core/auth/multi-auth.d.ts.map +1 -1
  71. package/dist/core/auth/multi-auth.js +7 -7
  72. package/dist/core/auth/multi-auth.js.map +1 -1
  73. package/dist/core/auth/token-generator/server.js +4 -4
  74. package/dist/core/auth/token-generator/server.js.map +1 -1
  75. package/dist/core/auth/types.d.ts +5 -0
  76. package/dist/core/auth/types.d.ts.map +1 -1
  77. package/dist/core/db/pg-db.d.ts +7 -0
  78. package/dist/core/db/pg-db.d.ts.map +1 -1
  79. package/dist/core/db/pg-db.js +54 -3
  80. package/dist/core/db/pg-db.js.map +1 -1
  81. package/dist/core/errors/BaseMcpError.d.ts +21 -1
  82. package/dist/core/errors/BaseMcpError.d.ts.map +1 -1
  83. package/dist/core/errors/BaseMcpError.js +20 -1
  84. package/dist/core/errors/BaseMcpError.js.map +1 -1
  85. package/dist/core/errors/ValidationError.d.ts +5 -0
  86. package/dist/core/errors/ValidationError.d.ts.map +1 -1
  87. package/dist/core/errors/ValidationError.js +6 -1
  88. package/dist/core/errors/ValidationError.js.map +1 -1
  89. package/dist/core/errors/errors.d.ts +31 -3
  90. package/dist/core/errors/errors.d.ts.map +1 -1
  91. package/dist/core/errors/errors.js +86 -6
  92. package/dist/core/errors/errors.js.map +1 -1
  93. package/dist/core/errors/specific-errors.d.ts +54 -0
  94. package/dist/core/errors/specific-errors.d.ts.map +1 -0
  95. package/dist/core/errors/specific-errors.js +82 -0
  96. package/dist/core/errors/specific-errors.js.map +1 -0
  97. package/dist/core/index.d.ts +10 -2
  98. package/dist/core/index.d.ts.map +1 -1
  99. package/dist/core/index.js +9 -1
  100. package/dist/core/index.js.map +1 -1
  101. package/dist/core/init-mcp-server.d.ts.map +1 -1
  102. package/dist/core/init-mcp-server.js +39 -0
  103. package/dist/core/init-mcp-server.js.map +1 -1
  104. package/dist/core/mcp/create-mcp-server.d.ts +12 -6
  105. package/dist/core/mcp/create-mcp-server.d.ts.map +1 -1
  106. package/dist/core/mcp/create-mcp-server.js +592 -33
  107. package/dist/core/mcp/create-mcp-server.js.map +1 -1
  108. package/dist/core/mcp/debug-trace.d.ts +3 -1
  109. package/dist/core/mcp/debug-trace.d.ts.map +1 -1
  110. package/dist/core/mcp/debug-trace.js +17 -2
  111. package/dist/core/mcp/debug-trace.js.map +1 -1
  112. package/dist/core/mcp/deprecation.d.ts +31 -0
  113. package/dist/core/mcp/deprecation.d.ts.map +1 -0
  114. package/dist/core/mcp/deprecation.js +96 -0
  115. package/dist/core/mcp/deprecation.js.map +1 -0
  116. package/dist/core/mcp/mcp-logging.d.ts +32 -0
  117. package/dist/core/mcp/mcp-logging.d.ts.map +1 -0
  118. package/dist/core/mcp/mcp-logging.js +97 -0
  119. package/dist/core/mcp/mcp-logging.js.map +1 -0
  120. package/dist/core/mcp/pagination.d.ts +13 -0
  121. package/dist/core/mcp/pagination.d.ts.map +1 -0
  122. package/dist/core/mcp/pagination.js +50 -0
  123. package/dist/core/mcp/pagination.js.map +1 -0
  124. package/dist/core/mcp/prompts.d.ts +5 -1
  125. package/dist/core/mcp/prompts.d.ts.map +1 -1
  126. package/dist/core/mcp/prompts.js +3 -1
  127. package/dist/core/mcp/prompts.js.map +1 -1
  128. package/dist/core/mcp/resources.d.ts +9 -0
  129. package/dist/core/mcp/resources.d.ts.map +1 -1
  130. package/dist/core/mcp/resources.js +158 -11
  131. package/dist/core/mcp/resources.js.map +1 -1
  132. package/dist/core/mcp/server-stdio.d.ts +7 -1
  133. package/dist/core/mcp/server-stdio.d.ts.map +1 -1
  134. package/dist/core/mcp/server-stdio.js +8 -3
  135. package/dist/core/mcp/server-stdio.js.map +1 -1
  136. package/dist/core/mcp/task-store.d.ts +97 -0
  137. package/dist/core/mcp/task-store.d.ts.map +1 -0
  138. package/dist/core/mcp/task-store.js +175 -0
  139. package/dist/core/mcp/task-store.js.map +1 -0
  140. package/dist/core/mcp/tool-limits.d.ts +22 -0
  141. package/dist/core/mcp/tool-limits.d.ts.map +1 -0
  142. package/dist/core/mcp/tool-limits.js +115 -0
  143. package/dist/core/mcp/tool-limits.js.map +1 -0
  144. package/dist/core/mcp/validate-tool-args.d.ts +16 -0
  145. package/dist/core/mcp/validate-tool-args.d.ts.map +1 -0
  146. package/dist/core/mcp/validate-tool-args.js +67 -0
  147. package/dist/core/mcp/validate-tool-args.js.map +1 -0
  148. package/dist/core/mcp/validate-tool-names.d.ts +11 -0
  149. package/dist/core/mcp/validate-tool-names.d.ts.map +1 -0
  150. package/dist/core/mcp/validate-tool-names.js +23 -0
  151. package/dist/core/mcp/validate-tool-names.js.map +1 -0
  152. package/dist/core/metrics/metrics.d.ts +45 -0
  153. package/dist/core/metrics/metrics.d.ts.map +1 -0
  154. package/dist/core/metrics/metrics.js +119 -0
  155. package/dist/core/metrics/metrics.js.map +1 -0
  156. package/dist/core/utils/mask-sensitive.d.ts +44 -0
  157. package/dist/core/utils/mask-sensitive.d.ts.map +1 -0
  158. package/dist/core/utils/mask-sensitive.js +64 -0
  159. package/dist/core/utils/mask-sensitive.js.map +1 -0
  160. package/dist/core/utils/testing/McpHttpClient.d.ts +8 -33
  161. package/dist/core/utils/testing/McpHttpClient.d.ts.map +1 -1
  162. package/dist/core/utils/testing/McpHttpClient.js +8 -74
  163. package/dist/core/utils/testing/McpHttpClient.js.map +1 -1
  164. package/dist/core/utils/testing/McpStreamableHttpClient.d.ts +24 -30
  165. package/dist/core/utils/testing/McpStreamableHttpClient.d.ts.map +1 -1
  166. package/dist/core/utils/testing/McpStreamableHttpClient.js +36 -198
  167. package/dist/core/utils/testing/McpStreamableHttpClient.js.map +1 -1
  168. package/dist/core/utils/utils.d.ts.map +1 -1
  169. package/dist/core/utils/utils.js +2 -0
  170. package/dist/core/utils/utils.js.map +1 -1
  171. package/dist/core/web/admin-router.js +3 -3
  172. package/dist/core/web/admin-router.js.map +1 -1
  173. package/dist/core/web/cors.d.ts +9 -1
  174. package/dist/core/web/cors.d.ts.map +1 -1
  175. package/dist/core/web/cors.js +26 -5
  176. package/dist/core/web/cors.js.map +1 -1
  177. package/dist/core/web/event-store.d.ts +33 -0
  178. package/dist/core/web/event-store.d.ts.map +1 -0
  179. package/dist/core/web/event-store.js +65 -0
  180. package/dist/core/web/event-store.js.map +1 -0
  181. package/dist/core/web/oauth-router.d.ts +37 -0
  182. package/dist/core/web/oauth-router.d.ts.map +1 -0
  183. package/dist/core/web/oauth-router.js +207 -0
  184. package/dist/core/web/oauth-router.js.map +1 -0
  185. package/dist/core/web/request-id.d.ts +44 -0
  186. package/dist/core/web/request-id.d.ts.map +1 -0
  187. package/dist/core/web/request-id.js +82 -0
  188. package/dist/core/web/request-id.js.map +1 -0
  189. package/dist/core/web/server-http.d.ts.map +1 -1
  190. package/dist/core/web/server-http.js +322 -182
  191. package/dist/core/web/server-http.js.map +1 -1
  192. package/package.json +15 -2
  193. package/scripts/claude-2-agents-symlink.js +10 -1
  194. package/scripts/generate-jwt.js +129 -51
  195. package/src/template/custom-resources.ts +14 -0
  196. package/src/template/prompts/custom-prompts.ts +4 -0
  197. package/src/template/tools/handle-tool-call.ts +59 -3
  198. package/src/template/tools/tools.ts +92 -31
  199. package/src/tests/mcp/test-http.js +1 -1
  200. package/src/tests/mcp/test-sse.js +1 -1
@@ -1,7 +1,10 @@
1
+ import { randomUUID } from 'node:crypto';
1
2
  import { dirname, join } from 'path';
2
3
  import { fileURLToPath } from 'url';
3
4
  import { SSEServerTransport } from '@modelcontextprotocol/sdk/server/sse.js';
4
- import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, } from '@modelcontextprotocol/sdk/types.js';
5
+ import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
6
+ import { InMemoryEventStore } from './event-store.js';
7
+ import { CallToolRequestSchema, ListToolsRequestSchema, ListPromptsRequestSchema, ListResourcesRequestSchema, ReadResourceRequestSchema, isInitializeRequest, } from '@modelcontextprotocol/sdk/types.js';
5
8
  import chalk from 'chalk';
6
9
  import express from 'express';
7
10
  import helmet from 'helmet';
@@ -10,24 +13,28 @@ import { createAgentTesterRouter } from '../agent-tester/agent-tester-router.js'
10
13
  import { validateAdminAuthConfig } from '../auth/admin-auth.js';
11
14
  import { createAgentTesterSessionMW } from '../auth/agent-tester-auth.js';
12
15
  import { checkJwtToken, generateToken, MIN_ENCRYPT_KEY_LENGTH } from '../auth/jwt.js';
16
+ import { canLocallyIssueJwt, getJwtRuntimeConfig } from '../auth/key-resolver.js';
13
17
  import { createAuthMW } from '../auth/middleware.js';
14
18
  import { checkPermanentToken } from '../auth/permanent.js';
15
19
  import { appConfig, getProjectData } from '../bootstrap/init-config.js';
16
- import { debugMcpNotification } from '../debug.js';
17
20
  import { getMainDBConnectionStatus } from '../db/pg-db.js';
18
- import { BaseMcpError } from '../errors/BaseMcpError.js';
19
21
  import { createJsonRpcErrorResponse, ServerError, toError, toStr } from '../errors/errors.js';
22
+ import { PayloadTooLargeError, RateLimitedError, ResourceNotFoundError, TimeoutError, } from '../errors/specific-errors.js';
20
23
  import { logger as lgr } from '../logger.js';
24
+ import { getMetrics, getMetricsRegistry, initMetrics } from '../metrics/metrics.js';
21
25
  import { createMcpServer } from '../mcp/create-mcp-server.js';
22
- import { getPrompt, getPromptsList } from '../mcp/prompts.js';
26
+ import { getPromptsList } from '../mcp/prompts.js';
23
27
  import { getResource, getResourcesList } from '../mcp/resources.js';
28
+ import { truncateToolResponse, withToolTimeout } from '../mcp/tool-limits.js';
24
29
  import { formatRateLimitError, isRateLimitError } from '../utils/rate-limit.js';
25
30
  import { getTools, normalizeHeaders } from '../utils/utils.js';
26
31
  import { createAdminRouter } from './admin-router.js';
27
32
  import { applyCors } from './cors.js';
28
33
  import { faviconSvg } from './favicon-svg.js';
29
34
  import { handleHomeInfo } from './home-api.js';
35
+ import { createOAuthRouter } from './oauth-router.js';
30
36
  import { configureOpenAPI, createSwaggerUIAssetsMiddleware } from './openapi.js';
37
+ import { requestIdMW } from './request-id.js';
31
38
  import { createSvgRouter } from './svg-icons.js';
32
39
  const __filename = fileURLToPath(import.meta.url);
33
40
  const __dirname = dirname(__filename);
@@ -35,6 +42,26 @@ const __dirname = dirname(__filename);
35
42
  const staticPath = join(__dirname, 'static');
36
43
  const logger = lgr.getSubLogger({ name: chalk.bgYellow('server-http') });
37
44
  export const isAdminEnabled = appConfig.adminPanel?.enabled === true;
45
+ /**
46
+ * Standard §14 — pick the rate-limit bucket key from the request:
47
+ * - `scope: 'subject'` → JWT `sub`/`user` claim, falling back to req.ip when no auth payload
48
+ * - `scope: 'ip'` → req.ip / unknown
49
+ */
50
+ function resolveRateLimitKey(req, suffix = '') {
51
+ const scope = appConfig.mcp.rateLimit?.scope ?? 'subject';
52
+ let key = '';
53
+ if (scope === 'subject') {
54
+ const payload = req.auth?.payload ?? req.authInfo?.payload;
55
+ const sub = payload?.sub ?? payload?.user ?? req.authInfo?.username;
56
+ if (sub && String(sub).trim()) {
57
+ key = `sub:${String(sub).trim().toLowerCase()}`;
58
+ }
59
+ }
60
+ if (!key) {
61
+ key = `ip:${req.ip || 'unknown'}`;
62
+ }
63
+ return suffix ? `${suffix}-${key}` : key;
64
+ }
38
65
  /**
39
66
  * Handle rate limiting with consistent error response
40
67
  */
@@ -46,19 +73,19 @@ async function handleRateLimit(rateLimiter, clientId, ip, context = '', res, id)
46
73
  if (isRateLimitError(rateLimitError)) {
47
74
  const rateLimitMessage = formatRateLimitError(rateLimitError, appConfig.mcp.rateLimit.maxRequests);
48
75
  logger.warn(`Rate limit exceeded${context ? ` in ${context}` : ''}: ip: ${ip}`);
76
+ const scope = (appConfig.mcp.rateLimit?.scope ?? 'subject');
77
+ getMetrics()?.rateLimitHits.inc({ scope });
78
+ // Standard §14 + Appendix B: HTTP 429, JSON-RPC code -32003, `Retry-After` header in
79
+ // seconds (also mirrored under `error.data.retryAfter` per Appendix B.3).
80
+ const retryAfterSec = Math.max(1, Math.ceil((rateLimitError.msBeforeNext ?? 1000) / 1000));
81
+ const error = new RateLimitedError(rateLimitMessage, retryAfterSec);
49
82
  if (res) {
50
- res.status(200).json({
51
- jsonrpc: '2.0',
52
- id: id ?? 1,
53
- error: {
54
- code: -32000,
55
- message: rateLimitMessage,
56
- },
57
- });
83
+ res.setHeader('Retry-After', String(retryAfterSec));
84
+ res.status(error.statusCode).json(createJsonRpcErrorResponse(error, id ?? null));
58
85
  return;
59
86
  }
60
87
  else {
61
- throw new Error(rateLimitMessage, { cause: rateLimitError });
88
+ throw error;
62
89
  }
63
90
  }
64
91
  throw rateLimitError;
@@ -69,6 +96,11 @@ async function handleRateLimit(rateLimiter, clientId, ip, context = '', res, id)
69
96
  */
70
97
  export async function startHttpServer() {
71
98
  const app = express();
99
+ // Express `trust proxy`. Required for /.well-known/openid-configuration when the server
100
+ // sits behind HTTPS reverse proxy (X-Forwarded-Proto / X-Forwarded-Host).
101
+ if (appConfig.webServer.trustProxy !== undefined) {
102
+ app.set('trust proxy', appConfig.webServer.trustProxy);
103
+ }
72
104
  // Initialize rate limiter
73
105
  const rateLimiter = new RateLimiterMemory({
74
106
  keyPrefix: appConfig.shortName,
@@ -77,15 +109,34 @@ export async function startHttpServer() {
77
109
  });
78
110
  // Create universal auth middleware for all endpoints
79
111
  const authMW = createAuthMW();
112
+ // Standard §15.1 — sticky `X-Request-Id` (+ W3C traceparent/tracestate) MUST be installed
113
+ // before CORS, auth or any handler that may shortcut the chain — otherwise 401/403
114
+ // responses would land without a correlation id, breaking downstream debugging.
115
+ app.use(requestIdMW());
116
+ // Standard §15.3 — Prometheus metrics. Opt-in: enabled flag drives both `prom-client`
117
+ // registry initialisation and `GET /metrics` mounting below.
118
+ const metricsCfg = appConfig.webServer.metrics;
119
+ const metricsEnabled = metricsCfg?.enabled === true;
120
+ if (metricsEnabled) {
121
+ initMetrics();
122
+ }
80
123
  // Security middleware
81
124
  app.use(helmet({
82
125
  contentSecurityPolicy: false, // Allow for SSE
83
126
  crossOriginEmbedderPolicy: false,
84
127
  }));
85
- // JSON parsing
86
- app.use(express.json({ limit: '10mb' }));
87
- app.use(express.urlencoded({ extended: true, limit: '10mb' }));
128
+ // JSON parsing. Body size is capped by `mcp.limits.maxPayloadBytes` (standard §14, default 1 MiB).
129
+ // Anything above is intercepted by the error-handling middleware below and converted to
130
+ // a JSON-RPC `-32005` / HTTP 413 response.
131
+ const { maxPayloadBytes } = appConfig.mcp.limits;
132
+ app.use(express.json({ limit: maxPayloadBytes }));
133
+ app.use(express.urlencoded({ extended: true, limit: maxPayloadBytes }));
88
134
  applyCors(app);
135
+ // OAuth discovery + token endpoints (mounted before auth MW so they remain public).
136
+ // Active only when jwtToken.mode !== 'legacyAesCtr'.
137
+ if (getJwtRuntimeConfig().mode !== 'legacyAesCtr') {
138
+ app.use(createOAuthRouter());
139
+ }
89
140
  app.use(faviconSvg());
90
141
  // Serve static files (CSS, JS, SVG)
91
142
  app.use('/static', express.static(staticPath));
@@ -97,12 +148,14 @@ export async function startHttpServer() {
97
148
  app.get('/', (req, res) => {
98
149
  res.sendFile(join(staticPath, 'home', 'index.html'));
99
150
  });
100
- // Health check endpoint
151
+ // Health check endpoint. Standard §16.1 mandates `status`, `version` and `uptime`. An
152
+ // `unhealthy` body is paired with HTTP 503 so platform health probes pick up the failure.
101
153
  app.get('/health', async (req, res) => {
102
154
  let health = {
103
155
  status: 'healthy',
156
+ version: appConfig.version,
157
+ uptime: process.uptime(),
104
158
  details: {
105
- uptime: process.uptime(),
106
159
  timestamp: new Date().toISOString(),
107
160
  },
108
161
  };
@@ -112,15 +165,56 @@ export async function startHttpServer() {
112
165
  health.status = 'unhealthy';
113
166
  }
114
167
  }
115
- res.json(health);
168
+ res.status(health.status === 'healthy' ? 200 : 503).json(health);
116
169
  });
117
- // Token check endpoint: GET /ct?t=<token> or POST /ct {"t": "<token>"}
118
- // Returns { success, type: 'permanent' | 'JWT', payload? } or { success: false, error }
119
- const handleTokenCheck = (req, res) => {
170
+ // Standard §15.3 — Prometheus metrics endpoint. Opt-in (default off). Public by design
171
+ // protect via network policy / reverse proxy if the server is reachable from the network.
172
+ if (metricsEnabled) {
173
+ const metricsPath = metricsCfg?.path || '/metrics';
174
+ app.get(metricsPath, async (_req, res) => {
175
+ try {
176
+ const reg = getMetricsRegistry();
177
+ res.setHeader('Content-Type', reg.contentType);
178
+ res.end(await reg.metrics());
179
+ }
180
+ catch (err) {
181
+ logger.error('Failed to render Prometheus metrics', err);
182
+ res.status(500).send('Failed to render metrics');
183
+ }
184
+ });
185
+ }
186
+ // Readiness probe (standard §16.2) — no authentication; reports whether every dependency
187
+ // the server needs to serve traffic is up. Empty / sensitive details are NEVER returned —
188
+ // each check is reduced to `ok` / `error`.
189
+ app.get('/ready', async (req, res) => {
190
+ const checks = {};
191
+ let ready = true;
192
+ if (appConfig.isMainDBUsed) {
193
+ const dbStatus = await getMainDBConnectionStatus();
194
+ if (dbStatus === 'connected') {
195
+ checks.db = 'ok';
196
+ }
197
+ else {
198
+ checks.db = 'error';
199
+ ready = false;
200
+ }
201
+ }
202
+ // Cache singleton: trivially available; surface it for diagnostic completeness.
203
+ checks.cache = 'ok';
204
+ // JWKS check is a placeholder until Phase 5 introduces the OAuth profile.
205
+ checks.jwks = 'skipped';
206
+ res.status(ready ? 200 : 503).json({
207
+ status: ready ? 'ready' : 'not_ready',
208
+ checks,
209
+ });
210
+ });
211
+ // Token check endpoint: POST /ct {"t": "<token>"}. Standard §7.1 forbids secrets in URL.
212
+ // GET /ct?t=<token> is gated behind webServer.tokenCheck.allowQueryToken (non-prod only).
213
+ const handleTokenCheck = async (req, res) => {
120
214
  const raw = req.method === 'GET' ? req.query.t : req.body?.t;
121
215
  const token = typeof raw === 'string' ? raw.trim() : '';
122
216
  if (!token) {
123
- return res.status(400).json({ success: false, error: 'Token not provided. Pass via "t" query/body parameter' });
217
+ return res.status(400).json({ success: false, error: 'Token not provided. Pass via "t" body parameter' });
124
218
  }
125
219
  const { errorReason: permError } = checkPermanentToken(token);
126
220
  if (!permError) {
@@ -129,13 +223,21 @@ export async function startHttpServer() {
129
223
  const xff = req.headers['x-forwarded-for'];
130
224
  const xffStr = (Array.isArray(xff) ? (xff[0] ?? '') : (xff ?? '')).split(',').shift() ?? '';
131
225
  const clientIp = req.ip ?? (xffStr.trim() || (req.socket?.remoteAddress ?? ''));
132
- const jwtResult = checkJwtToken({ token, clientIp });
226
+ const jwtResult = await checkJwtToken({ token, clientIp });
133
227
  if (!jwtResult.errorReason) {
134
228
  return res.json({ success: true, type: 'JWT', payload: jwtResult.payload });
135
229
  }
136
230
  return res.status(401).json({ success: false, error: jwtResult.errorReason });
137
231
  };
138
- app.get('/ct', handleTokenCheck);
232
+ const allowQueryToken = appConfig.webServer.tokenCheck?.allowQueryToken === true && process.env.NODE_ENV !== 'production';
233
+ if (allowQueryToken) {
234
+ app.get('/ct', handleTokenCheck);
235
+ }
236
+ else {
237
+ app.get('/ct', (_req, res) => res.status(405).json({
238
+ error: 'GET /ct is disabled by standard §7.1. Use POST /ct with JSON body {"t": "<token>"}.',
239
+ }));
240
+ }
139
241
  app.post('/ct', handleTokenCheck);
140
242
  // Public endpoint: returns used HTTP headers configured in the template (optional)
141
243
  app.get('/used-http-headers', (req, res) => {
@@ -172,13 +274,26 @@ export async function startHttpServer() {
172
274
  }
173
275
  // JWT generation API endpoint
174
276
  if (appConfig.webServer.genJwtApiEnable) {
277
+ const jwtMode = getJwtRuntimeConfig().mode;
175
278
  const encryptKey = appConfig.webServer.auth?.jwtToken?.encryptKey;
176
- if (!encryptKey || encryptKey.length < MIN_ENCRYPT_KEY_LENGTH || encryptKey === '***') {
279
+ const legacyKeyMissing = jwtMode === 'legacyAesCtr' && (!encryptKey || encryptKey.length < MIN_ENCRYPT_KEY_LENGTH || encryptKey === '***');
280
+ if (legacyKeyMissing) {
177
281
  logger.error('genJwtApiEnable is true but webServer.auth.jwtToken.encryptKey is not configured');
178
282
  }
283
+ else if (jwtMode === 'remoteJwks') {
284
+ app.post('/gen-jwt', authMW, (_req, res) => {
285
+ const { jwksUri } = getJwtRuntimeConfig();
286
+ res.status(501).json({
287
+ success: false,
288
+ error: 'cannot_issue_token',
289
+ error_description: 'This server runs in mode=remoteJwks and does not issue JWTs. ' +
290
+ (jwksUri ? `Obtain tokens from the IdP at ${jwksUri}.` : 'Obtain tokens from the configured IdP.'),
291
+ });
292
+ });
293
+ }
179
294
  else {
180
295
  const TTL_MULTIPLIERS = { s: 1, m: 60, d: 86400, y: 31536000 };
181
- app.post('/gen-jwt', authMW, (req, res) => {
296
+ app.post('/gen-jwt', authMW, async (req, res) => {
182
297
  try {
183
298
  const { username, ttl, service, params } = req.body;
184
299
  if (!username || !username.trim()) {
@@ -224,7 +339,14 @@ export async function startHttpServer() {
224
339
  Object.assign(payload, params);
225
340
  }
226
341
  }
227
- const token = generateToken(username.trim(), liveTimeSec, payload);
342
+ if (jwtMode !== 'legacyAesCtr' && !canLocallyIssueJwt()) {
343
+ return res.status(501).json({
344
+ success: false,
345
+ error: 'cannot_issue_token',
346
+ error_description: `Current jwtToken.mode=${jwtMode} cannot sign tokens locally.`,
347
+ });
348
+ }
349
+ const token = await generateToken(username.trim(), liveTimeSec, payload);
228
350
  const expire = Date.now() + liveTimeSec * 1000;
229
351
  return res.json({
230
352
  success: true,
@@ -270,7 +392,7 @@ export async function startHttpServer() {
270
392
  // Client capabilities are read lazily on every call via `sseServer.getClientCapabilities()` so the
271
393
  // value reflects the post-handshake state for every list/read/call.
272
394
  async function createSseServer(preservedHeaders, mcpAuthPayload) {
273
- const sseServer = createMcpServer();
395
+ const sseServer = createMcpServer('sse');
274
396
  const sseCtx = () => {
275
397
  const caps = sseServer.getClientCapabilities();
276
398
  return {
@@ -298,16 +420,20 @@ export async function startHttpServer() {
298
420
  return (await getResource(request.params.uri, sseCtx()));
299
421
  });
300
422
  // Override the tool call handler to include rate limiting, preserved headers and auth payload
301
- sseServer.setRequestHandler(CallToolRequestSchema, async (request) => {
423
+ sseServer.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
302
424
  // Apply rate limiting for each SSE tool call
303
425
  const toolCallClientId = 'sse-tool-unknown';
304
426
  await handleRateLimit(rateLimiter, toolCallClientId, 'unknown', `SSE tool call | tool: ${request.params.name}`);
305
- // Execute the tool call with preserved headers and payload from SSE connection establishment
427
+ // Execute the tool call with preserved headers and payload from SSE connection establishment.
428
+ // Same `mcp.limits` enforcement as the Streamable HTTP path.
306
429
  const { toolHandler } = getProjectData();
307
- return (await toolHandler({
430
+ const sseToolName = request.params?.name ?? 'unknown';
431
+ const response = (await withToolTimeout(sseToolName, () => toolHandler({
308
432
  ...request.params,
309
433
  ...sseCtx(),
310
- }));
434
+ signal: extra?.signal,
435
+ })));
436
+ return truncateToolResponse(response);
311
437
  });
312
438
  return sseServer;
313
439
  }
@@ -315,7 +441,7 @@ export async function startHttpServer() {
315
441
  app.get('/sse', authMW, async (req, res) => {
316
442
  try {
317
443
  // Apply rate limiting for SSE connection
318
- const clientId = `sse-${req.ip || 'unknown'}`;
444
+ const clientId = resolveRateLimitKey(req, 'sse');
319
445
  await handleRateLimit(rateLimiter, clientId, req.ip || 'unknown', 'SSE', res, 1);
320
446
  logger.info('SSE client connected');
321
447
  // Preserve normalized headers from SSE connection establishment
@@ -366,14 +492,8 @@ export async function startHttpServer() {
366
492
  }
367
493
  const transportData = sseTransports.get(sessionId);
368
494
  if (!transportData) {
369
- res.status(404).json({
370
- jsonrpc: '2.0',
371
- error: {
372
- code: -32001,
373
- message: 'Session not found',
374
- },
375
- id: null,
376
- });
495
+ const err = new ResourceNotFoundError('SSE session not found');
496
+ res.status(err.statusCode).json(createJsonRpcErrorResponse(err, null));
377
497
  return;
378
498
  }
379
499
  const { transport } = transportData;
@@ -398,18 +518,12 @@ export async function startHttpServer() {
398
518
  break;
399
519
  }
400
520
  if (!targetTransport) {
401
- res.status(404).json({
402
- jsonrpc: '2.0',
403
- error: {
404
- code: -32001,
405
- message: 'No SSE connection established. Connect via GET /sse first.',
406
- },
407
- id: req.body?.id ?? null,
408
- });
521
+ const err = new ResourceNotFoundError('No SSE connection established. Connect via GET /sse first.');
522
+ res.status(err.statusCode).json(createJsonRpcErrorResponse(err, req.body?.id ?? null));
409
523
  return;
410
524
  }
411
525
  // Apply rate limiting
412
- const clientId = req.ip || 'unknown';
526
+ const clientId = resolveRateLimitKey(req);
413
527
  await handleRateLimit(rateLimiter, clientId, req.ip || 'unknown', 'SSE POST', res, req.body?.id);
414
528
  logger.info(`Direct SSE POST request received: ${req.body.method} | id: ${req.body.id}`);
415
529
  // Use the transport's built-in handlePostMessage method
@@ -422,158 +536,169 @@ export async function startHttpServer() {
422
536
  }
423
537
  }
424
538
  });
425
- // Streamable HTTP is stateless per request there is no per-connection MCP server holding
426
- // capabilities after the handshake. We instead key the capabilities reported on `initialize`
427
- // by the `Mcp-Session-Id` header the client SHOULD send on subsequent requests. When the
428
- // header is missing (e.g. one-shot clients that don't observe sessions), `clientCapabilities`
429
- // stays `undefined` and handlers MUST fall back to text-only output.
430
- const httpClientCapabilitiesBySession = new Map();
539
+ // Streamable HTTP runs **stateful**: each MCP session owns a `StreamableHTTPServerTransport`
540
+ // bound to its own `Server` instance (so `getClientCapabilities()` works like on stdio). The
541
+ // transport is created on `initialize`, keyed by the server-generated `Mcp-Session-Id`, and the
542
+ // SDK transport handles protocol-version negotiation, error codes, notifications (202) and the
543
+ // GET-SSE / DELETE-teardown semantics for us.
431
544
  const HTTP_SESSION_HEADER = 'mcp-session-id';
432
- // Soft cap to bound memory; oldest session entry is evicted (FIFO via Map insertion order).
545
+ // Soft cap to bound memory; oldest session is evicted (FIFO via Map insertion order).
433
546
  const MAX_HTTP_SESSIONS = 4096;
434
- const getSessionId = (headers) => headers[HTTP_SESSION_HEADER];
435
- const cacheClientCapabilities = (sessionId, capabilities) => {
436
- if (httpClientCapabilitiesBySession.size >= MAX_HTTP_SESSIONS) {
437
- const oldestKey = httpClientCapabilitiesBySession.keys().next().value;
438
- if (oldestKey) {
439
- httpClientCapabilitiesBySession.delete(oldestKey);
547
+ const mcpTransports = new Map();
548
+ // Standard §6 (MAY) SSE resumability. A single in-memory EventStore is shared across sessions
549
+ // (each stream owns its own streamId). Created only when opted in; otherwise the transport is
550
+ // built without an eventStore and behavior is unchanged.
551
+ const sseResumability = appConfig.mcp.sse?.resumability === true;
552
+ const sseEventStore = sseResumability
553
+ ? new InMemoryEventStore(appConfig.mcp.sse?.maxStoredEvents ?? 1000)
554
+ : undefined;
555
+ if (sseResumability) {
556
+ logger.info(`MCP SSE resumability enabled (in-memory, max ${appConfig.mcp.sse?.maxStoredEvents ?? 1000} events)`);
557
+ }
558
+ const evictOldestSession = (keep) => {
559
+ while (mcpTransports.size > MAX_HTTP_SESSIONS) {
560
+ const oldest = mcpTransports.keys().next().value;
561
+ if (!oldest || oldest === keep) {
562
+ break;
563
+ }
564
+ const t = mcpTransports.get(oldest);
565
+ mcpTransports.delete(oldest);
566
+ void t?.close();
567
+ }
568
+ };
569
+ // Race the underlying transport against `mcp.limits.toolTimeoutMs` for `tools/call` requests.
570
+ // The standard (§14) requires HTTP 504 + JSON-RPC -32004 on timeout. The SDK's transport
571
+ // doesn't surface HTTP-level timeouts, so we monitor at this layer — whoever writes the
572
+ // response first wins. The tool promise itself is still bounded inside the SDK handler by
573
+ // `withToolTimeout`, which throws an `McpError(-32004)` to keep the JSON-RPC code correct
574
+ // for the SSE branch and for the (unlikely) case the handler beats the HTTP timer.
575
+ const runHttpToolCall = async (req, res, exec) => {
576
+ if (req.body?.method !== 'tools/call') {
577
+ await exec();
578
+ return;
579
+ }
580
+ const timeoutMs = appConfig.mcp.limits.toolTimeoutMs;
581
+ let timer;
582
+ const timerPromise = new Promise((resolve) => {
583
+ timer = setTimeout(() => resolve('timeout'), timeoutMs);
584
+ if (typeof timer?.unref === 'function') {
585
+ timer.unref();
586
+ }
587
+ });
588
+ try {
589
+ const winner = await Promise.race([exec().then(() => 'done'), timerPromise]);
590
+ if (winner === 'timeout' && !res.headersSent) {
591
+ const toolName = req.body?.params?.name ?? 'unknown';
592
+ const err = new TimeoutError(`Tool '${toolName}' exceeded ${timeoutMs} ms timeout`, {
593
+ reason: 'tool_timeout',
594
+ });
595
+ logger.warn(`Tool timeout (${timeoutMs} ms) for tool: ${toolName}`);
596
+ res.status(err.statusCode).json(createJsonRpcErrorResponse(err, req.body?.id ?? null));
440
597
  }
441
598
  }
442
- httpClientCapabilitiesBySession.set(sessionId, capabilities);
599
+ finally {
600
+ if (timer) {
601
+ clearTimeout(timer);
602
+ }
603
+ }
604
+ };
605
+ const noSessionError = (req, res) => {
606
+ res.status(400).json({
607
+ jsonrpc: '2.0',
608
+ id: req.body?.id ?? null,
609
+ error: { code: -32600, message: 'No valid MCP session. Send `initialize` first.' },
610
+ });
443
611
  };
444
- // POST endpoint for MCP requests
612
+ // GET (server→client SSE stream) and DELETE (session teardown) operate on an existing session.
613
+ const routeToSession = async (req, res) => {
614
+ const sessionId = req.headers[HTTP_SESSION_HEADER];
615
+ const transport = sessionId ? mcpTransports.get(sessionId) : undefined;
616
+ if (!transport) {
617
+ noSessionError(req, res);
618
+ return;
619
+ }
620
+ await transport.handleRequest(req, res, req.body);
621
+ };
622
+ // POST endpoint for MCP requests — handshake + all JSON-RPC calls go through the SDK transport.
445
623
  app.post('/mcp', authMW, async (req, res) => {
446
624
  try {
447
- // Apply rate limiting
448
- const clientId = req.ip || 'unknown';
625
+ // Rate limiting and the body-size limit stay here, before the transport takes over.
626
+ const clientId = resolveRateLimitKey(req);
449
627
  await handleRateLimit(rateLimiter, clientId, req.ip || 'unknown', 'HTTP MCP', res, req.body?.id);
450
- const request = req.body;
451
- const { method, params, id } = request;
452
- logger.info(`HTTP MCP request received: ${method} | id: ${id}`);
453
- // JSON-RPC notifications (no `id`) MUST NOT receive a response. MCP clients legitimately
454
- // emit a variety of `notifications/*` events (initialized, cancelled, progress,
455
- // roots/list_changed, message, ) and we acknowledge them with 204 instead of erroring out
456
- // on unknown ones matching MCP/JSON-RPC semantics and keeping the log clean.
457
- if (typeof method === 'string' && method.startsWith('notifications/')) {
458
- if (method === 'notifications/initialized') {
459
- logger.info('MCP client initialization completed');
460
- }
461
- else {
462
- logger.debug(`MCP notification received: ${method}`);
463
- }
464
- if (debugMcpNotification.enabled) {
465
- debugMcpNotification(`← ${method}\n${JSON.stringify(params ?? {}, null, 2)}`);
628
+ if (res.headersSent) {
629
+ return; // rate limit already responded
630
+ }
631
+ // Dedicated rate-limit bucket for tool calls (was inline in the old switch).
632
+ if (req.body?.method === 'tools/call') {
633
+ const toolCallClientId = resolveRateLimitKey(req, 'tool');
634
+ await handleRateLimit(rateLimiter, toolCallClientId, req.ip || 'unknown', `tool call | tool: ${req.body?.params?.name || 'unknown'}`, res, req.body?.id);
635
+ if (res.headersSent) {
636
+ return;
466
637
  }
467
- return res.status(204).send();
468
638
  }
469
- const mcpAuthPayload = req.authInfo?.payload;
470
- const preservedHeaders = normalizeHeaders(req.headers);
471
- const sessionId = getSessionId(preservedHeaders);
472
- const cachedCapabilities = sessionId ? httpClientCapabilitiesBySession.get(sessionId) : undefined;
473
- const httpArgs = {
474
- transport: 'http',
475
- headers: preservedHeaders,
476
- payload: mcpAuthPayload,
477
- ...(cachedCapabilities ? { clientCapabilities: cachedCapabilities } : {}),
478
- };
479
- let result;
480
- switch (method) {
481
- case 'initialize':
482
- const { protocolVersion, capabilities: clientCapabilities, clientInfo } = params || {};
483
- logger.info(`MCP client initializing: protocolVersion: ${protocolVersion} | clientCapabilities: ${JSON.stringify(clientCapabilities)} | clientInfo: ${JSON.stringify(clientInfo)}`);
484
- // Cache reported capabilities so subsequent stateless POSTs from the same session can
485
- // surface them through `IToolHandlerParams.clientCapabilities`.
486
- if (sessionId && clientCapabilities && typeof clientCapabilities === 'object') {
487
- cacheClientCapabilities(sessionId, clientCapabilities);
639
+ const sessionId = req.headers[HTTP_SESSION_HEADER];
640
+ const existing = sessionId ? mcpTransports.get(sessionId) : undefined;
641
+ if (existing) {
642
+ await runHttpToolCall(req, res, () => existing.handleRequest(req, res, req.body));
643
+ return;
644
+ }
645
+ if (isInitializeRequest(req.body)) {
646
+ logger.info(`MCP client initializing: protocolVersion: ${req.body?.params?.protocolVersion} | clientInfo: ${JSON.stringify(req.body?.params?.clientInfo)}`);
647
+ const transport = new StreamableHTTPServerTransport({
648
+ sessionIdGenerator: () => randomUUID(),
649
+ // Standard §6 (MAY) — attach the EventStore only when resumability is enabled.
650
+ ...(sseEventStore ? { eventStore: sseEventStore } : {}),
651
+ onsessioninitialized: (sid) => {
652
+ mcpTransports.set(sid, transport);
653
+ evictOldestSession(sid);
654
+ },
655
+ onsessionclosed: (sid) => {
656
+ mcpTransports.delete(sid);
657
+ },
658
+ });
659
+ // SDK `Transport` exposes `onclose` as a plain setter (not an EventTarget), so
660
+ // `addEventListener` does not apply here — this is the canonical SDK pattern.
661
+ // oxlint-disable-next-line unicorn/prefer-add-event-listener
662
+ transport.onclose = () => {
663
+ const sid = transport.sessionId;
664
+ if (sid) {
665
+ mcpTransports.delete(sid);
488
666
  }
489
- result = {
490
- protocolVersion: '2024-11-05',
491
- capabilities: {
492
- tools: {},
493
- prompts: {},
494
- resources: {},
495
- },
496
- serverInfo: {
497
- name: appConfig.name,
498
- version: appConfig.version,
499
- },
500
- };
501
- break;
502
- case 'tools/list':
503
- const tools = await getTools(httpArgs);
504
- result = { tools };
505
- break;
506
- case 'tools/call':
507
- // Apply rate limiting for tool calls
508
- const toolCallClientId = `tool-${req.ip || 'unknown'}`;
509
- await handleRateLimit(rateLimiter, toolCallClientId, req.ip || 'unknown', `tool call | tool: ${params?.name || 'unknown'}`, res, id);
510
- const { toolHandler } = getProjectData();
511
- result = await toolHandler({ ...params, ...httpArgs });
512
- break;
513
- case 'prompts/list':
514
- result = await getPromptsList(httpArgs);
515
- break;
516
- case 'prompts/get': {
517
- result = await getPrompt(request, httpArgs);
518
- break;
519
- }
520
- case 'resources/list':
521
- result = await getResourcesList(httpArgs);
522
- break;
523
- case 'resources/read': {
524
- result = await getResource(params.uri, httpArgs);
525
- break;
526
- }
527
- case 'ping':
528
- result = { pong: true };
529
- break;
530
- default:
531
- throw new Error(`Unknown method: ${method}`);
667
+ };
668
+ const server = createMcpServer('http');
669
+ // Cast: SDK `Transport` types are stricter under `exactOptionalPropertyTypes`, but
670
+ // `StreamableHTTPServerTransport` is a valid transport.
671
+ await server.connect(transport);
672
+ await runHttpToolCall(req, res, () => transport.handleRequest(req, res, req.body));
673
+ return;
532
674
  }
533
- return res.json({
534
- jsonrpc: '2.0',
535
- id,
536
- result,
537
- });
675
+ // No session and not an `initialize` request → 400 per MCP transport semantics.
676
+ noSessionError(req, res);
538
677
  }
539
678
  catch (error) {
540
679
  if (!error.printed) {
541
680
  logger.error('MCP request failed', toError(error));
542
681
  error.printed = true;
543
682
  }
544
- let errorResponse;
545
- if (error instanceof BaseMcpError) {
546
- // Use full error structure with details for better debugging
547
- const errorObj = error.toJSON();
548
- errorResponse = {
549
- code: -1,
550
- message: errorObj.message,
551
- data: {
552
- code: errorObj.code,
553
- details: errorObj.details,
554
- // stack: process.env.NODE_ENV === 'development' ? errorObj.stack : undefined
555
- },
556
- };
557
- }
558
- else {
559
- // Standard error handling for non-MCP errors
560
- errorResponse = {
561
- code: -1,
562
- message: toStr(error),
563
- };
683
+ if (!res.headersSent) {
684
+ res
685
+ .status(500)
686
+ .json(createJsonRpcErrorResponse(error instanceof ServerError ? error : new ServerError(toStr(error))));
564
687
  }
565
- return res.json({
566
- jsonrpc: '2.0',
567
- id: req.body?.id ?? 1,
568
- error: errorResponse,
569
- });
570
688
  }
571
689
  });
690
+ app.get('/mcp', authMW, async (req, res) => {
691
+ await routeToSession(req, res);
692
+ });
693
+ app.delete('/mcp', authMW, async (req, res) => {
694
+ await routeToSession(req, res);
695
+ });
572
696
  // 404 handler for unknown routes
573
697
  app.use((req, res) => {
574
698
  const availableEndpoints = {
575
699
  home: 'GET /',
576
700
  health: 'GET /health',
701
+ ready: 'GET /ready',
577
702
  checkToken: 'GET /ct?t=<token>, POST /ct',
578
703
  sse: 'GET /sse, POST /sse',
579
704
  messages: 'POST /messages',
@@ -599,17 +724,32 @@ export async function startHttpServer() {
599
724
  availableEndpoints,
600
725
  });
601
726
  });
602
- // Error handling middleware (must have 4 parameters for Express to recognize it)
727
+ // Error handling middleware (must have 4 parameters for Express to recognize it).
728
+ // Special case: `express.json()` raises `entity.too.large` with `error.type === 'entity.too.large'`
729
+ // when the request body exceeds `mcp.limits.maxPayloadBytes`. Standard §14 maps that to
730
+ // JSON-RPC `-32005` / HTTP 413.
603
731
  app.use((error, req, res, _next) => {
732
+ if (error && error.type === 'entity.too.large') {
733
+ logger.warn(`Payload too large from ip: ${req.ip}, limit: ${maxPayloadBytes}`);
734
+ if (!res.headersSent) {
735
+ const err = new PayloadTooLargeError(`Request body exceeds ${maxPayloadBytes} bytes`, {
736
+ reason: 'payload_too_large',
737
+ });
738
+ res.status(err.statusCode).json(createJsonRpcErrorResponse(err, req.body?.id ?? null));
739
+ }
740
+ return;
741
+ }
604
742
  logger.error('Express error handler', error);
605
743
  if (!res.headersSent) {
606
744
  res.status(500).json(createJsonRpcErrorResponse(error));
607
745
  }
608
746
  });
609
- // Start HTTP server
610
- const { port } = appConfig.webServer;
611
- app.listen(port, '0.0.0.0', () => {
612
- let msg = `${chalk.magenta(appConfig.productName)} started with ${chalk.blue('HTTP')} transport on port ${chalk.blue(port)}
747
+ // Start HTTP server. Bind address is driven by config — default is the loopback interface
748
+ // (`127.0.0.1`) so the server is unreachable from the network until an operator opts in to
749
+ // `0.0.0.0` or a specific NIC address. Standard §6.
750
+ const { port, host } = appConfig.webServer;
751
+ app.listen(port, host || '127.0.0.1', () => {
752
+ let msg = `${chalk.magenta(appConfig.productName)} started with ${chalk.blue('HTTP')} transport on ${chalk.blue(host)}:${chalk.blue(port)}
613
753
  Home page: http://localhost:${port}/`;
614
754
  if (isAdminEnabled) {
615
755
  msg += `\nAdmin panel: http://localhost:${port}/admin`;