Skip to content

Commit 57394b0

Browse files
authored
Apply the request body limit to the SSE and OAuth endpoints (#3336)
1 parent 0cee624 commit 57394b0

13 files changed

Lines changed: 420 additions & 189 deletions

File tree

docs/migration.md

Lines changed: 1 addition & 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

docs/run/index.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -67,7 +67,7 @@ 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 each POST with a single JSON body instead of an SSE stream. That body has room for the response and nothing else, so a tool that calls back into the client mid-request (`ctx.elicit()`, sampling) raises `NoBackChannelError` on this leg, and notifications tied to the in-flight call (progress from `ctx.report_progress()`, per-call log messages) are dropped; the standalone `GET` stream still carries unrelated ones.
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
70+
* `max_request_body_size`: largest accepted request 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
7272
exceed that size.
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`.

src/mcp/server/auth/routes.py

Lines changed: 22 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
from mcp.server.auth.middleware.client_auth import ClientAuthenticator
1818
from mcp.server.auth.provider import OAuthAuthorizationServerProvider
1919
from mcp.server.auth.settings import ClientRegistrationOptions, RevocationOptions
20+
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, RequestBodyLimitMiddleware
2021
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthMetadata, ProtectedResourceMetadata
2122
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
2223

@@ -51,17 +52,24 @@ def validate_issuer_url(url: AnyHttpUrl):
5152
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
5253

5354

54-
def cors_middleware(
55-
handler: Callable[[Request], Response | Awaitable[Response]],
56-
allow_methods: list[str],
57-
) -> ASGIApp:
58-
cors_app = CORSMiddleware(
59-
app=request_response(handler),
55+
def _cors(app: ASGIApp, allow_methods: list[str]) -> ASGIApp:
56+
return CORSMiddleware(
57+
app=app,
6058
allow_origins="*",
6159
allow_methods=allow_methods,
6260
allow_headers=[MCP_PROTOCOL_VERSION_HEADER],
6361
)
64-
return cors_app
62+
63+
64+
def _body_limited(app: ASGIApp) -> ASGIApp:
65+
return RequestBodyLimitMiddleware(app, DEFAULT_MAX_REQUEST_BODY_SIZE)
66+
67+
68+
def cors_middleware(
69+
handler: Callable[[Request], Response | Awaitable[Response]],
70+
allow_methods: list[str],
71+
) -> ASGIApp:
72+
return _cors(request_response(handler), allow_methods)
6573

6674

6775
def create_auth_routes(
@@ -84,11 +92,13 @@ def create_auth_routes(
8492
supports_identity_assertion=identity_assertion_enabled,
8593
)
8694
client_authenticator = ClientAuthenticator(provider)
95+
token_handler = TokenHandler(provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled)
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,17 +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(
112-
provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled
113-
).handle,
114-
["POST", "OPTIONS"],
115-
),
120+
endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
116121
methods=["POST", "OPTIONS"],
117122
),
118123
]
@@ -125,10 +130,7 @@ def create_auth_routes(
125130
routes.append(
126131
Route(
127132
REGISTRATION_PATH,
128-
endpoint=cors_middleware(
129-
registration_handler.handle,
130-
["POST", "OPTIONS"],
131-
),
133+
endpoint=_cors(_body_limited(request_response(registration_handler.handle)), ["POST", "OPTIONS"]),
132134
methods=["POST", "OPTIONS"],
133135
)
134136
)
@@ -138,10 +140,7 @@ def create_auth_routes(
138140
routes.append(
139141
Route(
140142
REVOCATION_PATH,
141-
endpoint=cors_middleware(
142-
revocation_handler.handle,
143-
["POST", "OPTIONS"],
144-
),
143+
endpoint=_cors(_body_limited(request_response(revocation_handler.handle)), ["POST", "OPTIONS"]),
145144
methods=["POST", "OPTIONS"],
146145
)
147146
)

src/mcp/server/lowlevel/server.py

Lines changed: 2 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -65,12 +65,8 @@ async def main():
6565
from mcp.server.models import InitializationOptions
6666
from mcp.server.runner import serve_dual_era_loop
6767
from mcp.server.streamable_http import EventStore
68-
from mcp.server.streamable_http_manager import (
69-
DEFAULT_MAX_REQUEST_BODY_SIZE,
70-
StreamableHTTPASGIApp,
71-
StreamableHTTPSessionManager,
72-
)
73-
from mcp.server.transport_security import TransportSecuritySettings
68+
from mcp.server.streamable_http_manager import StreamableHTTPASGIApp, StreamableHTTPSessionManager
69+
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
7470
from mcp.shared._stream_protocols import ReadStream, WriteStream
7571
from mcp.shared.exceptions import MCPDeprecationWarning
7672
from mcp.shared.message import SessionMessage

src/mcp/server/mcpserver/server.py

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -87,9 +87,9 @@
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 DEFAULT_MAX_REQUEST_BODY_SIZE, StreamableHTTPSessionManager
90+
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
9191
from mcp.server.subscriptions import InMemorySubscriptionBus, ListenHandler, SubscriptionBus
92-
from mcp.server.transport_security import TransportSecuritySettings
92+
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
9393
from mcp.shared.exceptions import MCPError
9494
from mcp.shared.uri_template import UriTemplate
9595

@@ -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: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,8 @@ async def handle_sse(request):
5252

5353
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
5454
from mcp.server.transport_security import (
55+
DEFAULT_MAX_REQUEST_BODY_SIZE,
56+
RequestBodyLimitMiddleware,
5557
TransportSecurityMiddleware,
5658
TransportSecuritySettings,
5759
)
@@ -79,14 +81,22 @@ class SseServerTransport:
7981
_session_owners: dict[UUID, AuthorizationContext]
8082
_security: TransportSecurityMiddleware
8183

82-
def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None:
84+
def __init__(
85+
self,
86+
endpoint: str,
87+
security_settings: TransportSecuritySettings | None = None,
88+
max_request_body_size: int = DEFAULT_MAX_REQUEST_BODY_SIZE,
89+
) -> None:
8390
"""Creates a new SSE server transport, which will direct the client to POST
8491
messages to the relative path given.
8592
8693
Args:
8794
endpoint: A relative path where messages should be posted
8895
(e.g., "/messages/").
8996
security_settings: Optional security settings for DNS rebinding protection.
97+
max_request_body_size: Maximum size in bytes for POSTed message bodies. Requests that
98+
declare or stream a larger body receive HTTP 413. Defaults to 4 MiB, matching
99+
`StreamableHTTPSessionManager`.
90100
91101
Note:
92102
We use relative paths instead of full URLs for several reasons:
@@ -103,6 +113,9 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
103113

104114
super().__init__()
105115

116+
if max_request_body_size <= 0:
117+
raise ValueError("max_request_body_size must be a positive number of bytes")
118+
106119
# Validate that endpoint is a relative path and not a full URL
107120
if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint:
108121
raise ValueError(
@@ -118,6 +131,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
118131
self._read_stream_writers = {}
119132
self._session_owners = {}
120133
self._security = TransportSecurityMiddleware(security_settings)
134+
self._post_message_app = RequestBodyLimitMiddleware(self._handle_post_message, max_request_body_size)
121135
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
122136

123137
@asynccontextmanager
@@ -203,6 +217,17 @@ async def response_wrapper(scope: Scope, receive: Receive, send: Send):
203217
self._session_owners.pop(session_id, None)
204218

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

src/mcp/server/streamable_http_manager.py

Lines changed: 5 additions & 68 deletions
Original file line numberDiff line numberDiff line change
@@ -4,25 +4,25 @@
44

55
import contextlib
66
import logging
7-
from collections import deque
87
from collections.abc import AsyncIterator
9-
from typing import TYPE_CHECKING, Any, Final
8+
from typing import TYPE_CHECKING, Any
109
from uuid import uuid4
1110

1211
import anyio
1312
from anyio.abc import TaskStatus
1413
from mcp_types import DEFAULT_NEGOTIATED_VERSION, INVALID_REQUEST, ErrorData, JSONRPCError
1514
from mcp_types.version import HANDSHAKE_PROTOCOL_VERSIONS
16-
from starlette.datastructures import Headers
1715
from starlette.requests import Request
1816
from starlette.responses import Response
19-
from starlette.types import ASGIApp, Message, Receive, Scope, Send
17+
from starlette.types import Receive, Scope, Send
2018

2119
from mcp.server._streamable_http_modern import handle_modern_request
2220
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser, AuthorizationContext, authorization_context
2321
from mcp.server.connection import Connection
2422
from mcp.server.runner import serve_connection, serve_loop
2523
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, EventStore, StreamableHTTPServerTransport
24+
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE as DEFAULT_MAX_REQUEST_BODY_SIZE
25+
from mcp.server.transport_security import RequestBodyLimitMiddleware as RequestBodyLimitMiddleware
2626
from mcp.server.transport_security import TransportSecuritySettings
2727
from mcp.shared._compat import resync_tracer
2828
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER
@@ -34,9 +34,6 @@
3434

3535
logger = logging.getLogger(__name__)
3636

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

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

@@ -371,66 +368,6 @@ async def run_server(*, task_status: TaskStatus[None] = anyio.TASK_STATUS_IGNORE
371368
await response(scope, receive, send)
372369

373370

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-
434371
class StreamableHTTPASGIApp:
435372
"""ASGI application for Streamable HTTP server transport."""
436373

0 commit comments

Comments
 (0)