Skip to content

Commit 03aaebd

Browse files
authored
Add Streamable HTTP request body limits (#3095)
1 parent 2713b53 commit 03aaebd

9 files changed

Lines changed: 280 additions & 19 deletions

File tree

docs/migration.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -622,6 +622,7 @@ Transport-specific parameters have been moved from the `MCPServer` constructor t
622622
- `sse_path`, `message_path` - SSE transport paths
623623
- `streamable_http_path` - StreamableHTTP endpoint path
624624
- `json_response`, `stateless_http` - StreamableHTTP behavior
625+
- `max_request_body_size` - StreamableHTTP request-body limit
625626
- `event_store`, `retry_interval` - StreamableHTTP event handling
626627
- `transport_security` - DNS rebinding protection
627628

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

676677
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.
677678

679+
### Streamable HTTP request bodies are limited to 4 MiB
680+
681+
V2 applies a 4 MiB default limit to Streamable HTTP POST bodies and returns HTTP 413 before parsing
682+
the JSON or creating a session when that limit is exceeded.
683+
684+
Most servers need no migration. If your application intentionally accepts larger MCP messages,
685+
set an explicit byte limit on `run()` or `streamable_http_app()`:
686+
687+
```python
688+
mcp.run(transport="streamable-http", max_request_body_size=8 * 1024 * 1024)
689+
```
690+
691+
The limit must be positive and applies to both legacy session-based requests and V2's modern
692+
single-exchange requests. Keep the smallest value your application actually needs.
693+
678694
### Streamable HTTP: lifespan now entered once at manager startup
679695

680696
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.

docs/run/index.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,9 @@ Each transport has its own keyword arguments, all on `run()`:
6767
* `streamable_http_path`: where the MCP endpoint lives. Default `/mcp`.
6868
* `json_response=True`: answer with plain JSON instead of an SSE stream.
6969
* `stateless_http=True`: a fresh transport per request, no session tracking.
70+
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
71+
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
72+
exceed that size.
7073
* `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`.
7174

7275
!!! warning

examples/stories/legacy_routing/README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ async def mcp_endpoint(scope, receive, send):
6868
case "legacy":
6969
await my_existing_v1_manager.handle_request(scope, replay, send)
7070
case "modern":
71-
await modern_manager.handle_request(scope, replay, send)
71+
await modern_manager.asgi_app(scope, replay, send)
7272
case rejection:
7373
await send_jsonrpc_error(send, rejection) # map via ERROR_CODE_HTTP_STATUS
7474
```

src/mcp/server/lowlevel/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,11 @@ async def main():
6464
from mcp.server.models import InitializationOptions
6565
from mcp.server.runner import serve_dual_era_loop
6666
from mcp.server.streamable_http import EventStore
67-
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
67+
from mcp.server.streamable_http_manager import (
68+
DEFAULT_MAX_REQUEST_BODY_SIZE,
69+
StreamableHTTPASGIApp,
70+
StreamableHTTPSessionManager,
71+
)
6872
from mcp.server.transport_security import TransportSecuritySettings
6973
from mcp.shared._stream_protocols import ReadStream, WriteStream
7074
from mcp.shared.exceptions import MCPDeprecationWarning
@@ -713,6 +717,7 @@ def streamable_http_app(
713717
stateless_http: bool = False,
714718
event_store: EventStore | None = None,
715719
retry_interval: int | None = None,
720+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
716721
transport_security: TransportSecuritySettings | None = None,
717722
host: str = "127.0.0.1",
718723
auth: AuthSettings | None = None,
@@ -737,6 +742,7 @@ def streamable_http_app(
737742
json_response=json_response,
738743
stateless=stateless_http,
739744
security_settings=transport_security,
745+
max_request_body_size=max_request_body_size,
740746
)
741747
self._session_manager = session_manager
742748

src/mcp/server/mcpserver/server.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@
8787
from mcp.server.sse import SseServerTransport
8888
from mcp.server.stdio import stdio_server
8989
from mcp.server.streamable_http import EventStore
90-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
90+
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
9191
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
9292
from mcp.server.transport_security import TransportSecuritySettings
9393
from mcp.shared.exceptions import MCPError
@@ -369,6 +369,7 @@ def run(
369369
stateless_http: bool = ...,
370370
event_store: EventStore | None = ...,
371371
retry_interval: int | None = ...,
372+
max_request_body_size: int = ...,
372373
transport_security: TransportSecuritySettings | None = ...,
373374
) -> None: ...
374375

@@ -1050,6 +1051,7 @@ async def run_streamable_http_async( # pragma: no cover
10501051
stateless_http: bool = False,
10511052
event_store: EventStore | None = None,
10521053
retry_interval: int | None = None,
1054+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
10531055
transport_security: TransportSecuritySettings | None = None,
10541056
) -> None:
10551057
"""Run the server using StreamableHTTP transport."""
@@ -1061,6 +1063,7 @@ async def run_streamable_http_async( # pragma: no cover
10611063
stateless_http=stateless_http,
10621064
event_store=event_store,
10631065
retry_interval=retry_interval,
1066+
max_request_body_size=max_request_body_size,
10641067
transport_security=transport_security,
10651068
host=host,
10661069
)
@@ -1209,6 +1212,7 @@ def streamable_http_app(
12091212
stateless_http: bool = False,
12101213
event_store: EventStore | None = None,
12111214
retry_interval: int | None = None,
1215+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
12121216
transport_security: TransportSecuritySettings | None = None,
12131217
host: str = "127.0.0.1",
12141218
) -> Starlette:
@@ -1219,6 +1223,7 @@ def streamable_http_app(
12191223
stateless_http=stateless_http,
12201224
event_store=event_store,
12211225
retry_interval=retry_interval,
1226+
max_request_body_size=max_request_body_size,
12221227
transport_security=transport_security,
12231228
host=host,
12241229
auth=self.settings.auth,

src/mcp/server/streamable_http_manager.py

Lines changed: 79 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,27 +4,25 @@
44

55
import contextlib
66
import logging
7+
from collections import deque
78
from collections.abc import AsyncIterator
8-
from typing import TYPE_CHECKING, Any
9+
from typing import TYPE_CHECKING, Any, Final
910
from uuid import uuid4
1011

1112
import anyio
1213
from anyio.abc import TaskStatus
1314
from mcp_types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
1415
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
16+
from starlette.datastructures import Headers
1517
from starlette.requests import Request
1618
from starlette.responses import Response
17-
from starlette.types import Receive, Scope, Send
19+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
1820

1921
from mcp.server._streamable_http_modern import handle_modern_request
2022
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
2123
from mcp.server.connection import Connection
2224
from mcp.server.runner import serve_connection, serve_loop
23-
from mcp.server.streamable_http import (
24-
MCP_SESSION_ID_HEADER,
25-
EventStore,
26-
StreamableHTTPServerTransport,
27-
)
25+
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport
2826
from mcp.server.transport_security import TransportSecuritySettings
2927
from mcp.shared._compat import resync_tracer
3028
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
@@ -36,6 +34,9 @@
3634

3735
logger = logging.getLogger(__name__)
3836

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

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

7477
def __init__(
@@ -80,11 +83,14 @@ def __init__(
8083
security_settings: TransportSecuritySettings | None = None,
8184
retry_interval: int | None = None,
8285
session_idle_timeout: float | None = None,
86+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
8387
):
8488
if session_idle_timeout is not None and session_idle_timeout <= 0:
8589
raise ValueError("session_idle_timeout must be a positive number of seconds")
8690
if stateless and session_idle_timeout is not None:
8791
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
92+
if max_request_body_size <= 0:
93+
raise ValueError("max_request_body_size must be a positive number of bytes")
8894

8995
self.app = app
9096
self.event_store = event_store
@@ -93,6 +99,8 @@ def __init__(
9399
self.security_settings = security_settings
94100
self.retry_interval = retry_interval
95101
self.session_idle_timeout = session_idle_timeout
102+
self.max_request_body_size = max_request_body_size
103+
self.asgi_app = RequestBodyLimitMiddleware(self._handle_request, max_request_body_size)
96104

97105
# Session tracking (only used if not stateless)
98106
self._session_creation_lock = anyio.Lock()
@@ -159,6 +167,9 @@ async def handle_request(self, scope: Scope, receive: Receive, send: Send) -> No
159167
160168
Dispatches to the appropriate handler based on stateless mode.
161169
"""
170+
await self.asgi_app(scope, receive, send)
171+
172+
async def _handle_request(self, scope: Scope, receive: Receive, send: Send) -> None:
162173
if self._task_group is None:
163174
raise RuntimeError("Task group is not initialized. Make sure to use run().")
164175

@@ -360,11 +371,71 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
360371
await response(scope, receive, send)
361372

362373

374+
class RequestBodyLimitMiddleware:
375+
"""Reject oversized HTTP request bodies before invoking an ASGI application."""
376+
377+
def __init__(self, app: ASGIApp, max_body_size: int) -> None:
378+
self.app = app
379+
self.max_body_size = max_body_size
380+
381+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
382+
if scope["type"] != "http" or scope["method"] != "POST":
383+
await self.app(scope, receive, send)
384+
return
385+
386+
headers = Headers(scope=scope)
387+
content_length = headers.get("content-length")
388+
if content_length is not None:
389+
try:
390+
declared_size = int(content_length)
391+
except ValueError:
392+
pass
393+
else:
394+
if declared_size > self.max_body_size:
395+
response = Response("Request body too large", status_code=413)
396+
return await response(scope, receive, send)
397+
398+
received_body = bytearray()
399+
received_request = False
400+
body_complete = False
401+
trailing_message: Message | None = None
402+
while True:
403+
message = await receive()
404+
if message["type"] != "http.request":
405+
trailing_message = message
406+
break
407+
408+
received_request = True
409+
body = message.get("body", b"")
410+
if len(received_body) + len(body) > self.max_body_size:
411+
response = Response("Request body too large", status_code=413)
412+
return await response(scope, receive, send)
413+
received_body.extend(body)
414+
if not message.get("more_body", False):
415+
body_complete = True
416+
break
417+
418+
cached_messages: deque[Message] = deque()
419+
if received_request:
420+
cached_messages.append(
421+
{"type": "http.request", "body": bytes(received_body), "more_body": not body_complete}
422+
)
423+
if trailing_message is not None:
424+
cached_messages.append(trailing_message)
425+
426+
async def replay() -> Message:
427+
if cached_messages:
428+
return cached_messages.popleft()
429+
return await receive()
430+
431+
await self.app(scope, replay, send)
432+
433+
363434
class StreamableHTTPASGIApp:
364435
"""ASGI application for Streamable HTTP server transport."""
365436

366437
def __init__(self, session_manager: StreamableHTTPSessionManager):
367438
self.session_manager = session_manager
368439

369440
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
370-
await self.session_manager.handle_request(scope, receive, send)
441+
await self.session_manager.asgi_app(scope, receive, send)

tests/docs_src/test_asgi.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None:
4343
"stateless_http",
4444
"event_store",
4545
"retry_interval",
46+
"max_request_body_size",
4647
"transport_security",
4748
"host",
4849
}
@@ -56,6 +57,17 @@ async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
5657
await http.post("/mcp")
5758

5859

60+
async def test_streamable_http_app_applies_the_configured_request_body_limit() -> None:
61+
"""The documented `max_request_body_size` option rejects larger requests with HTTP 413."""
62+
server = MCPServer("Notes")
63+
app = server.streamable_http_app(max_request_body_size=8)
64+
transport = httpx2.ASGITransport(app=app)
65+
async with server.session_manager.run():
66+
async with httpx2.AsyncClient(transport=transport, base_url="http://localhost") as http:
67+
response = await http.post("/mcp", content=b"123456789")
68+
assert response.status_code == 413
69+
70+
5971
async def test_mounting_at_the_root_keeps_the_default_path() -> None:
6072
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
6173
(mount,) = tutorial002.app.routes

tests/docs_src/test_legacy_clients.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ def test_streamable_http_app_has_no_era_knob() -> None:
5252
"stateless_http",
5353
"event_store",
5454
"retry_interval",
55+
"max_request_body_size",
5556
"transport_security",
5657
"host",
5758
}

0 commit comments

Comments
 (0)