Uh oh!
There was an error while loading. Please reload this page.
Add Streamable HTTP request body limits - #3095
Conversation
📚 Documentation preview
|
There was a problem hiding this comment.
Beyond the inline findings, two other candidates were examined and ruled out this run: (1) the middleware buffering the full POST body before the Origin/Host/session checks in handle_request run — the buffer is bounded by max_request_body_size, so it doesn't add unbounded exposure ahead of those checks; (2) the plain-text 413 body being inconsistent with the manager's JSON-RPC error responses — a formatting nit, not a correctness issue.
Extended reasoning...
Two bugs were confirmed and posted as inline comments (the public handle_request bypass of the limit, and per-message dict caching allowing memory amplification via tiny chunks), so this PR needs human review and I am not approving. This note only records the additional candidates that finder agents raised and verifiers refuted, so a later pass or the author doesn't re-explore them from scratch. It is informational, not a guarantee of correctness elsewhere in the diff.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Both findings from my previous review are verified fixed in the current revision: handle_request now delegates through asgi_app, and the middleware coalesces chunks into a single bytearray replayed as one message. Beyond the inline pre-existing SSE note, this run also examined and ruled out: buffering up to the limit before the transport's 421/403/404 fail-fast checks (bounded at 4 MiB, so not an amplification); zero-length more_body=True chunks spinning the read loop (memory stays bounded by the coalesced buffer; connection lifetime is the server's slow-request concern); and body buffering happening before DNS-rebinding validation (same bounded-cost reasoning).
Extended reasoning...
This run produced one inline finding, explicitly pre-existing and non-blocking (the legacy SSE POST endpoint still buffers bodies unbounded — untouched by this PR, and the PR's docs correctly scope the new limit to Streamable HTTP). The previous run's two blocking findings were both fixed by commit 54b0cc4, which I verified against the current diff. The remaining candidate issues raised by finders this run were all refuted on bounded-cost grounds: the middleware never retains more than max_request_body_size bytes plus a small constant regardless of chunking, ordering relative to the transport's security checks only shifts a bounded buffer, and the zero-length-chunk loop cannot grow memory. Since real (fixed-now) issues were found on earlier revisions and this adds new security-relevant middleware to every Streamable HTTP request path, a human should still make the final call; this note just records what has already been examined.
| class RequestBodyLimitMiddleware: | ||
| """Reject oversized HTTP request bodies before invoking an ASGI application.""" |
There was a problem hiding this comment.
🟣 Pre-existing issue, not introduced by this PR: the legacy SSE transport's POST endpoint (SseServerTransport.handle_post_message, src/mcp/server/sse.py:243) still buffers the entire request body unbounded via await request.body() — the same DoS vector this PR closes for Streamable HTTP. Since the new RequestBodyLimitMiddleware is a generic ASGI wrapper, applying it to the SSE message endpoint would be a natural one-line follow-up (or the docs could note explicitly that the limit covers Streamable HTTP only).
Extended reasoning...
What the issue is
This PR adds max_request_body_size enforcement (default 4 MiB, HTTP 413) for the Streamable HTTP transport via the new RequestBodyLimitMiddleware. The legacy SSE transport has the exact same unbounded-buffering exposure, and it remains open: SseServerTransport.handle_post_message (src/mcp/server/sse.py:205) reads the full POST body with body = await request.body() (src/mcp/server/sse.py:243) before any JSON validation, with no size check of any kind.
The code path
The SSE transport is still shipped and mountable — mcp.run(transport="sse") and sse_app() both wire sse.handle_post_message onto the message endpoint (see the Mount(message_path, app=sse.handle_post_message) routes in src/mcp/server/mcpserver/server.py). A request to that endpoint passes only header-level checks (transport security, session_id lookup) before line 243 buffers the entire body into memory. Starlette's Request.body() accumulates every chunk until the stream ends, so nothing bounds it.
Step-by-step proof
- Start any server with
mcp.run(transport="sse")(or mountsse_app()). - Open a legitimate SSE connection via
GET /sseto obtain a validsession_id(or skip this — the body is read even for requests that will fail JSON validation, as long as the session check passes; with a valid session it definitely proceeds to line 243). POST /messages/?session_id=<id>with an arbitrarily large body (e.g. a multi-GB chunked upload).await request.body()at line 243 buffers the whole thing in server memory beforetypes.JSONRPCMessage.model_validate_json(body)ever runs. Concurrent requests multiply the footprint. This is precisely the memory-exhaustion vector the PR's own migration-guide entry describes closing for Streamable HTTP.
Why this is pre-existing and non-blocking
The PR does not touch sse.py, and both documentation additions correctly scope the new limit: docs/migration.md says "Streamable HTTP request bodies are limited to 4 MiB" and the docs/run/index.md bullet sits under the Streamable HTTP section. So nothing in this PR is wrong or misleading — the gap predates it and is untouched by it. (For completeness: both earlier claude[bot] findings on this PR — handle_request bypassing the limit, and per-message dict memory amplification — are already fixed in the current revision: handle_request now delegates to asgi_app, and chunks coalesce into a single bytearray replayed as one message.)
How to fix (follow-up)
RequestBodyLimitMiddleware is deliberately transport-agnostic — it wraps any ASGI app and only inspects POST requests. Wrapping the SSE message endpoint, e.g. Mount(message_path, app=RequestBodyLimitMiddleware(sse.handle_post_message, max_request_body_size)) in sse_app(), would close the gap with essentially one line. Alternatively (or additionally), a one-line doc note stating that the 4 MiB limit applies to Streamable HTTP only would make the scope explicit for operators still serving SSE clients. Given SSE is legacy/discouraged (docs/run/index.md answers "When" with "You don't"), a follow-up issue is the right vehicle — this should not block the PR.
Summary
max_request_body_sizeoption for Streamable HTTP serversValidation
./scripts/testuv run --frozen pre-commit run --all-files