Skip to content

Cap the MCP request body behind the bearer gate - #241

Draft
raymondk wants to merge 1 commit into
mainfrom
mcp-body-limit
Draft

raymondk wants to merge 1 commit into
mainfrom
mcp-body-limit

Conversation

@raymondk

Copy link
Copy Markdown

What

The hosted /mcp route gets a cap on the request body, enforced behind the bearer gate.

Why

rmcp's streamable-HTTP service collects each request body whole before parsing it as JSON-RPC (expect_json in server_side_http.rs), and because it is a tower service rather than an axum extractor, axum's default 2 MiB extractor limit never applies to it. There was no body-limit layer on the route and Caddy sets only a read timeout, so any bearer-token holder could make the process buffer a body of arbitrary size. The staging unit runs under MemoryMax=2G.

This is independent of the deploy stack (#230–#232), but the hosted deploy work that follows will need a deploy-aware exemption from a general cap, so the general cap comes first and on its own.

How

  • src/body_limit.rs: a middleware that refuses a body over the cap with 413. A declared Content-Length over the cap is refused without reading the body; an undeclared (chunked) body is read up to the cap and no further (axum::body::to_bytes with the limit, telling LengthLimitError from a transport error). A body within the cap is handed on whole, which the transport buffers anyway.
  • Layer order on the MCP fallback: CORS, then require_token, then the cap. An unauthenticated request is still answered on its headers alone with the 401 challenge, so only an authenticated caller's body is read at all.
  • Default DEFAULT_MAX_REQUEST_BODY = 4 MiB. An IC ingress message is at most 2 MiB, so any single canister call fits even when its argument is spelled out as JSON. Embedders override with McpServer::with_max_request_body(bytes) (a setter, so McpConfig keeps its shape for the published crate's embedders); the binary reads $MCP_MAX_REQUEST_BODY and refuses to start on a value that is not a byte count.
  • Tests: the middleware alone (under, at, over the cap; declared vs undeclared length; the body is not drained past the cap), and the composed router (authenticated oversized → 413, unauthenticated oversized → 401, authenticated JSON-RPC within the cap reaches the transport). AuthStore::test_bearer is a cfg(test) helper that mints a token without an II handshake.
  • http-body-util moves from dev-dependencies to dependencies for LengthLimitError; futures-util is added as a dev-dependency for streamed test bodies. Both were already in the tree.

🤖 Generated with Claude Code

The streamable-HTTP transport reads each request body whole into memory
before parsing it as JSON-RPC, and axum's default extractor limit never
reaches it (the transport is a tower service, not an extractor), so the
hosted /mcp route had no bound on what one request could make the
process buffer. Caddy in front sets a read timeout but no size cap.

A middleware layer inside the bearer gate now refuses a body over the
cap with 413: a declared Content-Length over it is refused unread, and
an undeclared (chunked) body is read up to the cap and no further. The
gate still answers an unauthenticated request on its headers alone, so
only an authenticated caller's body is read at all. Default 4 MiB (an
IC ingress message is at most 2 MiB, so any one canister call fits even
spelled out as JSON); embedders set it with
McpServer::with_max_request_body, the binary from $MCP_MAX_REQUEST_BODY.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant