Skip to content

Commit 5f0b6af

Browse files
authored
[v1.x] Add Streamable HTTP request body limits (#3101)
1 parent ba33472 commit 5f0b6af

5 files changed

Lines changed: 238 additions & 11 deletions

File tree

docs/server.md

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,6 +1253,7 @@ The FastMCP server instance accessible via `ctx.fastmcp` provides access to serv
12531253
- `host` and `port` - Server network configuration
12541254
- `mount_path`, `sse_path`, `streamable_http_path` - Transport paths
12551255
- `stateless_http` - Whether the server operates in stateless mode
1256+
- `max_request_body_size` - Maximum Streamable HTTP POST body size in bytes
12561257
- And other configuration options
12571258

12581259
```python
@@ -1417,6 +1418,14 @@ Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMC
14171418

14181419
> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability.
14191420
1421+
Streamable HTTP POST bodies are limited to 4 MiB by default. Larger requests receive HTTP 413
1422+
before parsing or session creation. If your server intentionally accepts larger MCP messages,
1423+
configure the smallest suitable byte limit:
1424+
1425+
```python
1426+
mcp = FastMCP("Large messages", max_request_body_size=8 * 1024 * 1024)
1427+
```
1428+
14201429
<!-- snippet-source examples/snippets/servers/streamable_config.py -->
14211430
```python
14221431
"""

src/mcp/server/fastmcp/server.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@
6262
from mcp.server.sse import SseServerTransport
6363
from mcp.server.stdio import stdio_server
6464
from mcp.server.streamable_http import EventStore
65-
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
65+
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
6666
from mcp.server.transport_security import TransportSecuritySettings
6767
from mcp.shared.context import LifespanContextT, RequestContext, RequestT
6868
from mcp.types import Annotations, AnyFunction, ContentBlock, GetPromptResult, Icon, ToolAnnotations
@@ -106,6 +106,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
106106
json_response: bool
107107
stateless_http: bool
108108
"""Define if the server should create a new transport per request."""
109+
max_request_body_size: int
109110

110111
# resource settings
111112
warn_on_duplicate_resources: bool
@@ -166,6 +167,7 @@ def __init__( # noqa: PLR0913
166167
streamable_http_path: str = "/mcp",
167168
json_response: bool = False,
168169
stateless_http: bool = False,
170+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
169171
warn_on_duplicate_resources: bool = True,
170172
warn_on_duplicate_tools: bool = True,
171173
warn_on_duplicate_prompts: bool = True,
@@ -193,6 +195,7 @@ def __init__( # noqa: PLR0913
193195
streamable_http_path=streamable_http_path,
194196
json_response=json_response,
195197
stateless_http=stateless_http,
198+
max_request_body_size=max_request_body_size,
196199
warn_on_duplicate_resources=warn_on_duplicate_resources,
197200
warn_on_duplicate_tools=warn_on_duplicate_tools,
198201
warn_on_duplicate_prompts=warn_on_duplicate_prompts,
@@ -960,6 +963,7 @@ def streamable_http_app(self) -> Starlette:
960963
json_response=self.settings.json_response,
961964
stateless=self.settings.stateless_http, # Use the stateless setting
962965
security_settings=self.settings.transport_security,
966+
max_request_body_size=self.settings.max_request_body_size,
963967
)
964968

965969
# Create the ASGI handler

src/mcp/server/streamable_http_manager.py

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@
44

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

1112
import anyio
1213
from anyio.abc import TaskStatus
14+
from starlette.datastructures import Headers
1315
from starlette.requests import Request
1416
from starlette.responses import Response
15-
from starlette.types import Receive, Scope, Send
17+
from starlette.types import ASGIApp, Message, Receive, Scope, Send
1618

1719
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
1820
from mcp.server.lowlevel.server import Server as MCPServer
@@ -26,6 +28,9 @@
2628

2729
logger = logging.getLogger(__name__)
2830

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

3035
class StreamableHTTPSessionManager:
3136
"""
@@ -60,6 +65,8 @@ class StreamableHTTPSessionManager:
6065
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
6166
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
6267
(30 minutes) is recommended for most deployments.
68+
max_request_body_size: Maximum size in bytes for Streamable HTTP POST request bodies. Requests that
69+
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
6370
"""
6471

6572
def __init__(
@@ -71,11 +78,14 @@ def __init__(
7178
security_settings: TransportSecuritySettings | None = None,
7279
retry_interval: int | None = None,
7380
session_idle_timeout: float | None = None,
81+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
7482
):
7583
if session_idle_timeout is not None and session_idle_timeout <= 0:
7684
raise ValueError("session_idle_timeout must be a positive number of seconds")
7785
if stateless and session_idle_timeout is not None:
7886
raise RuntimeError("session_idle_timeout is not supported in stateless mode")
87+
if max_request_body_size <= 0:
88+
raise ValueError("max_request_body_size must be a positive number of bytes")
7989

8090
self.app = app
8191
self.event_store = event_store
@@ -84,6 +94,8 @@ def __init__(
8494
self.security_settings = security_settings
8595
self.retry_interval = retry_interval
8696
self.session_idle_timeout = session_idle_timeout
97+
self.max_request_body_size = max_request_body_size
98+
self.asgi_app = RequestBodyLimitMiddleware(self._handle_request, max_request_body_size)
8799

88100
# Session tracking (only used if not stateless)
89101
self._session_creation_lock = anyio.Lock()
@@ -156,6 +168,14 @@ async def handle_request(
156168
receive: ASGI receive function
157169
send: ASGI send function
158170
"""
171+
await self.asgi_app(scope, receive, send)
172+
173+
async def _handle_request(
174+
self,
175+
scope: Scope,
176+
receive: Receive,
177+
send: Send,
178+
) -> None:
159179
if self._task_group is None:
160180
raise RuntimeError("Task group is not initialized. Make sure to use run().")
161181

@@ -341,3 +361,63 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
341361
body.model_dump_json(by_alias=True, exclude_none=True), status_code=404, media_type="application/json"
342362
)
343363
await response(scope, receive, send)
364+
365+
366+
class RequestBodyLimitMiddleware:
367+
"""Reject oversized HTTP request bodies before invoking an ASGI application."""
368+
369+
def __init__(self, app: ASGIApp, max_body_size: int) -> None:
370+
self.app = app
371+
self.max_body_size = max_body_size
372+
373+
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
374+
if scope["type"] != "http" or scope["method"] != "POST":
375+
await self.app(scope, receive, send)
376+
return
377+
378+
headers = Headers(scope=scope)
379+
content_length = headers.get("content-length")
380+
if content_length is not None:
381+
try:
382+
declared_size = int(content_length)
383+
except ValueError:
384+
pass
385+
else:
386+
if declared_size > self.max_body_size:
387+
response = Response("Request body too large", status_code=413)
388+
return await response(scope, receive, send)
389+
390+
received_body = bytearray()
391+
received_request = False
392+
body_complete = False
393+
trailing_message: Message | None = None
394+
while True:
395+
message = await receive()
396+
if message["type"] != "http.request":
397+
trailing_message = message
398+
break
399+
400+
received_request = True
401+
body = message.get("body", b"")
402+
if len(received_body) + len(body) > self.max_body_size:
403+
response = Response("Request body too large", status_code=413)
404+
return await response(scope, receive, send)
405+
received_body.extend(body)
406+
if not message.get("more_body", False):
407+
body_complete = True
408+
break
409+
410+
cached_messages: deque[Message] = deque()
411+
if received_request:
412+
cached_messages.append(
413+
{"type": "http.request", "body": bytes(received_body), "more_body": not body_complete}
414+
)
415+
if trailing_message is not None:
416+
cached_messages.append(trailing_message)
417+
418+
async def replay() -> Message:
419+
if cached_messages:
420+
return cached_messages.popleft()
421+
return await receive()
422+
423+
await self.app(scope, replay, send)

tests/server/fastmcp/test_server.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1490,3 +1490,12 @@ def test_streamable_http_no_redirect() -> None:
14901490

14911491
# Verify path values
14921492
assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp"
1493+
1494+
1495+
def test_streamable_http_app_passes_the_configured_request_body_limit_to_its_manager() -> None:
1496+
"""SDK-defined: FastMCP forwards its public request-body setting to the Streamable HTTP manager."""
1497+
mcp = FastMCP(max_request_body_size=8)
1498+
1499+
mcp.streamable_http_app()
1500+
1501+
assert mcp.session_manager.max_request_body_size == 8

0 commit comments

Comments
 (0)