--- id: review-001-mcp-body-limit name: Cap the /mcp nest body size (SRV-03, gated on mcp feature) status: completed depends_on: [] scope: narrow risk: low impact: component level: implementation tags: [server, review-001, security, mcp] --- ## Description Review 001 finding SRV-03 (mcp feature only): `src/server/adapter.rs:141-150` nests rmcp's `StreamableHttpService`; the bearer middleware only stashes identity. axum's 2 MiB `DefaultBodyLimit` applies to axum *extractors*, but the nested rmcp service collects the raw body itself (`body.collect().await`, verified against rmcp 1.8.0) with no cap — a single multi-GB chunked `POST /mcp` is buffered entirely in memory; a few concurrent requests OOM the process. Gateway routes are correctly capped at 2 MiB by the extractors; `/mcp` is the one uncapped surface. Fix: wrap the `/mcp` nest with an explicit `DefaultBodyLimit` (or an equivalent body-limit layer) sized for MCP traffic. Pick and document the limit (a JSON-RPC batch is the largest legitimate body; something in the 2–8 MiB range is defensible) rather than leaving it at hyper's unlimited default. ## Acceptance Criteria - [x] Explicit body limit applied to the `/mcp` nest; limit value documented in code and ADR-039 or http-server.md if touched - [x] Test: oversized `POST /mcp` body → 413 (Content-Length and streaming/chunked variants) - [x] Normal-size MCP initialize + tools/call round-trip still passes - [x] `cargo test --all-features` passes (feature-gated code) ## References - docs/reviews/001-initial-implementation-review.md (Part A, SRV-03) - docs/architecture/decisions/039-http-server-and-client-host-colocated.md ## Notes `DefaultBodyLimit` was rejected on source evidence, not guesswork: its `Layer` implementation (axum-core 0.5.6 `extract/default_body_limit.rs`) only inserts an extension into the request — the limit is enforced inside `FromRequest` extractors. rmcp's `StreamableHttpService` consumes the raw body via `server_side_http::expect_json` → `body.collect()` and never checks that extension, so the layer cannot intercept a raw-body-collecting nested service. A bare `http-body-util::Limited` wrapper was also insufficient: its body-read error surfaces through rmcp as `500` (rmcp maps `expect_json` body errors to `INTERNAL_SERVER_ERROR`), not `413`. The implemented fix is a small `from_fn` middleware (`mcp_body_limit`, feature-gated in `src/server/adapter.rs`) layered directly on the `/mcp` nest, inner to the bearer-auth layer: 1. A `Content-Length`-declared body over the cap is rejected with `413` before any body bytes are read. 2. Otherwise the body is wrapped in a counting stream (`CountingBody`, 8 MiB budget) that flags exceedance in a request extension and errors on the first chunk that crosses the cap; the middleware post-checks the flag and answers `413` regardless of what the inner service produced. The cut-off also bounds buffering — rmcp never sees a body larger than the cap plus one chunk. Limit choice: **8 MiB** (`MCP_BODY_LIMIT`), headroom over the gateway's 2 MiB whole-body default for legitimate JSON-RPC batch payloads on the MCP surface; documented on the constant. http-server.md §"What" lists the `/mcp` surface but does not document per-surface body limits anywhere (the gateway's 2 MiB is extractor behavior, not prose), so no doc addition was needed; the constant's doc comment is the normative statement. ## Summary Capped the `/mcp` nest (SRV-03): an 8 MiB `mcp_body_limit` middleware now wraps the rmcp `StreamableHttpService` nest in `build_router` (feature `mcp`), inner to the bearer-auth layer, leaving auth ordering and the reserved-paths probe untouched. `DefaultBodyLimit` does not apply (extension-only mechanism; rmcp collects the raw body), so the middleware both caps and status-sources: declared oversizes are rejected before reading, streaming oversizes are cut mid-read and answered `413`. Tests: `mcp_rejects_oversized_body_declared_content_length_with_413`, `mcp_rejects_oversized_chunked_body_with_413` (raw chunked HTTP/1.1 over the duplex socket, concurrent write/read split), and the normal-size `mcp_endpoint_serves_four_gateway_tools_bearer_gated` round-trip stays green. Verified: `cargo test` (219), `cargo test --features mcp --lib` (257), `cargo test --all-features` (269 + integration suites), clippy default and `--all-features` `-D warnings`, `cargo fmt --check`.