Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions docs/migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -622,6 +622,7 @@ Transport-specific parameters have been moved from the `MCPServer` constructor t
- `sse_path`, `message_path` - SSE transport paths
- `streamable_http_path` - StreamableHTTP endpoint path
- `json_response`, `stateless_http` - StreamableHTTP behavior
- `max_request_body_size` - StreamableHTTP request-body limit
- `event_store`, `retry_interval` - StreamableHTTP event handling
- `transport_security` - DNS rebinding protection

Expand Down Expand Up @@ -675,6 +676,21 @@ app = Starlette(routes=[Mount("/", app=mcp.streamable_http_app(json_response=Tru

If you were mutating these via `mcp.settings` after construction (e.g., `mcp.settings.port = 9000`), pass them to `run()` / `sse_app()` / `streamable_http_app()` instead — these fields no longer exist on `Settings`. The `debug` and `log_level` parameters remain on the constructor.

### Streamable HTTP request bodies are limited to 4 MiB

V2 applies a 4 MiB default limit to Streamable HTTP POST bodies and returns HTTP 413 before parsing
the JSON or creating a session when that limit is exceeded.

Most servers need no migration. If your application intentionally accepts larger MCP messages,
set an explicit byte limit on `run()` or `streamable_http_app()`:

```python
mcp.run(transport="streamable-http", max_request_body_size=8 * 1024 * 1024)
```

The limit must be positive and applies to both legacy session-based requests and V2's modern
single-exchange requests. Keep the smallest value your application actually needs.

### Streamable HTTP: lifespan now entered once at manager startup

When serving streamable HTTP (stateful or `stateless_http=True`), the server's `lifespan` context manager is now entered once when `StreamableHTTPSessionManager.run()` starts, and the resulting state is shared across all sessions and requests. Previously each session (stateful) or each request (stateless) entered and exited `lifespan` independently.
Expand Down
3 changes: 3 additions & 0 deletions docs/run/index.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,9 @@ Each transport has its own keyword arguments, all on `run()`:
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
* `json_response=True`: answer with plain JSON instead of an SSE stream.
* `stateless_http=True`: a fresh transport per request, no session tracking.
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
exceed that size.
* `event_store`, `retry_interval`, `transport_security`: resumability and DNS-rebinding protection. They can wait, until you deploy somewhere other than localhost; **[Deploy & scale](deploy.md)** covers `transport_security`.

!!! warning
Expand Down
2 changes: 1 addition & 1 deletion examples/stories/legacy_routing/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,7 @@ async def mcp_endpoint(scope, receive, send):
case "legacy":
await my_existing_v1_manager.handle_request(scope, replay, send)
case "modern":
await modern_manager.handle_request(scope, replay, send)
await modern_manager.asgi_app(scope, replay, send)
case rejection:
await send_jsonrpc_error(send, rejection) # map via ERROR_CODE_HTTP_STATUS
```
Expand Down
8 changes: 7 additions & 1 deletion src/mcp/server/lowlevel/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,11 @@ async def main():
from mcp.server.models import InitializationOptions
from mcp.server.runner import serve_dual_era_loop
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
from mcp.server.streamable_http_manager import (
DEFAULT_MAX_REQUEST_BODY_SIZE,
StreamableHTTPASGIApp,
StreamableHTTPSessionManager,
)
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._stream_protocols import ReadStream, WriteStream
from mcp.shared.exceptions import MCPDeprecationWarning
Expand Down Expand Up @@ -713,6 +717,7 @@ def streamable_http_app(
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
auth: AuthSettings | None = None,
Expand All @@ -737,6 +742,7 @@ def streamable_http_app(
json_response=json_response,
stateless=stateless_http,
security_settings=transport_security,
max_request_body_size=max_request_body_size,
)
self._session_manager = session_manager

Expand Down
7 changes: 6 additions & 1 deletion src/mcp/server/mcpserver/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@
from mcp.server.sse import SseServerTransport
from mcp.server.stdio import stdio_server
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.exceptions import MCPError
Expand Down Expand Up @@ -369,6 +369,7 @@ def run(
stateless_http: bool = ...,
event_store: EventStore | None = ...,
retry_interval: int | None = ...,
max_request_body_size: int = ...,
transport_security: TransportSecuritySettings | None = ...,
) -> None: ...

Expand Down Expand Up @@ -1050,6 +1051,7 @@ async def run_streamable_http_async( # pragma: no cover
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
transport_security: TransportSecuritySettings | None = None,
) -> None:
"""Run the server using StreamableHTTP transport."""
Expand All @@ -1061,6 +1063,7 @@ async def run_streamable_http_async( # pragma: no cover
stateless_http=stateless_http,
event_store=event_store,
retry_interval=retry_interval,
max_request_body_size=max_request_body_size,
transport_security=transport_security,
host=host,
)
Expand Down Expand Up @@ -1209,6 +1212,7 @@ def streamable_http_app(
stateless_http: bool = False,
event_store: EventStore | None = None,
retry_interval: int | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
transport_security: TransportSecuritySettings | None = None,
host: str = "127.0.0.1",
) -> Starlette:
Expand All @@ -1219,6 +1223,7 @@ def streamable_http_app(
stateless_http=stateless_http,
event_store=event_store,
retry_interval=retry_interval,
max_request_body_size=max_request_body_size,
transport_security=transport_security,
host=host,
auth=self.settings.auth,
Expand Down
87 changes: 79 additions & 8 deletions src/mcp/server/streamable_http_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,27 +4,25 @@

import contextlib
import logging
from collections import deque
from collections.abc import AsyncIterator
from typing import TYPE_CHECKING, Any
from typing import TYPE_CHECKING, Any, Final
from uuid import uuid4

import anyio
from anyio.abc import TaskStatus
from mcp_types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
from starlette.datastructures import Headers
from starlette.requests import Request
from starlette.responses import Response
from starlette.types import Receive, Scope, Send
from starlette.types import ASGIApp, Message, Receive, Scope, Send

from mcp.server._streamable_http_modern import handle_modern_request
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
from mcp.server.connection import Connection
from mcp.server.runner import serve_connection, serve_loop
from mcp.server.streamable_http import (
MCP_SESSION_ID_HEADER,
EventStore,
StreamableHTTPServerTransport,
)
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared._compat import resync_tracer
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
Expand All @@ -36,6 +34,9 @@

logger = logging.getLogger(__name__)

DEFAULT_MAX_REQUEST_BODY_SIZE: Final = 4 * 1024 * 1024
"""Default maximum Streamable HTTP request body size in bytes (4 MiB)."""


class StreamableHTTPSessionManager:
"""Manages StreamableHTTP sessions with optional resumability via event store.
Expand Down Expand Up @@ -69,6 +70,8 @@
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
(30 minutes) is recommended for most deployments.
max_request_body_size: Maximum size in bytes for Streamable HTTP POST request bodies. Requests that
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
"""

def __init__(
Expand All @@ -80,11 +83,14 @@
security_settings: TransportSecuritySettings | None = None,
retry_interval: int | None = None,
session_idle_timeout: float | None = None,
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
):
if session_idle_timeout is not None and session_idle_timeout <= 0:
raise ValueError("session_idle_timeout must be a positive number of seconds")
if stateless and session_idle_timeout is not None:
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
if max_request_body_size <= 0:
raise ValueError("max_request_body_size must be a positive number of bytes")

self.app = app
self.event_store = event_store
Expand All @@ -93,6 +99,8 @@
self.security_settings = security_settings
self.retry_interval = retry_interval
self.session_idle_timeout = session_idle_timeout
self.max_request_body_size = max_request_body_size
self.asgi_app = RequestBodyLimitMiddleware(self._handle_request, max_request_body_size)

# Session tracking (only used if not stateless)
self._session_creation_lock = anyio.Lock()
Comment thread
claude[bot] marked this conversation as resolved.
Expand Down Expand Up @@ -159,6 +167,9 @@

Dispatches to the appropriate handler based on stateless mode.
"""
await self.asgi_app(scope, receive, send)

async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
if self._task_group is None:
raise RuntimeError("Task group is not initialized. Make sure to use run().")

Expand Down Expand Up @@ -360,11 +371,71 @@
await response(scope, receive, send)


class RequestBodyLimitMiddleware:
"""Reject oversized HTTP request bodies before invoking an ASGI application."""

Check notice on line 375 in src/mcp/server/streamable_http_manager.py

View check run for this annotation

Claude / Claude Code Review

SSE transport POST endpoint still has no request-body size limit (pre-existing)

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 St
Comment on lines +374 to +375

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟣 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

  1. Start any server with mcp.run(transport="sse") (or mount sse_app()).
  2. Open a legitimate SSE connection via GET /sse to obtain a valid session_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).
  3. POST /messages/?session_id=<id> with an arbitrarily large body (e.g. a multi-GB chunked upload).
  4. await request.body() at line 243 buffers the whole thing in server memory before types.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.


def __init__(self, app: ASGIApp, max_body_size: int) -> None:
self.app = app
self.max_body_size = max_body_size

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
if scope["type"] != "http" or scope["method"] != "POST":
await self.app(scope, receive, send)
return

headers = Headers(scope=scope)
content_length = headers.get("content-length")
if content_length is not None:
try:
declared_size = int(content_length)
except ValueError:
pass
else:
if declared_size > self.max_body_size:
response = Response("Request body too large", status_code=413)
return await response(scope, receive, send)

received_body = bytearray()
received_request = False
body_complete = False
trailing_message: Message | None = None
while True:
message = await receive()
if message["type"] != "http.request":
trailing_message = message
break

received_request = True
body = message.get("body", b"")
if len(received_body) + len(body) > self.max_body_size:
response = Response("Request body too large", status_code=413)
return await response(scope, receive, send)
received_body.extend(body)
if not message.get("more_body", False):
body_complete = True
break

cached_messages: deque[Message] = deque()
if received_request:
cached_messages.append(
{"type": "http.request", "body": bytes(received_body), "more_body": not body_complete}
)
if trailing_message is not None:
cached_messages.append(trailing_message)

async def replay() -> Message:
if cached_messages:
return cached_messages.popleft()
return await receive()

await self.app(scope, replay, send)


class StreamableHTTPASGIApp:
"""ASGI application for Streamable HTTP server transport."""

def __init__(self, session_manager: StreamableHTTPSessionManager):
self.session_manager = session_manager

async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
await self.session_manager.handle_request(scope, receive, send)
await self.session_manager.asgi_app(scope, receive, send)
12 changes: 12 additions & 0 deletions tests/docs_src/test_asgi.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None:
"stateless_http",
"event_store",
"retry_interval",
"max_request_body_size",
"transport_security",
"host",
}
Expand All @@ -56,6 +57,17 @@ async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
await http.post("/mcp")


async def test_streamable_http_app_applies_the_configured_request_body_limit() -> None:
"""The documented `max_request_body_size` option rejects larger requests with HTTP 413."""
server = MCPServer("Notes")
app = server.streamable_http_app(max_request_body_size=8)
transport = httpx2.ASGITransport(app=app)
async with server.session_manager.run():
async with httpx2.AsyncClient(transport=transport, base_url="http://localhost") as http:
response = await http.post("/mcp", content=b"123456789")
assert response.status_code == 413


async def test_mounting_at_the_root_keeps_the_default_path() -> None:
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
(mount,) = tutorial002.app.routes
Expand Down
1 change: 1 addition & 0 deletions tests/docs_src/test_legacy_clients.py
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ def test_streamable_http_app_has_no_era_knob() -> None:
"stateless_http",
"event_store",
"retry_interval",
"max_request_body_size",
"transport_security",
"host",
}
Expand Down
Loading
Loading