boondmanager-mcp-server 2.15.2 → 2.17.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 (272) hide show
  1. package/LICENSE +1 -1
  2. package/NOTICE +16 -2
  3. package/README.md +60 -15
  4. package/dist/config/access-policy.d.ts.map +1 -1
  5. package/dist/config/access-policy.js +6 -18
  6. package/dist/config/access-policy.js.map +1 -1
  7. package/dist/config/dictionary-overrides.d.ts.map +1 -1
  8. package/dist/config/dictionary-overrides.js +2 -12
  9. package/dist/config/dictionary-overrides.js.map +1 -1
  10. package/dist/config/env.d.ts +63 -0
  11. package/dist/config/env.d.ts.map +1 -0
  12. package/dist/config/env.js +116 -0
  13. package/dist/config/env.js.map +1 -0
  14. package/dist/config/profiles.d.ts +22 -8
  15. package/dist/config/profiles.d.ts.map +1 -1
  16. package/dist/config/profiles.js +48 -4
  17. package/dist/config/profiles.js.map +1 -1
  18. package/dist/constants.d.ts +13 -2
  19. package/dist/constants.d.ts.map +1 -1
  20. package/dist/constants.js +25 -1
  21. package/dist/constants.js.map +1 -1
  22. package/dist/icons.d.ts.map +1 -1
  23. package/dist/icons.js +6 -1
  24. package/dist/icons.js.map +1 -1
  25. package/dist/index.js +33 -15
  26. package/dist/index.js.map +1 -1
  27. package/dist/instructions.d.ts +1 -1
  28. package/dist/instructions.d.ts.map +1 -1
  29. package/dist/instructions.js +6 -6
  30. package/dist/instructions.js.map +1 -1
  31. package/dist/prompts/index.d.ts +19 -1
  32. package/dist/prompts/index.d.ts.map +1 -1
  33. package/dist/prompts/index.js +495 -25
  34. package/dist/prompts/index.js.map +1 -1
  35. package/dist/prompts/periods.d.ts +45 -0
  36. package/dist/prompts/periods.d.ts.map +1 -0
  37. package/dist/prompts/periods.js +154 -0
  38. package/dist/prompts/periods.js.map +1 -0
  39. package/dist/resources/index.d.ts +14 -0
  40. package/dist/resources/index.d.ts.map +1 -1
  41. package/dist/resources/index.js +285 -0
  42. package/dist/resources/index.js.map +1 -1
  43. package/dist/resources/templates.d.ts +1 -1
  44. package/dist/resources/templates.d.ts.map +1 -1
  45. package/dist/resources/templates.js +2 -0
  46. package/dist/resources/templates.js.map +1 -1
  47. package/dist/schema-dialect.js.map +1 -1
  48. package/dist/schemas/filter-aliases.d.ts +1 -1
  49. package/dist/schemas/filter-aliases.d.ts.map +1 -1
  50. package/dist/schemas/filter-aliases.js +101 -2
  51. package/dist/schemas/filter-aliases.js.map +1 -1
  52. package/dist/schemas/index.d.ts +987 -161
  53. package/dist/schemas/index.d.ts.map +1 -1
  54. package/dist/schemas/index.js +853 -323
  55. package/dist/schemas/index.js.map +1 -1
  56. package/dist/server.d.ts.map +1 -1
  57. package/dist/server.js +10 -3
  58. package/dist/server.js.map +1 -1
  59. package/dist/services/boond-client.d.ts +29 -213
  60. package/dist/services/boond-client.d.ts.map +1 -1
  61. package/dist/services/boond-client.js +29 -1168
  62. package/dist/services/boond-client.js.map +1 -1
  63. package/dist/services/dictionary.d.ts +27 -3
  64. package/dist/services/dictionary.d.ts.map +1 -1
  65. package/dist/services/dictionary.js +60 -23
  66. package/dist/services/dictionary.js.map +1 -1
  67. package/dist/services/document-text.d.ts +32 -0
  68. package/dist/services/document-text.d.ts.map +1 -0
  69. package/dist/services/document-text.js +120 -0
  70. package/dist/services/document-text.js.map +1 -0
  71. package/dist/services/format/detail.d.ts +16 -0
  72. package/dist/services/format/detail.d.ts.map +1 -0
  73. package/dist/services/format/detail.js +36 -0
  74. package/dist/services/format/detail.js.map +1 -0
  75. package/dist/services/format/html.d.ts +19 -0
  76. package/dist/services/format/html.d.ts.map +1 -0
  77. package/dist/services/format/html.js +87 -0
  78. package/dist/services/format/html.js.map +1 -0
  79. package/dist/services/format/list.d.ts +3 -0
  80. package/dist/services/format/list.d.ts.map +1 -0
  81. package/dist/services/format/list.js +44 -0
  82. package/dist/services/format/list.js.map +1 -0
  83. package/dist/services/format/summary.d.ts +16 -0
  84. package/dist/services/format/summary.d.ts.map +1 -0
  85. package/dist/services/format/summary.js +172 -0
  86. package/dist/services/format/summary.js.map +1 -0
  87. package/dist/services/format/tab.d.ts +9 -0
  88. package/dist/services/format/tab.d.ts.map +1 -0
  89. package/dist/services/format/tab.js +32 -0
  90. package/dist/services/format/tab.js.map +1 -0
  91. package/dist/services/http/auth.d.ts +44 -0
  92. package/dist/services/http/auth.d.ts.map +1 -0
  93. package/dist/services/http/auth.js +146 -0
  94. package/dist/services/http/auth.js.map +1 -0
  95. package/dist/services/http/download.d.ts +60 -0
  96. package/dist/services/http/download.d.ts.map +1 -0
  97. package/dist/services/http/download.js +160 -0
  98. package/dist/services/http/download.js.map +1 -0
  99. package/dist/services/http/errors.d.ts +45 -0
  100. package/dist/services/http/errors.d.ts.map +1 -0
  101. package/dist/services/http/errors.js +188 -0
  102. package/dist/services/http/errors.js.map +1 -0
  103. package/dist/services/http/rate-limit.d.ts +40 -0
  104. package/dist/services/http/rate-limit.d.ts.map +1 -0
  105. package/dist/services/http/rate-limit.js +92 -0
  106. package/dist/services/http/rate-limit.js.map +1 -0
  107. package/dist/services/http/retry.d.ts +46 -0
  108. package/dist/services/http/retry.d.ts.map +1 -0
  109. package/dist/services/http/retry.js +101 -0
  110. package/dist/services/http/retry.js.map +1 -0
  111. package/dist/services/http/transport.d.ts +73 -0
  112. package/dist/services/http/transport.d.ts.map +1 -0
  113. package/dist/services/http/transport.js +272 -0
  114. package/dist/services/http/transport.js.map +1 -0
  115. package/dist/services/logger.d.ts +53 -2
  116. package/dist/services/logger.d.ts.map +1 -1
  117. package/dist/services/logger.js +82 -21
  118. package/dist/services/logger.js.map +1 -1
  119. package/dist/services/oauth.d.ts +12 -0
  120. package/dist/services/oauth.d.ts.map +1 -1
  121. package/dist/services/oauth.js +21 -8
  122. package/dist/services/oauth.js.map +1 -1
  123. package/dist/services/rate-limiter.d.ts +9 -2
  124. package/dist/services/rate-limiter.d.ts.map +1 -1
  125. package/dist/services/rate-limiter.js +18 -6
  126. package/dist/services/rate-limiter.js.map +1 -1
  127. package/dist/services/request-context.d.ts +47 -0
  128. package/dist/services/request-context.d.ts.map +1 -0
  129. package/dist/services/request-context.js +112 -0
  130. package/dist/services/request-context.js.map +1 -0
  131. package/dist/services/search.d.ts +26 -0
  132. package/dist/services/search.d.ts.map +1 -0
  133. package/dist/services/search.js +98 -0
  134. package/dist/services/search.js.map +1 -0
  135. package/dist/services/update-checker.d.ts.map +1 -1
  136. package/dist/services/update-checker.js +15 -8
  137. package/dist/services/update-checker.js.map +1 -1
  138. package/dist/tools/absences.d.ts +9 -0
  139. package/dist/tools/absences.d.ts.map +1 -1
  140. package/dist/tools/absences.js +65 -20
  141. package/dist/tools/absences.js.map +1 -1
  142. package/dist/tools/actions.d.ts.map +1 -1
  143. package/dist/tools/actions.js +11 -27
  144. package/dist/tools/actions.js.map +1 -1
  145. package/dist/tools/advantages.d.ts +2 -0
  146. package/dist/tools/advantages.d.ts.map +1 -1
  147. package/dist/tools/advantages.js +66 -13
  148. package/dist/tools/advantages.js.map +1 -1
  149. package/dist/tools/alerts.d.ts +18 -0
  150. package/dist/tools/alerts.d.ts.map +1 -0
  151. package/dist/tools/alerts.js +82 -0
  152. package/dist/tools/alerts.js.map +1 -0
  153. package/dist/tools/application.d.ts +2 -1
  154. package/dist/tools/application.d.ts.map +1 -1
  155. package/dist/tools/application.js +9 -3
  156. package/dist/tools/application.js.map +1 -1
  157. package/dist/tools/contacts.d.ts.map +1 -1
  158. package/dist/tools/contacts.js +3 -7
  159. package/dist/tools/contacts.js.map +1 -1
  160. package/dist/tools/contracts.d.ts +22 -0
  161. package/dist/tools/contracts.d.ts.map +1 -1
  162. package/dist/tools/contracts.js +185 -52
  163. package/dist/tools/contracts.js.map +1 -1
  164. package/dist/tools/crud-factory.d.ts +33 -2
  165. package/dist/tools/crud-factory.d.ts.map +1 -1
  166. package/dist/tools/crud-factory.js +42 -15
  167. package/dist/tools/crud-factory.js.map +1 -1
  168. package/dist/tools/deliveries.d.ts +3 -0
  169. package/dist/tools/deliveries.d.ts.map +1 -1
  170. package/dist/tools/deliveries.js +50 -94
  171. package/dist/tools/deliveries.js.map +1 -1
  172. package/dist/tools/description-builders.d.ts +0 -1
  173. package/dist/tools/description-builders.d.ts.map +1 -1
  174. package/dist/tools/description-builders.js +46 -3
  175. package/dist/tools/description-builders.js.map +1 -1
  176. package/dist/tools/documents.d.ts.map +1 -1
  177. package/dist/tools/documents.js +73 -21
  178. package/dist/tools/documents.js.map +1 -1
  179. package/dist/tools/expenses.d.ts.map +1 -1
  180. package/dist/tools/expenses.js +2 -11
  181. package/dist/tools/expenses.js.map +1 -1
  182. package/dist/tools/find.d.ts +61 -0
  183. package/dist/tools/find.d.ts.map +1 -0
  184. package/dist/tools/find.js +221 -0
  185. package/dist/tools/find.js.map +1 -0
  186. package/dist/tools/flags.d.ts +5 -0
  187. package/dist/tools/flags.d.ts.map +1 -1
  188. package/dist/tools/flags.js +116 -1
  189. package/dist/tools/flags.js.map +1 -1
  190. package/dist/tools/forms.d.ts +5 -0
  191. package/dist/tools/forms.d.ts.map +1 -0
  192. package/dist/tools/forms.js +64 -0
  193. package/dist/tools/forms.js.map +1 -0
  194. package/dist/tools/groupments.d.ts +5 -0
  195. package/dist/tools/groupments.d.ts.map +1 -0
  196. package/dist/tools/groupments.js +68 -0
  197. package/dist/tools/groupments.js.map +1 -0
  198. package/dist/tools/inactivities.d.ts +5 -0
  199. package/dist/tools/inactivities.d.ts.map +1 -0
  200. package/dist/tools/inactivities.js +59 -0
  201. package/dist/tools/inactivities.js.map +1 -0
  202. package/dist/tools/index.d.ts +4 -0
  203. package/dist/tools/index.d.ts.map +1 -1
  204. package/dist/tools/index.js +4 -0
  205. package/dist/tools/index.js.map +1 -1
  206. package/dist/tools/invoices.d.ts.map +1 -1
  207. package/dist/tools/invoices.js +27 -2
  208. package/dist/tools/invoices.js.map +1 -1
  209. package/dist/tools/linked-entity-filters.d.ts +48 -0
  210. package/dist/tools/linked-entity-filters.d.ts.map +1 -0
  211. package/dist/tools/linked-entity-filters.js +64 -0
  212. package/dist/tools/linked-entity-filters.js.map +1 -0
  213. package/dist/tools/notifications.d.ts.map +1 -1
  214. package/dist/tools/notifications.js +0 -5
  215. package/dist/tools/notifications.js.map +1 -1
  216. package/dist/tools/orders.d.ts.map +1 -1
  217. package/dist/tools/orders.js +33 -4
  218. package/dist/tools/orders.js.map +1 -1
  219. package/dist/tools/parameter-disclosure.js.map +1 -1
  220. package/dist/tools/payments.d.ts +3 -0
  221. package/dist/tools/payments.d.ts.map +1 -1
  222. package/dist/tools/payments.js +43 -109
  223. package/dist/tools/payments.js.map +1 -1
  224. package/dist/tools/positionings.d.ts.map +1 -1
  225. package/dist/tools/positionings.js +9 -39
  226. package/dist/tools/positionings.js.map +1 -1
  227. package/dist/tools/projects.d.ts.map +1 -1
  228. package/dist/tools/projects.js +5 -12
  229. package/dist/tools/projects.js.map +1 -1
  230. package/dist/tools/provider-invoices.d.ts +2 -0
  231. package/dist/tools/provider-invoices.d.ts.map +1 -1
  232. package/dist/tools/provider-invoices.js +30 -111
  233. package/dist/tools/provider-invoices.js.map +1 -1
  234. package/dist/tools/purchases.d.ts +2 -0
  235. package/dist/tools/purchases.d.ts.map +1 -1
  236. package/dist/tools/purchases.js +50 -104
  237. package/dist/tools/purchases.js.map +1 -1
  238. package/dist/tools/registration-decorators.d.ts +28 -0
  239. package/dist/tools/registration-decorators.d.ts.map +1 -1
  240. package/dist/tools/registration-decorators.js +107 -0
  241. package/dist/tools/registration-decorators.js.map +1 -1
  242. package/dist/tools/resources.d.ts.map +1 -1
  243. package/dist/tools/resources.js +27 -5
  244. package/dist/tools/resources.js.map +1 -1
  245. package/dist/tools/rights.d.ts +101 -0
  246. package/dist/tools/rights.d.ts.map +1 -0
  247. package/dist/tools/rights.js +75 -0
  248. package/dist/tools/rights.js.map +1 -0
  249. package/dist/tools/tab-tools.js +3 -3
  250. package/dist/tools/tab-tools.js.map +1 -1
  251. package/dist/tools/timesheets.d.ts +29 -0
  252. package/dist/tools/timesheets.d.ts.map +1 -1
  253. package/dist/tools/timesheets.js +196 -126
  254. package/dist/tools/timesheets.js.map +1 -1
  255. package/dist/tools/todolists.d.ts +2 -0
  256. package/dist/tools/todolists.d.ts.map +1 -1
  257. package/dist/tools/todolists.js +64 -1
  258. package/dist/tools/todolists.js.map +1 -1
  259. package/dist/tools/validations.d.ts +9 -0
  260. package/dist/tools/validations.d.ts.map +1 -1
  261. package/dist/tools/validations.js +86 -9
  262. package/dist/tools/validations.js.map +1 -1
  263. package/dist/tools/workflows.js +1 -1
  264. package/dist/tools/workflows.js.map +1 -1
  265. package/dist/transports/http.d.ts +116 -0
  266. package/dist/transports/http.d.ts.map +1 -1
  267. package/dist/transports/http.js +422 -92
  268. package/dist/transports/http.js.map +1 -1
  269. package/dist/types.d.ts +3 -3
  270. package/dist/types.d.ts.map +1 -1
  271. package/manifest.json +7 -7
  272. package/package.json +14 -10
@@ -1,10 +1,13 @@
1
- import { createServer } from "node:http";
2
- import { randomUUID } from "node:crypto";
1
+ import { createServer, IncomingMessage, ServerResponse } from "node:http";
2
+ import { createHash, randomUUID, timingSafeEqual } from "node:crypto";
3
3
  import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
4
4
  import { isInitializeRequest } from "@modelcontextprotocol/sdk/types.js";
5
5
  import { logger, generateCorrelationId } from "../services/logger.js";
6
+ import { runWithRequestContext } from "../services/request-context.js";
7
+ import { apiRequest, BoondApiError } from "../services/boond-client.js";
8
+ import { readBool, readCsv, readPositiveInt, readString, readUrl } from "../config/env.js";
6
9
  import { SERVER_VERSION } from "../server.js";
7
- import { buildProtectedResourceMetadata, extractBearerToken, oauthContext, resolveAdvertisedScopes, resolveAuthorizationServer, } from "../services/oauth.js";
10
+ import { buildProtectedResourceMetadata, currentAuthIdentity, extractBearerToken, oauthContext, resolveAdvertisedScopes, resolveAuthorizationServer, } from "../services/oauth.js";
8
11
  // Defaults: a half-hour idle window matches typical MCP gateway behaviour, and
9
12
  // a 5-minute sweep keeps memory bounded without hammering the event loop.
10
13
  const DEFAULT_SESSION_TTL_MS = 30 * 60_000;
@@ -13,38 +16,28 @@ const DEFAULT_SESSION_SWEEP_INTERVAL_MS = 5 * 60_000;
13
16
  // McpServer + transport until its TTL sweep, so without a cap an authenticated
14
17
  // client could spin up unbounded `initialize` requests and exhaust memory.
15
18
  const DEFAULT_MAX_SESSIONS = 1000;
19
+ // Server socket timeouts (issue #237). Keep-alive above the usual load-balancer
20
+ // idle timeouts (60 s ALB, 75 s nginx) so the LB never reuses a connection Node
21
+ // has just closed; headers a second above keep-alive (Node's own requirement);
22
+ // request generous because reporting calls are long.
23
+ export const DEFAULT_KEEP_ALIVE_TIMEOUT_MS = 65_000;
24
+ export const DEFAULT_HEADERS_TIMEOUT_MS = 66_000;
25
+ export const DEFAULT_REQUEST_TIMEOUT_MS = 300_000;
26
+ export const DEFAULT_SHUTDOWN_TIMEOUT_MS = 10_000;
27
+ /** How often `close()` re-reaps idle keep-alive sockets while draining. */
28
+ const IDLE_REAP_INTERVAL_MS = 100;
29
+ // Token validation (issue #234): one BoondManager round-trip per token per
30
+ // minute is the cost of turning an expired token into a real 401.
31
+ export const DEFAULT_TOKEN_VALIDATION_TTL_MS = 60_000;
32
+ export const MAX_TOKEN_VALIDATION_ENTRIES = 500;
16
33
  // Loopback addresses that should default to the localhost host allow-list.
17
34
  const LOOPBACK_HOSTS = new Set(["127.0.0.1", "::1", "localhost"]);
18
35
  const LOCALHOST_ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"];
19
- function readEnv(key) {
20
- const v = process.env[key];
21
- if (!v || v.startsWith("${"))
22
- return undefined;
23
- return v;
24
- }
25
- function readPositiveInt(key, fallback) {
26
- const raw = readEnv(key);
27
- if (!raw)
28
- return fallback;
29
- const parsed = Number(raw);
30
- if (!Number.isFinite(parsed) || parsed <= 0)
31
- return fallback;
32
- return Math.floor(parsed);
33
- }
34
- function readCsvEnv(key) {
35
- const raw = readEnv(key);
36
- if (raw === undefined)
37
- return undefined;
38
- return raw
39
- .split(",")
40
- .map((s) => s.trim())
41
- .filter((s) => s.length > 0);
42
- }
43
36
  function readAllowedHosts() {
44
- return readCsvEnv("MCP_HTTP_ALLOWED_HOSTS");
37
+ return readCsv("MCP_HTTP_ALLOWED_HOSTS");
45
38
  }
46
39
  function readAllowedOrigins() {
47
- return readCsvEnv("MCP_HTTP_ALLOWED_ORIGINS");
40
+ return readCsv("MCP_HTTP_ALLOWED_ORIGINS");
48
41
  }
49
42
  /**
50
43
  * Resolves the effective Host header allow-list given user options and the
@@ -203,31 +196,129 @@ function extractHostname(hostHeader) {
203
196
  return undefined;
204
197
  }
205
198
  }
199
+ /** `{ [key]: value }` when the value is defined, `{}` otherwise — keeps optional keys absent rather than `undefined`. */
200
+ function optional(key, value) {
201
+ return value === undefined ? {} : { [key]: value };
202
+ }
203
+ /**
204
+ * SDK 1.30 declares `Transport.onclose` as `onclose?: () => void` but the
205
+ * `StreamableHTTPServerTransport` accessor as `(() => void) | undefined`. The
206
+ * SDK assigns one to the other internally; under `exactOptionalPropertyTypes`
207
+ * the two are incompatible at *our* call sites only. Delete this adapter when
208
+ * the SDK aligns the two declarations.
209
+ */
210
+ function asTransport(transport) {
211
+ return transport;
212
+ }
206
213
  export function resolveHttpOptions() {
207
- const portRaw = readEnv("MCP_HTTP_PORT");
214
+ const portRaw = readString("MCP_HTTP_PORT")?.trim();
208
215
  const port = portRaw ? Number.parseInt(portRaw, 10) : 3000;
209
216
  if (!Number.isFinite(port) || port <= 0 || port > 65535) {
210
217
  throw new Error(`Invalid MCP_HTTP_PORT: ${portRaw}`);
211
218
  }
212
- const stateless = (readEnv("MCP_HTTP_STATEFUL") ?? "false").toLowerCase() !== "true";
213
- const enableJsonResponse = (readEnv("MCP_HTTP_JSON_RESPONSE") ?? "false").toLowerCase() === "true";
214
- const staticAuthRaw = (readEnv("BOOND_HTTP_STATIC_AUTH") ?? "").toLowerCase();
215
- const staticAuth = staticAuthRaw === "true" || staticAuthRaw === "1" || staticAuthRaw === "yes";
219
+ const stateless = !readBool("MCP_HTTP_STATEFUL", false);
220
+ const enableJsonResponse = readBool("MCP_HTTP_JSON_RESPONSE", false);
221
+ const staticAuth = readBool("BOOND_HTTP_STATIC_AUTH", false);
222
+ const insecureStaticAuth = readBool("MCP_HTTP_INSECURE_STATIC_AUTH", false);
223
+ // A blank key is "not configured", never an empty secret that every request matches.
224
+ const apiKey = readString("MCP_HTTP_API_KEY")?.trim();
216
225
  return {
217
- host: readEnv("MCP_HTTP_HOST") ?? "127.0.0.1",
226
+ host: readString("MCP_HTTP_HOST")?.trim() ?? "127.0.0.1",
218
227
  port,
219
- path: readEnv("MCP_HTTP_PATH") ?? "/mcp",
228
+ path: readString("MCP_HTTP_PATH")?.trim() ?? "/mcp",
220
229
  stateless,
221
230
  enableJsonResponse,
222
231
  sessionTtlMs: readPositiveInt("MCP_HTTP_SESSION_TTL_MS", DEFAULT_SESSION_TTL_MS),
223
232
  sessionSweepIntervalMs: readPositiveInt("MCP_HTTP_SESSION_SWEEP_INTERVAL_MS", DEFAULT_SESSION_SWEEP_INTERVAL_MS),
224
233
  maxSessions: readPositiveInt("MCP_HTTP_MAX_SESSIONS", DEFAULT_MAX_SESSIONS),
225
- allowedHosts: readAllowedHosts(),
226
- allowedOrigins: readAllowedOrigins(),
227
- publicUrl: readEnv("MCP_HTTP_PUBLIC_URL"),
234
+ ...optional("allowedHosts", readAllowedHosts()),
235
+ ...optional("allowedOrigins", readAllowedOrigins()),
236
+ ...optional("publicUrl", readUrl("MCP_HTTP_PUBLIC_URL")),
228
237
  staticAuth,
238
+ ...optional("apiKey", apiKey),
239
+ insecureStaticAuth,
240
+ keepAliveTimeoutMs: readPositiveInt("MCP_HTTP_KEEP_ALIVE_TIMEOUT_MS", DEFAULT_KEEP_ALIVE_TIMEOUT_MS),
241
+ headersTimeoutMs: readPositiveInt("MCP_HTTP_HEADERS_TIMEOUT_MS", DEFAULT_HEADERS_TIMEOUT_MS),
242
+ requestTimeoutMs: readPositiveInt("MCP_HTTP_REQUEST_TIMEOUT_MS", DEFAULT_REQUEST_TIMEOUT_MS),
243
+ shutdownTimeoutMs: readPositiveInt("MCP_HTTP_SHUTDOWN_TIMEOUT_MS", DEFAULT_SHUTDOWN_TIMEOUT_MS),
244
+ validateToken: readBool("MCP_HTTP_VALIDATE_TOKEN", false),
245
+ tokenValidationTtlMs: readPositiveInt("MCP_HTTP_TOKEN_VALIDATION_TTL_MS", DEFAULT_TOKEN_VALIDATION_TTL_MS),
229
246
  };
230
247
  }
248
+ /**
249
+ * The socket timeouts applied to the Node server, with the one invariant Node
250
+ * itself enforces made explicit: `headersTimeout` must be strictly greater
251
+ * than `keepAliveTimeout`, otherwise a kept-alive connection can be timed out
252
+ * while waiting for its next request's headers. A configuration that breaks it
253
+ * is repaired (headers = keep-alive + 1 s) and logged rather than refused.
254
+ */
255
+ export function resolveServerTimeouts(options) {
256
+ const keepAliveTimeout = options.keepAliveTimeoutMs ?? DEFAULT_KEEP_ALIVE_TIMEOUT_MS;
257
+ const requestedHeaders = options.headersTimeoutMs ?? DEFAULT_HEADERS_TIMEOUT_MS;
258
+ const coerced = requestedHeaders <= keepAliveTimeout;
259
+ const headersTimeout = coerced ? keepAliveTimeout + 1_000 : requestedHeaders;
260
+ const requestTimeout = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
261
+ return { keepAliveTimeout, headersTimeout, requestTimeout, coerced };
262
+ }
263
+ /**
264
+ * Refuses a static-auth deployment that would expose the operator's
265
+ * BoondManager credentials to anyone who can reach the port (issue #230).
266
+ *
267
+ * Static auth skips the Bearer check entirely, and off loopback the `Host` /
268
+ * `Origin` validations are disabled too — the Docker image binds `0.0.0.0` by
269
+ * default — so with no `apiKey` the endpoint is an anonymous proxy carrying
270
+ * the operator's read *and* write rights. Loopback is exempt (only local
271
+ * processes can connect), and `insecureStaticAuth` is the explicit,
272
+ * named opt-out for a deployment whose network is the boundary. Throwing here
273
+ * rather than warning is deliberate: a warning on stderr is exactly what an
274
+ * operator running `docker run -e BOOND_HTTP_STATIC_AUTH=true` does not read.
275
+ */
276
+ export function assertStaticAuthPolicy(options) {
277
+ if (!options.staticAuth || options.apiKey)
278
+ return;
279
+ if (LOOPBACK_HOSTS.has(options.host))
280
+ return;
281
+ if (options.insecureStaticAuth)
282
+ return;
283
+ throw new Error(`BOOND_HTTP_STATIC_AUTH=true on a non-loopback interface (${options.host}) requires MCP_HTTP_API_KEY: ` +
284
+ "without it, anyone who can reach the port acts with the operator's BoondManager credentials. " +
285
+ "Set MCP_HTTP_API_KEY=<secret> (clients send `Authorization: Bearer <secret>` or `X-Api-Key: <secret>`), " +
286
+ "bind to 127.0.0.1 behind an authenticating proxy, or set MCP_HTTP_INSECURE_STATIC_AUTH=1 to accept the exposure explicitly.");
287
+ }
288
+ /**
289
+ * Constant-time comparison of a presented API key against the configured one.
290
+ *
291
+ * `timingSafeEqual` throws on buffers of different lengths, so a length
292
+ * mismatch is answered by comparing the expected key against itself first —
293
+ * the call still costs one full comparison — and then returning `false`. The
294
+ * key's *length* is therefore observable, which is acceptable: it is not
295
+ * secret for a random key (the README generates 32 random bytes), and the
296
+ * alternative — hashing both sides to equalise lengths — reads to static
297
+ * analysers as a password stored under a fast hash, which it is not.
298
+ */
299
+ export function isApiKeyMatch(presented, expected) {
300
+ if (!presented)
301
+ return false;
302
+ const a = Buffer.from(presented, "utf8");
303
+ const b = Buffer.from(expected, "utf8");
304
+ if (a.length !== b.length) {
305
+ timingSafeEqual(b, b);
306
+ return false;
307
+ }
308
+ return timingSafeEqual(a, b);
309
+ }
310
+ /** The key a request presents: `Authorization: Bearer <key>` first, then `X-Api-Key`. */
311
+ function presentedApiKey(req) {
312
+ const bearer = extractBearerToken(req.headers["authorization"]);
313
+ if (bearer)
314
+ return bearer;
315
+ const raw = req.headers["x-api-key"];
316
+ const value = Array.isArray(raw) ? raw[0] : raw;
317
+ if (typeof value !== "string")
318
+ return null;
319
+ const trimmed = value.trim();
320
+ return trimmed.length > 0 ? trimmed : null;
321
+ }
231
322
  /**
232
323
  * Build the canonical "resource" URL advertised in the OAuth2 protected
233
324
  * resource metadata and in the `WWW-Authenticate` challenge. Behind a
@@ -241,13 +332,34 @@ function resolveResourceUrl(options) {
241
332
  }
242
333
  /** Max accepted request body size (1 MiB). MCP initialize payloads are tiny;
243
334
  * this caps the memory a single authenticated request can force us to buffer. */
244
- const MAX_BODY_BYTES = 1024 * 1024;
335
+ export const MAX_BODY_BYTES = 1024 * 1024;
245
336
  class PayloadTooLargeError extends Error {
246
337
  constructor() {
247
338
  super("Request body exceeds the maximum allowed size");
248
339
  this.name = "PayloadTooLargeError";
249
340
  }
250
341
  }
342
+ /**
343
+ * Buffer a request body under `MAX_BODY_BYTES`, then parse it as JSON.
344
+ *
345
+ * This is the **only** body reader on the MCP endpoint (issue #227). Every
346
+ * POST — stateless, existing stateful session, or `initialize` — goes through
347
+ * it and the parsed value is handed to `transport.handleRequest(req, res,
348
+ * parsedBody)`, so the SDK (which reads without a ceiling) never touches the
349
+ * stream. The `Content-Length` precheck upstream only covers clients that
350
+ * announce their size; a chunked transfer (no `Content-Length`, which is also
351
+ * what a reverse proxy that re-encodes produces) is caught here, by counting
352
+ * bytes as they arrive.
353
+ *
354
+ * On overflow the loop stops without `req.destroy()`: destroying the socket
355
+ * would tear down the response along with the request, and the client would
356
+ * see a reset instead of the `413` (the caller answers with `Connection:
357
+ * close`, and Node closes the socket once that response is flushed).
358
+ *
359
+ * An unparseable body is reported as `invalid` rather than `undefined`: a
360
+ * `parsedBody` of `undefined` makes the SDK fall back to `req.json()` on a
361
+ * stream that has already been consumed.
362
+ */
251
363
  async function readJsonBody(req) {
252
364
  const chunks = [];
253
365
  let total = 0;
@@ -255,30 +367,44 @@ async function readJsonBody(req) {
255
367
  const buf = chunk;
256
368
  total += buf.length;
257
369
  if (total > MAX_BODY_BYTES) {
258
- req.destroy();
259
370
  throw new PayloadTooLargeError();
260
371
  }
261
372
  chunks.push(buf);
262
373
  }
263
374
  const raw = Buffer.concat(chunks).toString("utf8");
264
- if (!raw)
265
- return undefined;
266
375
  try {
267
- return JSON.parse(raw);
376
+ return { kind: "json", value: JSON.parse(raw) };
268
377
  }
269
378
  catch {
270
- return undefined;
379
+ return { kind: "invalid" };
271
380
  }
272
381
  }
273
- function writeJsonRpcError(res, status, message) {
382
+ /** JSON-RPC 2.0 reserved code for a body that is not parseable JSON. */
383
+ const JSON_RPC_PARSE_ERROR = -32700;
384
+ function writeJsonRpcError(res, status, message, code = -32000) {
274
385
  res.statusCode = status;
275
386
  res.setHeader("Content-Type", "application/json");
276
387
  res.end(JSON.stringify({
277
388
  jsonrpc: "2.0",
278
- error: { code: -32000, message },
389
+ error: { code, message },
279
390
  id: null,
280
391
  }));
281
392
  }
393
+ /**
394
+ * `413` for a body that blew through `MAX_BODY_BYTES` mid-stream. The request
395
+ * stream is left unread past the overflow point, so the response must not be
396
+ * reused on a keep-alive connection: `Connection: close` makes Node destroy
397
+ * the socket right after this response is flushed, instead of draining (and
398
+ * therefore fully receiving) whatever the client still has to send.
399
+ */
400
+ function writePayloadTooLarge(res) {
401
+ if (res.headersSent) {
402
+ res.end();
403
+ return;
404
+ }
405
+ res.setHeader("Connection", "close");
406
+ writeJsonRpcError(res, 413, "Request body too large");
407
+ }
282
408
  /**
283
409
  * Tear down a session's transport + server exactly once. Several paths can race
284
410
  * to dispose the same entry (idle sweep, the transport's own `onclose`, the
@@ -297,6 +423,22 @@ function destroySession(entry) {
297
423
  return entry.closing;
298
424
  }
299
425
  export async function startHttpTransport(createServerFactory, options) {
426
+ assertStaticAuthPolicy(options);
427
+ if (options.staticAuth) {
428
+ logger.warn({
429
+ host: options.host,
430
+ apiKey: options.apiKey ? "configured" : "none",
431
+ insecureStaticAuth: options.insecureStaticAuth === true,
432
+ }, "Static auth mode: every MCP request runs with the operator's BoondManager credentials" +
433
+ (options.apiKey
434
+ ? "; clients must present MCP_HTTP_API_KEY"
435
+ : LOOPBACK_HOSTS.has(options.host)
436
+ ? "; no client authentication (loopback bind only)"
437
+ : "; NO CLIENT AUTHENTICATION on a non-loopback bind (MCP_HTTP_INSECURE_STATIC_AUTH)"));
438
+ }
439
+ else if (options.apiKey) {
440
+ logger.warn("MCP_HTTP_API_KEY is set but BOOND_HTTP_STATIC_AUTH is not: ignored. In OAuth mode the Bearer is the BoondManager access token.");
441
+ }
300
442
  const sessions = new Map();
301
443
  const sessionTtlMs = options.sessionTtlMs ?? DEFAULT_SESSION_TTL_MS;
302
444
  const sessionSweepIntervalMs = options.sessionSweepIntervalMs ?? DEFAULT_SESSION_SWEEP_INTERVAL_MS;
@@ -316,6 +458,50 @@ export async function startHttpTransport(createServerFactory, options) {
316
458
  : resourceUrl;
317
459
  const metadataUrl = `${resourceOrigin}${OAUTH_METADATA_PATH}${options.path}`;
318
460
  const wwwAuthenticate = `Bearer realm="${resourceUrl}", resource_metadata="${metadataUrl}"`;
461
+ // Bearer validation cache (issue #234): sha256(token) → verdict + expiry,
462
+ // LRU-bounded like the other per-identity maps. The token itself is never
463
+ // stored. A negative verdict is cached too, so a client retrying with a
464
+ // dead token does not turn into a BoondManager call per retry.
465
+ const tokenValidationTtlMs = options.tokenValidationTtlMs ?? DEFAULT_TOKEN_VALIDATION_TTL_MS;
466
+ const tokenVerdicts = new Map();
467
+ const validateAccessToken = async (accessToken) => {
468
+ const key = createHash("sha256").update(accessToken).digest("hex");
469
+ const now = Date.now();
470
+ const cached = tokenVerdicts.get(key);
471
+ if (cached && cached.expiresAt > now) {
472
+ // Refresh LRU position.
473
+ tokenVerdicts.delete(key);
474
+ tokenVerdicts.set(key, cached);
475
+ return cached.valid ? "valid" : "invalid";
476
+ }
477
+ let verdict;
478
+ try {
479
+ await oauthContext.run({ accessToken }, () => apiRequest("/application/current-user"));
480
+ verdict = "valid";
481
+ }
482
+ catch (error) {
483
+ if (error instanceof BoondApiError && error.status === 401) {
484
+ verdict = "invalid";
485
+ }
486
+ else {
487
+ // BoondManager down, rate-limited, network error: not a verdict on
488
+ // the token. Fail open — the tool call will surface the real error —
489
+ // and cache nothing.
490
+ logger.warn({ err: error }, "Token validation could not reach BoondManager; letting the request through");
491
+ verdict = "unknown";
492
+ }
493
+ }
494
+ if (verdict !== "unknown") {
495
+ tokenVerdicts.set(key, { valid: verdict === "valid", expiresAt: now + tokenValidationTtlMs });
496
+ while (tokenVerdicts.size > MAX_TOKEN_VALIDATION_ENTRIES) {
497
+ const oldest = tokenVerdicts.keys().next().value;
498
+ if (oldest === undefined)
499
+ break;
500
+ tokenVerdicts.delete(oldest);
501
+ }
502
+ }
503
+ return verdict;
504
+ };
319
505
  const sweepIdleSessions = async () => {
320
506
  const cutoff = Date.now() - sessionTtlMs;
321
507
  const expired = [];
@@ -350,10 +536,17 @@ export async function startHttpTransport(createServerFactory, options) {
350
536
  res.setHeader("Cache-Control", "public, max-age=3600");
351
537
  res.end(JSON.stringify(doc));
352
538
  };
353
- /** RFC 6750 §3.1 challenge for missing/invalid bearer tokens. */
354
- const writeOAuthChallenge = (res, status, message) => {
539
+ /**
540
+ * RFC 6750 §3.1 challenge. `error` is omitted when no token was presented
541
+ * (the RFC says a bare challenge is the right answer there) and set to
542
+ * `invalid_token` when one was and BoondManager rejected it — that value is
543
+ * what an MCP client keys its "start a new authorization" logic on.
544
+ */
545
+ const writeOAuthChallenge = (res, status, message, error) => {
355
546
  res.statusCode = status;
356
- res.setHeader("WWW-Authenticate", wwwAuthenticate);
547
+ res.setHeader("WWW-Authenticate", error
548
+ ? `${wwwAuthenticate}, error="${error}", error_description="${message.replace(/"/g, "'")}"`
549
+ : wwwAuthenticate);
357
550
  res.setHeader("Content-Type", "application/json");
358
551
  res.end(JSON.stringify({
359
552
  jsonrpc: "2.0",
@@ -361,9 +554,50 @@ export async function startHttpTransport(createServerFactory, options) {
361
554
  id: null,
362
555
  }));
363
556
  };
364
- const httpServer = createServer(async (req, res) => {
557
+ const handleRequest = async (req, res) => {
365
558
  const corrId = generateCorrelationId();
366
- const reqLogger = logger.child({ corrId, method: req.method, path: req.url });
559
+ // Path only: the query string of a request is never logged (#236).
560
+ const path = req.url?.split("?")[0];
561
+ const reqLogger = logger.child({ corrId, method: req.method, path });
562
+ const startedAt = Date.now();
563
+ let rejection;
564
+ // Access log (#236): one line per request when the response is finished,
565
+ // or when the client went away before that. 401 / 403 / 413 (and the
566
+ // other rejections this handler writes itself) are `warn` with their
567
+ // reason; 5xx is `error`; the liveness probe stays at `debug` so a
568
+ // 10-second Kubernetes probe does not fill the log.
569
+ let accessLogged = false;
570
+ const writeAccessLog = () => {
571
+ if (accessLogged)
572
+ return;
573
+ accessLogged = true;
574
+ const status = res.statusCode;
575
+ const fields = {
576
+ status,
577
+ durationMs: Date.now() - startedAt,
578
+ ...(rejection !== undefined ? { reason: rejection } : {}),
579
+ ...(res.writableFinished ? {} : { aborted: true }),
580
+ };
581
+ if (path === "/healthz")
582
+ reqLogger.debug(fields, "http request");
583
+ else if (status >= 500)
584
+ reqLogger.error(fields, "http request");
585
+ else if (rejection !== undefined || status === 401 || status === 403 || status === 413) {
586
+ reqLogger.warn(fields, "http request");
587
+ }
588
+ else
589
+ reqLogger.info(fields, "http request");
590
+ };
591
+ res.on("finish", writeAccessLog);
592
+ res.on("close", writeAccessLog);
593
+ const reject = (status, message, code) => {
594
+ rejection = message;
595
+ writeJsonRpcError(res, status, message, code);
596
+ };
597
+ const challenge = (status, message, error) => {
598
+ rejection = message;
599
+ writeOAuthChallenge(res, status, message, error);
600
+ };
367
601
  try {
368
602
  // Liveness probe — served before Host validation so Docker/Kubernetes
369
603
  // probes (which often send the pod IP as Host) always succeed. GET only,
@@ -384,11 +618,11 @@ export async function startHttpTransport(createServerFactory, options) {
384
618
  if (allowedHosts.length > 0) {
385
619
  const hostname = extractHostname(req.headers.host);
386
620
  if (!hostname) {
387
- writeJsonRpcError(res, 403, "Missing or invalid Host header");
621
+ reject(403, "Missing or invalid Host header");
388
622
  return;
389
623
  }
390
624
  if (!allowedHosts.includes(hostname)) {
391
- writeJsonRpcError(res, 403, `Invalid Host: ${hostname}`);
625
+ reject(403, `Invalid Host: ${hostname}`);
392
626
  return;
393
627
  }
394
628
  }
@@ -402,10 +636,9 @@ export async function startHttpTransport(createServerFactory, options) {
402
636
  // *because* we pointed it there from a 401 challenge — 403ing it would
403
637
  // dead-end the OAuth bootstrap the challenge just started.
404
638
  if (originPolicy.enabled && !isDiscoveryPath(req.url, options.path)) {
405
- const originHeader = req.headers.origin;
406
- const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
639
+ const origin = req.headers.origin;
407
640
  if (origin && !isOriginAllowed(originPolicy, origin)) {
408
- writeJsonRpcError(res, 403, `Invalid Origin: ${origin}`);
641
+ reject(403, `Invalid Origin: ${origin}`);
409
642
  return;
410
643
  }
411
644
  }
@@ -414,7 +647,12 @@ export async function startHttpTransport(createServerFactory, options) {
414
647
  // readJsonBody still covers chunked / lying Content-Length cases.
415
648
  const contentLength = Number(req.headers["content-length"]);
416
649
  if (Number.isFinite(contentLength) && contentLength > MAX_BODY_BYTES) {
417
- writeJsonRpcError(res, 413, "Request body too large");
650
+ // Plain 413, deliberately *without* `Connection: close`: Node drains
651
+ // the announced body itself (`req._dump()`), so the connection is
652
+ // reusable — and closing it early makes a client still uploading see
653
+ // a reset instead of the 413 (undici's fetch rejects). The mid-stream
654
+ // overflow below is different: its stream is left unread.
655
+ reject(413, "Request body too large");
418
656
  return;
419
657
  }
420
658
  const url = new URL(req.url ?? "/", `http://${req.headers.host ?? "localhost"}`);
@@ -432,13 +670,29 @@ export async function startHttpTransport(createServerFactory, options) {
432
670
  }
433
671
  // Core MCP dispatch — shared between OAuth and static-auth paths.
434
672
  const dispatchMcpRequest = async () => {
435
- if (options.stateless) {
436
- if (req.method !== "POST") {
437
- writeJsonRpcError(res, 405, "Only POST is supported in stateless mode");
673
+ if (options.stateless && req.method !== "POST") {
674
+ reject(405, "Only POST is supported in stateless mode");
675
+ return;
676
+ }
677
+ // The body is read *here*, once, under the 1 MiB streaming cap, and
678
+ // handed to the SDK pre-parsed on every path below — the SDK's own
679
+ // reader has no ceiling, so letting it read on any path would leave
680
+ // that path uncapped (#227). GET (SSE stream) and DELETE carry no body
681
+ // and the SDK never reads one for them.
682
+ let parsedBody;
683
+ if (req.method === "POST") {
684
+ const read = await readJsonBody(req);
685
+ if (read.kind === "invalid") {
686
+ reject(400, "Parse error: Invalid JSON", JSON_RPC_PARSE_ERROR);
438
687
  return;
439
688
  }
689
+ parsedBody = read.value;
690
+ }
691
+ if (options.stateless) {
692
+ // No `sessionIdGenerator` = stateless mode (the SDK documents the
693
+ // key as `undefined`; omitting it is the same and type-checks under
694
+ // exactOptionalPropertyTypes).
440
695
  const transport = new StreamableHTTPServerTransport({
441
- sessionIdGenerator: undefined,
442
696
  enableJsonResponse: options.enableJsonResponse,
443
697
  });
444
698
  const server = createServerFactory();
@@ -446,27 +700,41 @@ export async function startHttpTransport(createServerFactory, options) {
446
700
  void transport.close();
447
701
  void server.close();
448
702
  });
449
- await server.connect(transport);
450
- await transport.handleRequest(req, res);
703
+ await server.connect(asTransport(transport));
704
+ await transport.handleRequest(req, res, parsedBody);
451
705
  return;
452
706
  }
453
707
  // Stateful mode: route by Mcp-Session-Id header
454
708
  const sessionIdHeader = req.headers["mcp-session-id"];
455
709
  const sessionId = Array.isArray(sessionIdHeader) ? sessionIdHeader[0] : sessionIdHeader;
456
- if (sessionId && sessions.has(sessionId)) {
710
+ const identity = currentAuthIdentity();
711
+ if (sessionId) {
457
712
  const entry = sessions.get(sessionId);
458
- entry.lastActivityAt = Date.now();
459
- await entry.transport.handleRequest(req, res);
460
- return;
713
+ if (entry && entry.ownerIdentity === identity) {
714
+ entry.lastActivityAt = Date.now();
715
+ await entry.transport.handleRequest(req, res, parsedBody);
716
+ return;
717
+ }
718
+ // Unknown session, or a session opened under another identity: one
719
+ // answer for both, so the probe does not learn whether the id exists
720
+ // (#232). A POST `initialize` carrying a stale id still opens a new
721
+ // session below, as it always did.
722
+ if (!(req.method === "POST" && isInitializeRequest(parsedBody))) {
723
+ if (entry) {
724
+ reqLogger.warn({ sessionId }, "Session ownership mismatch; answering as unknown session");
725
+ }
726
+ reject(404, "Session not found", -32001);
727
+ return;
728
+ }
461
729
  }
462
730
  if (req.method !== "POST") {
463
- writeJsonRpcError(res, 400, "Missing or invalid session ID");
731
+ reject(400, "Missing or invalid session ID");
464
732
  return;
465
733
  }
466
- // Parse body to detect initialization
467
- const body = await readJsonBody(req);
734
+ // First request of a session must be `initialize`
735
+ const body = parsedBody;
468
736
  if (!isInitializeRequest(body)) {
469
- writeJsonRpcError(res, 400, "First request must be an MCP initialize message");
737
+ reject(400, "First request must be an MCP initialize message");
470
738
  return;
471
739
  }
472
740
  // Cap concurrent sessions. Try a sweep first in case the ceiling is
@@ -475,7 +743,7 @@ export async function startHttpTransport(createServerFactory, options) {
475
743
  await sweepIdleSessions();
476
744
  if (sessions.size >= maxSessions) {
477
745
  reqLogger.warn({ sessionCount: sessions.size, maxSessions }, "Session limit reached; rejecting new initialize");
478
- writeJsonRpcError(res, 503, "Server session limit reached; retry later");
746
+ reject(503, "Server session limit reached; retry later");
479
747
  return;
480
748
  }
481
749
  }
@@ -483,7 +751,7 @@ export async function startHttpTransport(createServerFactory, options) {
483
751
  sessionIdGenerator: () => randomUUID(),
484
752
  enableJsonResponse: options.enableJsonResponse,
485
753
  onsessioninitialized: (id) => {
486
- sessions.set(id, { transport, server, lastActivityAt: Date.now() });
754
+ sessions.set(id, { transport, server, lastActivityAt: Date.now(), ownerIdentity: identity });
487
755
  reqLogger.info({ sessionId: id, sessionCount: sessions.size }, "MCP session initialized");
488
756
  },
489
757
  onsessionclosed: (id) => {
@@ -509,35 +777,41 @@ export async function startHttpTransport(createServerFactory, options) {
509
777
  }
510
778
  };
511
779
  const server = createServerFactory();
512
- await server.connect(transport);
780
+ await server.connect(asTransport(transport));
513
781
  await transport.handleRequest(req, res, body);
514
782
  };
515
783
  if (options.staticAuth) {
516
784
  // Static-auth mode: env-based JWT credentials configured at startup via
517
- // initClient(). No Bearer token required from the MCP client.
518
- await dispatchMcpRequest();
785
+ // initClient(). The MCP client authenticates with the shared API key
786
+ // when one is configured (#230) — never with a BoondManager token.
787
+ if (options.apiKey && !isApiKeyMatch(presentedApiKey(req), options.apiKey)) {
788
+ res.setHeader("WWW-Authenticate", `Bearer realm="${resourceUrl}"`);
789
+ reject(401, "Missing or invalid API key. Send `Authorization: Bearer <MCP_HTTP_API_KEY>` or `X-Api-Key: <MCP_HTTP_API_KEY>`.", -32001);
790
+ return;
791
+ }
792
+ await runWithRequestContext({ corrId }, dispatchMcpRequest);
519
793
  }
520
794
  else {
521
795
  // OAuth2 Bearer is mandatory on the MCP endpoint. The token is opaque
522
796
  // to us — we forward it to BoondManager, which is authoritative.
523
797
  const accessToken = extractBearerToken(req.headers["authorization"]);
524
798
  if (!accessToken) {
525
- writeOAuthChallenge(res, 401, "Missing Bearer token. Authenticate against BoondManager and include `Authorization: Bearer <access_token>`.");
799
+ challenge(401, "Missing Bearer token. Authenticate against BoondManager and include `Authorization: Bearer <access_token>`.");
800
+ return;
801
+ }
802
+ if (options.validateToken && (await validateAccessToken(accessToken)) === "invalid") {
803
+ reqLogger.info("Bearer rejected by BoondManager; answering 401 invalid_token");
804
+ challenge(401, "BoondManager rejected the access token (expired or revoked). Re-authorize and retry.", "invalid_token");
526
805
  return;
527
806
  }
528
807
  // Wrap in AsyncLocalStorage so boond-client's oauthContextAuth can pull
529
808
  // the token out when issuing API calls.
530
- await oauthContext.run({ accessToken }, dispatchMcpRequest);
809
+ await oauthContext.run({ accessToken }, () => runWithRequestContext({ corrId }, dispatchMcpRequest));
531
810
  }
532
811
  }
533
812
  catch (error) {
534
813
  if (error instanceof PayloadTooLargeError) {
535
- if (!res.headersSent) {
536
- writeJsonRpcError(res, 413, "Request body too large");
537
- }
538
- else {
539
- res.end();
540
- }
814
+ writePayloadTooLarge(res);
541
815
  return;
542
816
  }
543
817
  reqLogger.error({ err: error }, "HTTP transport error");
@@ -548,7 +822,29 @@ export async function startHttpTransport(createServerFactory, options) {
548
822
  res.end();
549
823
  }
550
824
  }
825
+ };
826
+ // `createServer` takes a sync listener: an async callback's rejection would
827
+ // be an unhandled promise, invisible to the client (issue #237). The handler
828
+ // catches everything it can above; this is the last net.
829
+ const httpServer = createServer((req, res) => {
830
+ void handleRequest(req, res).catch((error) => {
831
+ logger.error({ err: error }, "Unhandled HTTP transport error");
832
+ if (!res.headersSent) {
833
+ writeJsonRpcError(res, 500, "Internal server error");
834
+ }
835
+ else {
836
+ res.end();
837
+ }
838
+ });
551
839
  });
840
+ const timeouts = resolveServerTimeouts(options);
841
+ if (timeouts.coerced) {
842
+ logger.warn({ requested: options.headersTimeoutMs, applied: timeouts.headersTimeout, keepAlive: timeouts.keepAliveTimeout }, "MCP_HTTP_HEADERS_TIMEOUT_MS must exceed the keep-alive timeout; raised to keep-alive + 1 s");
843
+ }
844
+ httpServer.keepAliveTimeout = timeouts.keepAliveTimeout;
845
+ httpServer.headersTimeout = timeouts.headersTimeout;
846
+ httpServer.requestTimeout = timeouts.requestTimeout;
847
+ const shutdownTimeoutMs = options.shutdownTimeoutMs ?? DEFAULT_SHUTDOWN_TIMEOUT_MS;
552
848
  await new Promise((resolve, reject) => {
553
849
  httpServer.once("error", reject);
554
850
  httpServer.listen(options.port, options.host, () => {
@@ -560,20 +856,54 @@ export async function startHttpTransport(createServerFactory, options) {
560
856
  // the kernel picks a free port and only server.address() knows which one.
561
857
  const bound = httpServer.address();
562
858
  const boundPort = bound && typeof bound === "object" ? bound.port : options.port;
563
- return {
564
- address: { host: options.host, port: boundPort, path: options.path },
565
- sessionCount: () => sessions.size,
566
- sweepIdleSessions,
567
- close: async () => {
859
+ // Memoised so a second close() — a second SIGTERM, or a caller's cleanup
860
+ // racing the signal handler — waits on the same shutdown instead of calling
861
+ // `server.close()` on a closed server (ERR_SERVER_NOT_RUNNING, unhandled).
862
+ let closing;
863
+ const close = () => {
864
+ if (closing)
865
+ return closing;
866
+ closing = (async () => {
568
867
  if (sweepTimer)
569
868
  clearInterval(sweepTimer);
570
869
  const entries = Array.from(sessions.values());
571
870
  sessions.clear();
572
871
  await Promise.all(entries.map((e) => destroySession(e)));
573
872
  await new Promise((resolve, reject) => {
574
- httpServer.close((err) => (err ? reject(err) : resolve()));
873
+ // `server.close()` stops accepting, reaps idle keep-alive sockets once
874
+ // (Node's `httpServerPreClose` → `closeIdleConnections()`), and
875
+ // resolves when every remaining connection has ended. Two things
876
+ // never end on their own: an open SSE stream / a request stuck
877
+ // mid-headers (destroyed after the grace period), and a keep-alive
878
+ // socket that was still draining a body at that single reap — Node
879
+ // never re-checks it, and with a 65 s keep-alive it would hold the
880
+ // close for the whole grace period. So idle reaping is repeated until
881
+ // the server is closed.
882
+ const force = setTimeout(() => {
883
+ logger.warn({ shutdownTimeoutMs }, "Shutdown grace period elapsed; destroying remaining connections");
884
+ httpServer.closeAllConnections();
885
+ }, shutdownTimeoutMs);
886
+ force.unref();
887
+ const reapIdle = setInterval(() => httpServer.closeIdleConnections(), IDLE_REAP_INTERVAL_MS);
888
+ reapIdle.unref();
889
+ httpServer.close((err) => {
890
+ clearTimeout(force);
891
+ clearInterval(reapIdle);
892
+ if (err)
893
+ reject(err);
894
+ else
895
+ resolve();
896
+ });
575
897
  });
576
- },
898
+ })();
899
+ return closing;
900
+ };
901
+ return {
902
+ address: { host: options.host, port: boundPort, path: options.path },
903
+ server: httpServer,
904
+ sessionCount: () => sessions.size,
905
+ sweepIdleSessions,
906
+ close,
577
907
  };
578
908
  }
579
909
  //# sourceMappingURL=http.js.map