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.
- package/LICENSE +1 -1
- package/NOTICE +16 -2
- package/README.md +60 -15
- package/dist/config/access-policy.d.ts.map +1 -1
- package/dist/config/access-policy.js +6 -18
- package/dist/config/access-policy.js.map +1 -1
- package/dist/config/dictionary-overrides.d.ts.map +1 -1
- package/dist/config/dictionary-overrides.js +2 -12
- package/dist/config/dictionary-overrides.js.map +1 -1
- package/dist/config/env.d.ts +63 -0
- package/dist/config/env.d.ts.map +1 -0
- package/dist/config/env.js +116 -0
- package/dist/config/env.js.map +1 -0
- package/dist/config/profiles.d.ts +22 -8
- package/dist/config/profiles.d.ts.map +1 -1
- package/dist/config/profiles.js +48 -4
- package/dist/config/profiles.js.map +1 -1
- package/dist/constants.d.ts +13 -2
- package/dist/constants.d.ts.map +1 -1
- package/dist/constants.js +25 -1
- package/dist/constants.js.map +1 -1
- package/dist/icons.d.ts.map +1 -1
- package/dist/icons.js +6 -1
- package/dist/icons.js.map +1 -1
- package/dist/index.js +33 -15
- package/dist/index.js.map +1 -1
- package/dist/instructions.d.ts +1 -1
- package/dist/instructions.d.ts.map +1 -1
- package/dist/instructions.js +6 -6
- package/dist/instructions.js.map +1 -1
- package/dist/prompts/index.d.ts +19 -1
- package/dist/prompts/index.d.ts.map +1 -1
- package/dist/prompts/index.js +495 -25
- package/dist/prompts/index.js.map +1 -1
- package/dist/prompts/periods.d.ts +45 -0
- package/dist/prompts/periods.d.ts.map +1 -0
- package/dist/prompts/periods.js +154 -0
- package/dist/prompts/periods.js.map +1 -0
- package/dist/resources/index.d.ts +14 -0
- package/dist/resources/index.d.ts.map +1 -1
- package/dist/resources/index.js +285 -0
- package/dist/resources/index.js.map +1 -1
- package/dist/resources/templates.d.ts +1 -1
- package/dist/resources/templates.d.ts.map +1 -1
- package/dist/resources/templates.js +2 -0
- package/dist/resources/templates.js.map +1 -1
- package/dist/schema-dialect.js.map +1 -1
- package/dist/schemas/filter-aliases.d.ts +1 -1
- package/dist/schemas/filter-aliases.d.ts.map +1 -1
- package/dist/schemas/filter-aliases.js +101 -2
- package/dist/schemas/filter-aliases.js.map +1 -1
- package/dist/schemas/index.d.ts +987 -161
- package/dist/schemas/index.d.ts.map +1 -1
- package/dist/schemas/index.js +853 -323
- package/dist/schemas/index.js.map +1 -1
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +10 -3
- package/dist/server.js.map +1 -1
- package/dist/services/boond-client.d.ts +29 -213
- package/dist/services/boond-client.d.ts.map +1 -1
- package/dist/services/boond-client.js +29 -1168
- package/dist/services/boond-client.js.map +1 -1
- package/dist/services/dictionary.d.ts +27 -3
- package/dist/services/dictionary.d.ts.map +1 -1
- package/dist/services/dictionary.js +60 -23
- package/dist/services/dictionary.js.map +1 -1
- package/dist/services/document-text.d.ts +32 -0
- package/dist/services/document-text.d.ts.map +1 -0
- package/dist/services/document-text.js +120 -0
- package/dist/services/document-text.js.map +1 -0
- package/dist/services/format/detail.d.ts +16 -0
- package/dist/services/format/detail.d.ts.map +1 -0
- package/dist/services/format/detail.js +36 -0
- package/dist/services/format/detail.js.map +1 -0
- package/dist/services/format/html.d.ts +19 -0
- package/dist/services/format/html.d.ts.map +1 -0
- package/dist/services/format/html.js +87 -0
- package/dist/services/format/html.js.map +1 -0
- package/dist/services/format/list.d.ts +3 -0
- package/dist/services/format/list.d.ts.map +1 -0
- package/dist/services/format/list.js +44 -0
- package/dist/services/format/list.js.map +1 -0
- package/dist/services/format/summary.d.ts +16 -0
- package/dist/services/format/summary.d.ts.map +1 -0
- package/dist/services/format/summary.js +172 -0
- package/dist/services/format/summary.js.map +1 -0
- package/dist/services/format/tab.d.ts +9 -0
- package/dist/services/format/tab.d.ts.map +1 -0
- package/dist/services/format/tab.js +32 -0
- package/dist/services/format/tab.js.map +1 -0
- package/dist/services/http/auth.d.ts +44 -0
- package/dist/services/http/auth.d.ts.map +1 -0
- package/dist/services/http/auth.js +146 -0
- package/dist/services/http/auth.js.map +1 -0
- package/dist/services/http/download.d.ts +60 -0
- package/dist/services/http/download.d.ts.map +1 -0
- package/dist/services/http/download.js +160 -0
- package/dist/services/http/download.js.map +1 -0
- package/dist/services/http/errors.d.ts +45 -0
- package/dist/services/http/errors.d.ts.map +1 -0
- package/dist/services/http/errors.js +188 -0
- package/dist/services/http/errors.js.map +1 -0
- package/dist/services/http/rate-limit.d.ts +40 -0
- package/dist/services/http/rate-limit.d.ts.map +1 -0
- package/dist/services/http/rate-limit.js +92 -0
- package/dist/services/http/rate-limit.js.map +1 -0
- package/dist/services/http/retry.d.ts +46 -0
- package/dist/services/http/retry.d.ts.map +1 -0
- package/dist/services/http/retry.js +101 -0
- package/dist/services/http/retry.js.map +1 -0
- package/dist/services/http/transport.d.ts +73 -0
- package/dist/services/http/transport.d.ts.map +1 -0
- package/dist/services/http/transport.js +272 -0
- package/dist/services/http/transport.js.map +1 -0
- package/dist/services/logger.d.ts +53 -2
- package/dist/services/logger.d.ts.map +1 -1
- package/dist/services/logger.js +82 -21
- package/dist/services/logger.js.map +1 -1
- package/dist/services/oauth.d.ts +12 -0
- package/dist/services/oauth.d.ts.map +1 -1
- package/dist/services/oauth.js +21 -8
- package/dist/services/oauth.js.map +1 -1
- package/dist/services/rate-limiter.d.ts +9 -2
- package/dist/services/rate-limiter.d.ts.map +1 -1
- package/dist/services/rate-limiter.js +18 -6
- package/dist/services/rate-limiter.js.map +1 -1
- package/dist/services/request-context.d.ts +47 -0
- package/dist/services/request-context.d.ts.map +1 -0
- package/dist/services/request-context.js +112 -0
- package/dist/services/request-context.js.map +1 -0
- package/dist/services/search.d.ts +26 -0
- package/dist/services/search.d.ts.map +1 -0
- package/dist/services/search.js +98 -0
- package/dist/services/search.js.map +1 -0
- package/dist/services/update-checker.d.ts.map +1 -1
- package/dist/services/update-checker.js +15 -8
- package/dist/services/update-checker.js.map +1 -1
- package/dist/tools/absences.d.ts +9 -0
- package/dist/tools/absences.d.ts.map +1 -1
- package/dist/tools/absences.js +65 -20
- package/dist/tools/absences.js.map +1 -1
- package/dist/tools/actions.d.ts.map +1 -1
- package/dist/tools/actions.js +11 -27
- package/dist/tools/actions.js.map +1 -1
- package/dist/tools/advantages.d.ts +2 -0
- package/dist/tools/advantages.d.ts.map +1 -1
- package/dist/tools/advantages.js +66 -13
- package/dist/tools/advantages.js.map +1 -1
- package/dist/tools/alerts.d.ts +18 -0
- package/dist/tools/alerts.d.ts.map +1 -0
- package/dist/tools/alerts.js +82 -0
- package/dist/tools/alerts.js.map +1 -0
- package/dist/tools/application.d.ts +2 -1
- package/dist/tools/application.d.ts.map +1 -1
- package/dist/tools/application.js +9 -3
- package/dist/tools/application.js.map +1 -1
- package/dist/tools/contacts.d.ts.map +1 -1
- package/dist/tools/contacts.js +3 -7
- package/dist/tools/contacts.js.map +1 -1
- package/dist/tools/contracts.d.ts +22 -0
- package/dist/tools/contracts.d.ts.map +1 -1
- package/dist/tools/contracts.js +185 -52
- package/dist/tools/contracts.js.map +1 -1
- package/dist/tools/crud-factory.d.ts +33 -2
- package/dist/tools/crud-factory.d.ts.map +1 -1
- package/dist/tools/crud-factory.js +42 -15
- package/dist/tools/crud-factory.js.map +1 -1
- package/dist/tools/deliveries.d.ts +3 -0
- package/dist/tools/deliveries.d.ts.map +1 -1
- package/dist/tools/deliveries.js +50 -94
- package/dist/tools/deliveries.js.map +1 -1
- package/dist/tools/description-builders.d.ts +0 -1
- package/dist/tools/description-builders.d.ts.map +1 -1
- package/dist/tools/description-builders.js +46 -3
- package/dist/tools/description-builders.js.map +1 -1
- package/dist/tools/documents.d.ts.map +1 -1
- package/dist/tools/documents.js +73 -21
- package/dist/tools/documents.js.map +1 -1
- package/dist/tools/expenses.d.ts.map +1 -1
- package/dist/tools/expenses.js +2 -11
- package/dist/tools/expenses.js.map +1 -1
- package/dist/tools/find.d.ts +61 -0
- package/dist/tools/find.d.ts.map +1 -0
- package/dist/tools/find.js +221 -0
- package/dist/tools/find.js.map +1 -0
- package/dist/tools/flags.d.ts +5 -0
- package/dist/tools/flags.d.ts.map +1 -1
- package/dist/tools/flags.js +116 -1
- package/dist/tools/flags.js.map +1 -1
- package/dist/tools/forms.d.ts +5 -0
- package/dist/tools/forms.d.ts.map +1 -0
- package/dist/tools/forms.js +64 -0
- package/dist/tools/forms.js.map +1 -0
- package/dist/tools/groupments.d.ts +5 -0
- package/dist/tools/groupments.d.ts.map +1 -0
- package/dist/tools/groupments.js +68 -0
- package/dist/tools/groupments.js.map +1 -0
- package/dist/tools/inactivities.d.ts +5 -0
- package/dist/tools/inactivities.d.ts.map +1 -0
- package/dist/tools/inactivities.js +59 -0
- package/dist/tools/inactivities.js.map +1 -0
- package/dist/tools/index.d.ts +4 -0
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +4 -0
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/invoices.d.ts.map +1 -1
- package/dist/tools/invoices.js +27 -2
- package/dist/tools/invoices.js.map +1 -1
- package/dist/tools/linked-entity-filters.d.ts +48 -0
- package/dist/tools/linked-entity-filters.d.ts.map +1 -0
- package/dist/tools/linked-entity-filters.js +64 -0
- package/dist/tools/linked-entity-filters.js.map +1 -0
- package/dist/tools/notifications.d.ts.map +1 -1
- package/dist/tools/notifications.js +0 -5
- package/dist/tools/notifications.js.map +1 -1
- package/dist/tools/orders.d.ts.map +1 -1
- package/dist/tools/orders.js +33 -4
- package/dist/tools/orders.js.map +1 -1
- package/dist/tools/parameter-disclosure.js.map +1 -1
- package/dist/tools/payments.d.ts +3 -0
- package/dist/tools/payments.d.ts.map +1 -1
- package/dist/tools/payments.js +43 -109
- package/dist/tools/payments.js.map +1 -1
- package/dist/tools/positionings.d.ts.map +1 -1
- package/dist/tools/positionings.js +9 -39
- package/dist/tools/positionings.js.map +1 -1
- package/dist/tools/projects.d.ts.map +1 -1
- package/dist/tools/projects.js +5 -12
- package/dist/tools/projects.js.map +1 -1
- package/dist/tools/provider-invoices.d.ts +2 -0
- package/dist/tools/provider-invoices.d.ts.map +1 -1
- package/dist/tools/provider-invoices.js +30 -111
- package/dist/tools/provider-invoices.js.map +1 -1
- package/dist/tools/purchases.d.ts +2 -0
- package/dist/tools/purchases.d.ts.map +1 -1
- package/dist/tools/purchases.js +50 -104
- package/dist/tools/purchases.js.map +1 -1
- package/dist/tools/registration-decorators.d.ts +28 -0
- package/dist/tools/registration-decorators.d.ts.map +1 -1
- package/dist/tools/registration-decorators.js +107 -0
- package/dist/tools/registration-decorators.js.map +1 -1
- package/dist/tools/resources.d.ts.map +1 -1
- package/dist/tools/resources.js +27 -5
- package/dist/tools/resources.js.map +1 -1
- package/dist/tools/rights.d.ts +101 -0
- package/dist/tools/rights.d.ts.map +1 -0
- package/dist/tools/rights.js +75 -0
- package/dist/tools/rights.js.map +1 -0
- package/dist/tools/tab-tools.js +3 -3
- package/dist/tools/tab-tools.js.map +1 -1
- package/dist/tools/timesheets.d.ts +29 -0
- package/dist/tools/timesheets.d.ts.map +1 -1
- package/dist/tools/timesheets.js +196 -126
- package/dist/tools/timesheets.js.map +1 -1
- package/dist/tools/todolists.d.ts +2 -0
- package/dist/tools/todolists.d.ts.map +1 -1
- package/dist/tools/todolists.js +64 -1
- package/dist/tools/todolists.js.map +1 -1
- package/dist/tools/validations.d.ts +9 -0
- package/dist/tools/validations.d.ts.map +1 -1
- package/dist/tools/validations.js +86 -9
- package/dist/tools/validations.js.map +1 -1
- package/dist/tools/workflows.js +1 -1
- package/dist/tools/workflows.js.map +1 -1
- package/dist/transports/http.d.ts +116 -0
- package/dist/transports/http.d.ts.map +1 -1
- package/dist/transports/http.js +422 -92
- package/dist/transports/http.js.map +1 -1
- package/dist/types.d.ts +3 -3
- package/dist/types.d.ts.map +1 -1
- package/manifest.json +7 -7
- package/package.json +14 -10
package/dist/transports/http.js
CHANGED
|
@@ -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
|
|
37
|
+
return readCsv("MCP_HTTP_ALLOWED_HOSTS");
|
|
45
38
|
}
|
|
46
39
|
function readAllowedOrigins() {
|
|
47
|
-
return
|
|
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 =
|
|
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 = (
|
|
213
|
-
const enableJsonResponse = (
|
|
214
|
-
const
|
|
215
|
-
const
|
|
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:
|
|
226
|
+
host: readString("MCP_HTTP_HOST")?.trim() ?? "127.0.0.1",
|
|
218
227
|
port,
|
|
219
|
-
path:
|
|
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
|
|
226
|
-
allowedOrigins
|
|
227
|
-
publicUrl
|
|
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
|
|
379
|
+
return { kind: "invalid" };
|
|
271
380
|
}
|
|
272
381
|
}
|
|
273
|
-
|
|
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
|
|
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
|
-
/**
|
|
354
|
-
|
|
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",
|
|
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
|
|
557
|
+
const handleRequest = async (req, res) => {
|
|
365
558
|
const corrId = generateCorrelationId();
|
|
366
|
-
|
|
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
|
-
|
|
621
|
+
reject(403, "Missing or invalid Host header");
|
|
388
622
|
return;
|
|
389
623
|
}
|
|
390
624
|
if (!allowedHosts.includes(hostname)) {
|
|
391
|
-
|
|
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
|
|
406
|
-
const origin = Array.isArray(originHeader) ? originHeader[0] : originHeader;
|
|
639
|
+
const origin = req.headers.origin;
|
|
407
640
|
if (origin && !isOriginAllowed(originPolicy, origin)) {
|
|
408
|
-
|
|
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
|
-
|
|
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
|
-
|
|
437
|
-
|
|
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
|
-
|
|
710
|
+
const identity = currentAuthIdentity();
|
|
711
|
+
if (sessionId) {
|
|
457
712
|
const entry = sessions.get(sessionId);
|
|
458
|
-
entry.
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
731
|
+
reject(400, "Missing or invalid session ID");
|
|
464
732
|
return;
|
|
465
733
|
}
|
|
466
|
-
//
|
|
467
|
-
const body =
|
|
734
|
+
// First request of a session must be `initialize`
|
|
735
|
+
const body = parsedBody;
|
|
468
736
|
if (!isInitializeRequest(body)) {
|
|
469
|
-
|
|
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
|
-
|
|
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().
|
|
518
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
|
|
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
|