Skip to content

Commit f40e845

Browse files
committed
Apply the request body limit to the SSE message endpoint
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 POST 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. MCPServer.sse_app(), run_sse_async() and run(transport="sse") expose the keyword, mirroring streamable_http_app().
1 parent 0d92192 commit f40e845

7 files changed

Lines changed: 147 additions & 8 deletions

File tree

docs/migration.md

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -755,7 +755,7 @@ Transport-specific parameters have been moved off the `MCPServer` constructor an
755755
- `sse_path`, `message_path` - SSE transport paths, on `run(transport="sse", ...)` and `sse_app()`
756756
- `streamable_http_path` - StreamableHTTP endpoint path, on `run(transport="streamable-http", ...)` and `streamable_http_app()`
757757
- `json_response`, `stateless_http` - StreamableHTTP behavior, same two places; each also removes a server-to-client channel, see [Server-initiated sampling, elicitation, and roots raise `NoBackChannelError`](#server-initiated-sampling-elicitation-and-roots-raise-nobackchannelerror)
758-
- `max_request_body_size` - StreamableHTTP request-body limit, same two places
758+
- `max_request_body_size` - HTTP request-body limit, on `run()` for both HTTP transports and on both app methods
759759
- `event_store`, `retry_interval` - StreamableHTTP event handling, same two places
760760
- `transport_security` - DNS rebinding protection, on `run()` for both HTTP transports and on both app methods
761761

@@ -860,6 +860,11 @@ mcp.run(transport="streamable-http", max_request_body_size=8 * 1024 * 1024)
860860
The limit must be positive and applies to both legacy session-based requests and V2's modern
861861
single-exchange requests. Keep the smallest value your application actually needs.
862862

863+
The SSE transport's message endpoint applies the same limit, configured the same way
864+
(`run(transport="sse", max_request_body_size=...)`, `sse_app(...)`, or
865+
`SseServerTransport(..., max_request_body_size=...)` when you mount the transport yourself), and
866+
answers HTTP 405 to anything other than POST.
867+
863868
### Streamable HTTP: lifespan now entered once at manager startup
864869

865870
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: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ Each transport has its own keyword arguments, all on `run()`:
6969
* `stateless_http=True`: a fresh transport per request, no session tracking.
7070
* `max_request_body_size`: largest accepted POST body in bytes. Defaults to 4 MiB; larger requests
7171
receive HTTP 413 before parsing or session creation. Raise it only when legitimate MCP messages
72-
exceed that size.
72+
exceed that size. `transport="sse"` takes the same keyword for its message endpoint.
7373
* `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`.
7474

7575
!!! warning

src/mcp/server/mcpserver/server.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -365,6 +365,7 @@ def run(
365365
port: int = ...,
366366
sse_path: str = ...,
367367
message_path: str = ...,
368+
max_request_body_size: int = ...,
368369
transport_security: TransportSecuritySettings | None = ...,
369370
) -> None: ...
370371

@@ -1031,6 +1032,7 @@ async def run_sse_async( # pragma: no cover
10311032
port: int = 8000,
10321033
sse_path: str = "/sse",
10331034
message_path: str = "/messages/",
1035+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
10341036
transport_security: TransportSecuritySettings | None = None,
10351037
) -> None:
10361038
"""Run the server using SSE transport."""
@@ -1039,6 +1041,7 @@ async def run_sse_async( # pragma: no cover
10391041
starlette_app = self.sse_app(
10401042
sse_path=sse_path,
10411043
message_path=message_path,
1044+
max_request_body_size=max_request_body_size,
10421045
transport_security=transport_security,
10431046
host=host,
10441047
)
@@ -1093,6 +1096,7 @@ def sse_app(
10931096
*,
10941097
sse_path: str = "/sse",
10951098
message_path: str = "/messages/",
1099+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
10961100
transport_security: TransportSecuritySettings | None = None,
10971101
host: str = "127.0.0.1",
10981102
) -> Starlette:
@@ -1105,7 +1109,9 @@ def sse_app(
11051109
allowed_origins=["http://127.0.0.1:*", "http://localhost:*", "http://[::1]:*"],
11061110
)
11071111

1108-
sse = SseServerTransport(message_path, security_settings=transport_security)
1112+
sse = SseServerTransport(
1113+
message_path, security_settings=transport_security, max_request_body_size=max_request_body_size
1114+
)
11091115

11101116
async def handle_sse(scope: Scope, receive: Receive, send: Send): # pragma: no cover
11111117
# Add client ID from auth context into request context if available

src/mcp/server/sse.py

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ async def handle_sse(request):
5151
from starlette.types import Receive, Scope, Send
5252

5353
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
54+
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware
5455
from mcp.server.transport_security import (
5556
TransportSecurityMiddleware,
5657
TransportSecuritySettings,
@@ -79,14 +80,22 @@ class SseServerTransport:
7980
_session_owners: dict[UUID, AuthorizationContext]
8081
_security: TransportSecurityMiddleware
8182

82-
def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None:
83+
def __init__(
84+
self,
85+
endpoint: str,
86+
security_settings: TransportSecuritySettings | None = None,
87+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
88+
) -> None:
8389
"""Creates a new SSE server transport, which will direct the client to POST
8490
messages to the relative path given.
8591
8692
Args:
8793
endpoint: A relative path where messages should be posted
8894
(e.g., "/messages/").
8995
security_settings: Optional security settings for DNS rebinding protection.
96+
max_request_body_size: Maximum size in bytes for POSTed message bodies. Requests that
97+
declare or stream a larger body receive HTTP 413. Defaults to 4 MiB, matching
98+
`StreamableHTTPSessionManager`.
9099
91100
Note:
92101
We use relative paths instead of full URLs for several reasons:
@@ -103,6 +112,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
103112

104113
super().__init__()
105114

115+
if max_request_body_size <= 0:
116+
raise ValueError("max_request_body_size must be a positive number of bytes")
117+
106118
# Validate that endpoint is a relative path and not a full URL
107119
if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint:
108120
raise ValueError(
@@ -118,6 +130,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
118130
self._read_stream_writers = {}
119131
self._session_owners = {}
120132
self._security = TransportSecurityMiddleware(security_settings)
133+
self._post_message_app = RequestBodyLimitMiddleware(self._handle_post_message, max_request_body_size)
121134
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
122135

123136
@asynccontextmanager
@@ -203,6 +216,17 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
203216
self._session_owners.pop(session_id, None)
204217

205218
async def handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None:
219+
"""ASGI application for the message endpoint.
220+
221+
Only POST is accepted (other methods get 405), and bodies larger than
222+
`max_request_body_size` are answered with 413 before the message is handled.
223+
"""
224+
if scope["method"] != "POST":
225+
response = Response(status_code=405, headers={"Allow": "POST"})
226+
return await response(scope, receive, send)
227+
await self._post_message_app(scope, receive, send)
228+
229+
async def _handle_post_message(self, scope: Scope, receive: Receive, send: Send) -> None:
206230
logger.debug("Handling POST message")
207231
request = Request(scope, receive)
208232

src/mcp/server/streamable_http_manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@
3535
logger = logging.getLogger(__name__)
3636

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

4040

4141
class StreamableHTTPSessionManager:

tests/server/mcpserver/test_server.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
from unittest.mock import AsyncMock, MagicMock, patch
66

77
import anyio
8+
import httpx2
89
import pytest
910
from inline_snapshot import snapshot
1011
from mcp_types import (
@@ -1785,6 +1786,19 @@ def test_streamable_http_no_redirect() -> None:
17851786
assert streamable_routes[0].path == "/mcp", "Streamable route path should be /mcp"
17861787

17871788

1789+
async def test_sse_app_applies_the_configured_request_body_limit() -> None:
1790+
"""`sse_app(max_request_body_size=...)` rejects larger POSTs to the message endpoint with HTTP 413."""
1791+
app = MCPServer("test").sse_app(max_request_body_size=8, host="0.0.0.0")
1792+
transport = httpx2.ASGITransport(app=app)
1793+
async with httpx2.AsyncClient(transport=transport, base_url="http://localhost") as http:
1794+
response = await http.post(
1795+
"/messages/?session_id=12345678123456781234567812345678",
1796+
content=b"123456789",
1797+
headers={"Content-Type": "application/json"},
1798+
)
1799+
assert response.status_code == 413
1800+
1801+
17881802
async def test_report_progress_delegates_to_session_report_progress():
17891803
"""Context.report_progress delegates to ServerSession.report_progress unconditionally.
17901804

tests/server/test_sse_security.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
1919
from mcp.server.auth.provider import AccessToken
2020
from mcp.server.sse import SseServerTransport
21+
from mcp.server.streamable_http_manager import DEFAULT_MAX_REQUEST_BODY_SIZE
2122
from mcp.server.transport_security import TransportSecuritySettings
2223
from mcp.shared._stream_protocols import WriteStream
2324
from mcp.shared.message import SessionMessage
@@ -204,9 +205,18 @@ def _authenticated_user(client_id: str, subject: str | None = None, issuer: str
204205

205206

206207
def _sse_scope(
207-
method: str, path: str, user: AuthenticatedUser | None, *, query_string: bytes = b"", body: bytes = b""
208+
method: str,
209+
path: str,
210+
user: AuthenticatedUser | None,
211+
*,
212+
query_string: bytes = b"",
213+
body: bytes | list[bytes] = b"",
208214
) -> tuple[Scope, Receive, Send, list[Message]]:
209-
"""Build an ASGI scope/receive/send triple for a request to the SSE transport."""
215+
"""Build an ASGI scope/receive/send triple for a request to the SSE transport.
216+
217+
`body` may be a list of chunks to deliver the request body over several `http.request` messages;
218+
no Content-Length header is set either way.
219+
"""
210220
scope: Scope = {
211221
"type": "http",
212222
"method": method,
@@ -218,9 +228,11 @@ def _sse_scope(
218228
if user is not None:
219229
scope["user"] = user
220230
sent: list[Message] = []
231+
chunks = list(body) if isinstance(body, list) else [body]
221232

222233
async def receive() -> Message:
223-
return {"type": "http.request", "body": body, "more_body": False}
234+
chunk = chunks.pop(0)
235+
return {"type": "http.request", "body": chunk, "more_body": bool(chunks)}
224236

225237
async def send(message: Message) -> None:
226238
sent.append(message)
@@ -233,6 +245,10 @@ def _response_status(sent: list[Message]) -> int:
233245
return response_start["status"]
234246

235247

248+
def _response_body(sent: list[Message]) -> bytes:
249+
return b"".join(msg.get("body", b"") for msg in sent if msg["type"] == "http.response.body")
250+
251+
236252
async def _post_message(transport: SseServerTransport, session_id: str, user: AuthenticatedUser | None) -> int:
237253
"""POST a message to an SSE session as `user` and return the response status."""
238254
body = b'{"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null}'
@@ -368,6 +384,80 @@ async def test_sse_post_with_a_disallowed_host_is_rejected_before_session_lookup
368384
assert _response_status(sent) == 421
369385

370386

387+
# A well-formed session ID that no live session owns.
388+
_UNKNOWN_SESSION = b"session_id=12345678123456781234567812345678"
389+
390+
391+
@pytest.mark.anyio
392+
async def test_sse_post_body_over_the_limit_returns_413():
393+
"""A POST body larger than max_request_body_size is answered with 413 before any session handling."""
394+
transport = SseServerTransport("/messages/", max_request_body_size=8)
395+
scope, receive, send, sent = _sse_scope(
396+
"POST", "/messages/", None, query_string=_UNKNOWN_SESSION, body=b"123456789"
397+
)
398+
399+
await transport.handle_post_message(scope, receive, send)
400+
assert _response_status(sent) == 413
401+
assert _response_body(sent) == b"Request body too large"
402+
403+
404+
@pytest.mark.anyio
405+
async def test_sse_post_body_limit_defaults_to_four_mib():
406+
"""Without an explicit limit, a body one byte over 4 MiB (and no Content-Length) is answered with 413."""
407+
transport = SseServerTransport("/messages/")
408+
body = b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1)
409+
scope, receive, send, sent = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION, body=body)
410+
411+
await transport.handle_post_message(scope, receive, send)
412+
assert _response_status(sent) == 413
413+
414+
415+
@pytest.mark.anyio
416+
async def test_sse_post_streamed_body_over_the_limit_returns_413():
417+
"""The limit counts bytes across body chunks, not just a declared Content-Length."""
418+
transport = SseServerTransport("/messages/", max_request_body_size=8)
419+
scope, receive, send, sent = _sse_scope(
420+
"POST", "/messages/", None, query_string=_UNKNOWN_SESSION, body=[b"1234", b"56789"]
421+
)
422+
423+
await transport.handle_post_message(scope, receive, send)
424+
assert _response_status(sent) == 413
425+
426+
427+
@pytest.mark.anyio
428+
async def test_sse_post_within_the_limit_reaches_session_lookup():
429+
"""A body within the limit is passed on intact: an unknown session still gets its 404."""
430+
transport = SseServerTransport("/messages/", max_request_body_size=64)
431+
scope, receive, send, sent = _sse_scope(
432+
"POST", "/messages/", None, query_string=_UNKNOWN_SESSION, body=[b'{"jsonrpc": ', b'"2.0"}']
433+
)
434+
435+
await transport.handle_post_message(scope, receive, send)
436+
assert _response_status(sent) == 404
437+
assert _response_body(sent) == b"Could not find session"
438+
439+
440+
@pytest.mark.anyio
441+
@pytest.mark.parametrize("method", ["GET", "PUT"])
442+
async def test_sse_message_endpoint_answers_405_to_non_post(method: str):
443+
"""The message endpoint only accepts POST; other methods get 405 with an Allow header."""
444+
transport = SseServerTransport("/messages/")
445+
scope, receive, send, sent = _sse_scope(method, "/messages/", None, query_string=_UNKNOWN_SESSION, body=b"{}")
446+
447+
await transport.handle_post_message(scope, receive, send)
448+
assert _response_status(sent) == 405
449+
response_start = next(msg for msg in sent if msg["type"] == "http.response.start")
450+
assert (b"allow", b"POST") in response_start["headers"]
451+
452+
453+
@pytest.mark.parametrize("max_request_body_size", [0, -1])
454+
def test_sse_transport_rejects_a_non_positive_body_limit(max_request_body_size: int):
455+
"""The body limit must be a positive number of bytes, matching StreamableHTTPSessionManager."""
456+
with pytest.raises(ValueError) as exc_info:
457+
SseServerTransport("/messages/", max_request_body_size=max_request_body_size)
458+
assert str(exc_info.value) == "max_request_body_size must be a positive number of bytes"
459+
460+
371461
@pytest.mark.anyio
372462
async def test_sse_round_trip_delivers_posted_messages_and_streams_responses():
373463
"""A POSTed JSON-RPC message reaches the server's read stream, and a message

0 commit comments

Comments
 (0)