Files
alkhttp/tasks/server/review-001-mcp-body-limit.md
T
glm-5.3-flash a4df859771 fix(server): cap /mcp body size (SRV-03)
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.
2026-08-29 09:42:42 +00:00

92 lines
4.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
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
28 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`.