Skip to content

Commit a79c0f3

Browse files
committed
[v1.x] Apply the request body limit to the SSE and OAuth endpoints
SseServerTransport now takes max_request_body_size (default 4 MiB, the same default and validation as StreamableHTTPSessionManager) and answers 413 before session lookup or parsing when a request declares or streams a larger body. The message endpoint only ever handled POST bodies, so it now answers 405 (Allow: POST) to other methods instead of treating them like a POST. FastMCP forwards its existing max_request_body_size setting to the SSE transport, so one setting governs both HTTP transports. The create_auth_routes endpoints (/token, /revoke, /register and /authorize) are wrapped in RequestBodyLimitMiddleware at the route declarations and answer 413 to bodies over the 4 MiB default before any form or JSON parsing. On the CORS-enabled routes the limit sits inside the CORS wrapper so a 413 still carries CORS headers; cors_middleware itself is unchanged. The middleware no longer special-cases POST, since some of these routes also accept OPTIONS or HEAD and their handlers read the body either way. Differences from the main change: - FastMCP reuses its existing max_request_body_size setting for the SSE app instead of adding keywords to sse_app()/run(); no new FastMCP parameter. - /register reads its body via request.json() on this line; the same wrapper applies unchanged. - Tests use httpx and this line's dict-based SSE scope helper.
1 parent 98b7159 commit a79c0f3

9 files changed

Lines changed: 260 additions & 38 deletions

File tree

docs/server.md

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1253,7 +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
1256+
- `max_request_body_size` - Maximum HTTP request body size in bytes (Streamable HTTP and SSE)
12571257
- And other configuration options
12581258

12591259
```python
@@ -1418,9 +1418,9 @@ Note that `uv run mcp run` or `uv run mcp dev` only supports server using FastMC
14181418

14191419
> **Note**: Streamable HTTP transport is the recommended transport for production deployments. Use `stateless_http=True` and `json_response=True` for optimal scalability.
14201420
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:
1421+
HTTP request bodies (Streamable HTTP and SSE) are limited to 4 MiB by default. Larger requests
1422+
receive HTTP 413 before parsing or session creation. If your server intentionally accepts larger MCP
1423+
messages, configure the smallest suitable byte limit:
14241424

14251425
```python
14261426
mcp = FastMCP("Large messages", max_request_body_size=8 * 1024 * 1024)

src/mcp/server/auth/routes.py

Lines changed: 22 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
1919
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
2020
from mcp.server.streamable_http import MCP_PROTOCOL_VERSION_HEADER
21+
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware
2122
from mcp.shared.auth import OAuthMetadata
2223

2324

@@ -53,17 +54,24 @@ def validate_issuer_url(url: AnyHttpUrl):
5354
REVOCATION_PATH = "/revoke"
5455

5556

56-
def cors_middleware(
57-
handler: Callable[[Request], Response | Awaitable[Response]],
58-
allow_methods: list[str],
59-
) -> ASGIApp:
60-
cors_app = CORSMiddleware(
61-
app=request_response(handler),
57+
def _cors(app: ASGIApp, allow_methods: list[str]) -> ASGIApp:
58+
return CORSMiddleware(
59+
app=app,
6260
allow_origins="*",
6361
allow_methods=allow_methods,
6462
allow_headers=[MCP_PROTOCOL_VERSION_HEADER],
6563
)
66-
return cors_app
64+
65+
66+
def _body_limited(app: ASGIApp) -> ASGIApp:
67+
return RequestBodyLimitMiddleware(app, DEFAULT_MAX_REQUEST_BODY_SIZE)
68+
69+
70+
def cors_middleware(
71+
handler: Callable[[Request], Response | Awaitable[Response]],
72+
allow_methods: list[str],
73+
) -> ASGIApp:
74+
return _cors(request_response(handler), allow_methods)
6775

6876

6977
def create_auth_routes(
@@ -84,11 +92,13 @@ def create_auth_routes(
8492
revocation_options,
8593
)
8694
client_authenticator = ClientAuthenticator(provider)
95+
token_handler = TokenHandler(provider, client_authenticator)
8796

8897
# Create routes
8998
# Allow CORS requests for endpoints meant to be hit by the OAuth client
9099
# (with the client secret). This is intended to support things like MCP Inspector,
91-
# where the client runs in a web browser.
100+
# where the client runs in a web browser. CORS is the outermost wrapper so that
101+
# responses produced by inner layers (such as a 413) still carry CORS headers.
92102
routes = [
93103
Route(
94104
"/.well-known/oauth-authorization-server",
@@ -102,15 +112,12 @@ def create_auth_routes(
102112
AUTHORIZATION_PATH,
103113
# do not allow CORS for authorization endpoint;
104114
# clients should just redirect to this
105-
endpoint=AuthorizationHandler(provider).handle,
115+
endpoint=_body_limited(request_response(AuthorizationHandler(provider).handle)),
106116
methods=["GET", "POST"],
107117
),
108118
Route(
109119
TOKEN_PATH,
110-
endpoint=cors_middleware(
111-
TokenHandler(provider, client_authenticator).handle,
112-
["POST", "OPTIONS"],
113-
),
120+
endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
114121
methods=["POST", "OPTIONS"],
115122
),
116123
]
@@ -123,10 +130,7 @@ def create_auth_routes(
123130
routes.append(
124131
Route(
125132
REGISTRATION_PATH,
126-
endpoint=cors_middleware(
127-
registration_handler.handle,
128-
["POST", "OPTIONS"],
129-
),
133+
endpoint=_cors(_body_limited(request_response(registration_handler.handle)), ["POST", "OPTIONS"]),
130134
methods=["POST", "OPTIONS"],
131135
)
132136
)
@@ -136,10 +140,7 @@ def create_auth_routes(
136140
routes.append(
137141
Route(
138142
REVOCATION_PATH,
139-
endpoint=cors_middleware(
140-
revocation_handler.handle,
141-
["POST", "OPTIONS"],
142-
),
143+
endpoint=_cors(_body_limited(request_response(revocation_handler.handle)), ["POST", "OPTIONS"]),
143144
methods=["POST", "OPTIONS"],
144145
)
145146
)

src/mcp/server/fastmcp/server.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ class Settings(BaseSettings, Generic[LifespanResultT]):
107107
stateless_http: bool
108108
"""Define if the server should create a new transport per request."""
109109
max_request_body_size: int
110+
"""Maximum request body size in bytes for the Streamable HTTP endpoint and the SSE message endpoint."""
110111

111112
# resource settings
112113
warn_on_duplicate_resources: bool
@@ -835,6 +836,7 @@ def sse_app(self, mount_path: str | None = None) -> Starlette:
835836
sse = SseServerTransport(
836837
normalized_message_endpoint,
837838
security_settings=self.settings.transport_security,
839+
max_request_body_size=self.settings.max_request_body_size,
838840
)
839841

840842
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover

src/mcp/server/sse.py

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@ async def handle_sse(request):
5353

5454
import mcp.types as types
5555
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
56+
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware
5657
from mcp.server.transport_security import (
5758
TransportSecurityMiddleware,
5859
TransportSecuritySettings,
@@ -81,7 +82,12 @@ class SseServerTransport:
8182
_session_owners: dict[UUID, AuthorizationContext]
8283
_security: TransportSecurityMiddleware
8384

84-
def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None:
85+
def __init__(
86+
self,
87+
endpoint: str,
88+
security_settings: TransportSecuritySettings | None = None,
89+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
90+
) -> None:
8591
"""
8692
Creates a new SSE server transport, which will direct the client to POST
8793
messages to the relative path given.
@@ -90,6 +96,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
9096
endpoint: A relative path where messages should be posted
9197
(e.g., "/messages/").
9298
security_settings: Optional security settings for DNS rebinding protection.
99+
max_request_body_size: Maximum size in bytes for POSTed message bodies. Requests that
100+
declare or stream a larger body receive HTTP 413. Defaults to 4 MiB, matching
101+
`StreamableHTTPSessionManager`.
93102
94103
Note:
95104
We use relative paths instead of full URLs for several reasons:
@@ -106,6 +115,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
106115

107116
super().__init__()
108117

118+
if max_request_body_size <= 0:
119+
raise ValueError("max_request_body_size must be a positive number of bytes")
120+
109121
# Validate that endpoint is a relative path and not a full URL
110122
if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint:
111123
raise ValueError(
@@ -121,6 +133,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
121133
self._read_stream_writers = {}
122134
self._session_owners = {}
123135
self._security = TransportSecurityMiddleware(security_settings)
136+
self._post_message_app = RequestBodyLimitMiddleware(self._handle_post_message, max_request_body_size)
124137
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
125138

126139
@asynccontextmanager
@@ -214,7 +227,18 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
214227
self._read_stream_writers.pop(session_id, None)
215228
self._session_owners.pop(session_id, None)
216229

217-
async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
230+
async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None:
231+
"""ASGI application for the message endpoint.
232+
233+
Only POST is accepted (other methods get 405), and bodies larger than
234+
`max_request_body_size` are answered with 413 before the message is handled.
235+
"""
236+
if scope["method"] != "POST":
237+
response = Response(status_code=405, headers={"Allow": "POST"})
238+
return await response(scope, receive, send)
239+
await self._post_message_app(scope, receive, send)
240+
241+
async def _handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None: # pragma: no cover
218242
logger.debug("Handling POST message")
219243
request = Request(scope, receive)
220244

src/mcp/server/streamable_http_manager.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
logger = logging.getLogger(__name__)
3030

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

3434

3535
class StreamableHTTPSessionManager:
@@ -65,7 +65,7 @@ class StreamableHTTPSessionManager:
6565
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
6666
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
6767
(30 minutes) is recommended for most deployments.
68-
max_request_body_size: Maximum size in bytes for Streamable HTTP POST request bodies. Requests that
68+
max_request_body_size: Maximum size in bytes for Streamable HTTP request bodies. Requests that
6969
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
7070
"""
7171

@@ -371,7 +371,7 @@ def __init__(self, app: ASGIApp, max_body_size: int) -> None:
371371
self.max_body_size = max_body_size
372372

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

tests/server/auth/test_error_handling.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414

1515
from mcp.server.auth.provider import AuthorizeError, RegistrationError, TokenError
1616
from mcp.server.auth.routes import create_auth_routes
17+
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE
1718

1819
# TODO(Marcelo): This TYPE_CHECKING shouldn't be here, but pytest doesn't seem to get the module correctly.
1920
if TYPE_CHECKING:
@@ -302,3 +303,59 @@ async def test_token_error_handling_refresh_token(
302303
data = refresh_response.json()
303304
assert data["error"] == "invalid_scope"
304305
assert data["error_description"] == "The requested scope is invalid"
306+
307+
308+
_FORM = "application/x-www-form-urlencoded"
309+
310+
311+
@pytest.mark.anyio
312+
@pytest.mark.parametrize(
313+
("method", "path", "content_type"),
314+
[
315+
("POST", "/token", _FORM),
316+
("POST", "/revoke", _FORM),
317+
("POST", "/register", "application/json"),
318+
("POST", "/authorize", _FORM),
319+
# The other methods these routes accept reach the same body-reading handlers.
320+
("OPTIONS", "/token", _FORM),
321+
("OPTIONS", "/revoke", _FORM),
322+
("OPTIONS", "/register", "application/json"),
323+
("HEAD", "/authorize", _FORM),
324+
],
325+
)
326+
async def test_oversized_request_body_returns_413(client: httpx.AsyncClient, method: str, path: str, content_type: str):
327+
"""Each endpoint that reads a request body rejects one over 4 MiB before parsing it, whatever the method."""
328+
response = await client.request(
329+
method, path, content=b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1), headers={"Content-Type": content_type}
330+
)
331+
assert response.status_code == 413
332+
333+
334+
@pytest.mark.anyio
335+
async def test_request_body_within_the_limit_is_still_parsed(client: httpx.AsyncClient):
336+
"""A small body is passed through to the handler intact: the form is parsed and its fields validated."""
337+
response = await client.post("/token", data={"grant_type": "authorization_code"})
338+
assert response.status_code == 401
339+
assert response.json() == {"error": "unauthorized_client", "error_description": "Missing client_id"}
340+
341+
342+
@pytest.mark.anyio
343+
async def test_cors_preflight_is_still_answered(client: httpx.AsyncClient):
344+
"""A CORS preflight to a body-limited endpoint is answered by the CORS layer as before."""
345+
response = await client.options(
346+
"/token", headers={"Origin": "https://client.example.com", "Access-Control-Request-Method": "POST"}
347+
)
348+
assert response.status_code == 200
349+
assert response.headers["access-control-allow-origin"] == "*"
350+
351+
352+
@pytest.mark.anyio
353+
async def test_oversized_cross_origin_request_gets_413_with_cors_headers(client: httpx.AsyncClient):
354+
"""The 413 is produced inside the CORS layer, so a browser client can still read it."""
355+
response = await client.post(
356+
"/token",
357+
content=b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1),
358+
headers={"Content-Type": _FORM, "Origin": "https://client.example.com"},
359+
)
360+
assert response.status_code == 413
361+
assert response.headers["access-control-allow-origin"] == "*"

tests/server/fastmcp/test_server.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
from typing import TYPE_CHECKING, Any
44
from unittest.mock import patch
55

6+
import httpx
67
import pytest
78
from pydantic import AnyUrl, BaseModel
89
from starlette.routing import Mount, Route
@@ -1499,3 +1500,17 @@ def test_streamable_http_app_passes_the_configured_request_body_limit_to_its_man
14991500
mcp.streamable_http_app()
15001501

15011502
assert mcp.session_manager.max_request_body_size == 8
1503+
1504+
1505+
@pytest.mark.anyio
1506+
async def test_sse_app_applies_the_configured_request_body_limit() -> None:
1507+
"""FastMCP forwards its request-body setting to the SSE message endpoint: larger POSTs get HTTP 413."""
1508+
mcp = FastMCP(host="0.0.0.0", max_request_body_size=8)
1509+
transport = httpx.ASGITransport(app=mcp.sse_app())
1510+
async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as http:
1511+
response = await http.post(
1512+
"/messages/?session_id=12345678123456781234567812345678",
1513+
content=b"123456789",
1514+
headers={"Content-Type": "application/json"},
1515+
)
1516+
assert response.status_code == 413

0 commit comments

Comments
 (0)