Add tests for the /mcp body cap and complete the task file. - oversized POST /mcp with declared Content-Length > 8 MiB -> 413 before any body read - oversized chunked POST /mcp -> 413 (counting-stream cut mid-body; rmcp maps body errors to 500, so the middleware sources the status) - normal-size initialize round-trip unchanged - task file: status completed, Summary filled Verified: cargo test (219), cargo test --features mcp --lib (257), cargo test --all-features (269 + integration), clippy default and --all-features (-D warnings), cargo fmt --check.
4.3 KiB
id, name, status, depends_on, scope, risk, impact, level, tags
| id | name | status | depends_on | scope | risk | impact | level | tags | ||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|
| review-001-mcp-body-limit | Cap the /mcp nest body size (SRV-03, gated on mcp feature) | completed | narrow | low | component | implementation |
|
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
- Explicit body limit applied to the
/mcpnest; limit value documented in code and ADR-039 or http-server.md if touched - Test: oversized
POST /mcpbody → 413 (Content-Length and streaming/chunked variants) - Normal-size MCP initialize + tools/call round-trip still passes
cargo test --all-featurespasses (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:
- A
Content-Length-declared body over the cap is rejected with413before any body bytes are read. - 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 answers413regardless 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.