Summary
POST /api/mcp fully buffers and JSON-parses the request body before any authentication happens, and does so with no byte-size cap. An unauthenticated client can force the Worker to parse arbitrarily large JSON payloads (Cloudflare accepts request bodies up to ~100 MB; a Worker's memory limit is 128 MB) before the request is rejected with 401 — a cheap CPU/memory exhaustion vector on a public endpoint.
Evidence
src/lib/api/routes/mcp-request-handler.ts:49 calls extractMcpToolCallNamesFromRequest(request) before authenticateMcpRequest(request, toolNames) at line 52.
src/lib/mcp/tool-scopes.ts:217-240 — the extractor does await request.clone().json() with no size guard, purely to learn tool names for the scope check.
- The observability pass parses the body again after auth (
src/lib/mcp/observability-summary.ts:29-39) — that one at least checks content-length against a 64 KB budget, but it runs post-auth and the header can be absent/under-reported with chunked encoding.
- Contrast with the GraphQL endpoint, which enforces a streaming 64 KB body cap pre-execution:
readBodyWithinLimit in src/lib/graphql/server.ts:109-154 with GRAPHQL_LIMITS.bodyBytes = 64 * 1024 (src/lib/graphql/constants.ts:5).
- REST mutation routes are not affected the same way because they call
requireAuth before parseRouteJsonBody (e.g. src/lib/api/routes/upload-session-routes.ts:24-33), so unauthenticated oversized bodies are rejected before parsing.
Why the order is this way
The scope gate needs the tool names from the body (authenticateMcpRequest(request, toolNames) checks per-tool scopes). But token authentication (JWT verification) does not depend on the body at all.
Suggested fix
Two-phase handling in mcp-request-handler.ts:
- Authenticate the bearer token first (no scope evaluation).
- Parse the body under a hard cap — check
content-length and/or stream with a byte budget like GraphQL's readBodyWithinLimit, rejecting with 413.
- Only then evaluate per-tool scopes (and the existing write rate limit) using the parsed tool names.
This keeps the current scope semantics while removing the pre-auth unbounded parse.
Summary
POST /api/mcpfully buffers and JSON-parses the request body before any authentication happens, and does so with no byte-size cap. An unauthenticated client can force the Worker to parse arbitrarily large JSON payloads (Cloudflare accepts request bodies up to ~100 MB; a Worker's memory limit is 128 MB) before the request is rejected with 401 — a cheap CPU/memory exhaustion vector on a public endpoint.Evidence
src/lib/api/routes/mcp-request-handler.ts:49callsextractMcpToolCallNamesFromRequest(request)beforeauthenticateMcpRequest(request, toolNames)at line 52.src/lib/mcp/tool-scopes.ts:217-240— the extractor doesawait request.clone().json()with no size guard, purely to learn tool names for the scope check.src/lib/mcp/observability-summary.ts:29-39) — that one at least checkscontent-lengthagainst a 64 KB budget, but it runs post-auth and the header can be absent/under-reported with chunked encoding.readBodyWithinLimitinsrc/lib/graphql/server.ts:109-154withGRAPHQL_LIMITS.bodyBytes = 64 * 1024(src/lib/graphql/constants.ts:5).requireAuthbeforeparseRouteJsonBody(e.g.src/lib/api/routes/upload-session-routes.ts:24-33), so unauthenticated oversized bodies are rejected before parsing.Why the order is this way
The scope gate needs the tool names from the body (
authenticateMcpRequest(request, toolNames)checks per-tool scopes). But token authentication (JWT verification) does not depend on the body at all.Suggested fix
Two-phase handling in
mcp-request-handler.ts:content-lengthand/or stream with a byte budget like GraphQL'sreadBodyWithinLimit, rejecting with 413.This keeps the current scope semantics while removing the pre-auth unbounded parse.