Skip to content

Commit 9e52110

Browse files
Theodor N. EngøyTheodor N. Engøy
authored andcommitted
server: add max_body_bytes guard for HTTP request bodies
1 parent 7601a52 commit 9e52110

File tree

10 files changed

+271
-23
lines changed

10 files changed

+271
-23
lines changed

src/mcp/server/auth/handlers/register.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
from mcp.server.auth.json_response import PydanticJSONResponse
1313
from mcp.server.auth.provider import OAuthAuthorizationServerProvider, RegistrationError, RegistrationErrorCode
1414
from mcp.server.auth.settings import ClientRegistrationOptions
15+
from mcp.server.http_body import BodyTooLargeError, read_request_body
1516
from mcp.shared.auth import OAuthClientInformationFull, OAuthClientMetadata
1617

1718
# this alias is a no-op; it's just to separate out the types exposed to the
@@ -32,10 +33,12 @@ class RegistrationHandler:
3233
async def handle(self, request: Request) -> Response:
3334
# Implements dynamic client registration as defined in https://datatracker.ietf.org/doc/html/rfc7591#section-3.1
3435
try:
35-
body = await request.body()
36+
body = await read_request_body(request, max_body_bytes=self.options.max_body_bytes)
3637
client_metadata = OAuthClientMetadata.model_validate_json(body)
3738

3839
# Scope validation is handled below
40+
except BodyTooLargeError:
41+
return Response("Payload too large", status_code=413, headers={"Connection": "close"})
3942
except ValidationError as validation_error:
4043
return PydanticJSONResponse(
4144
content=RegistrationErrorResponse(

src/mcp/server/auth/settings.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
from pydantic import AnyHttpUrl, BaseModel, Field
22

3+
from mcp.server.http_body import DEFAULT_MAX_BODY_BYTES
4+
35

46
class ClientRegistrationOptions(BaseModel):
57
enabled: bool = False
68
client_secret_expiry_seconds: int | None = None
79
valid_scopes: list[str] | None = None
810
default_scopes: list[str] | None = None
11+
# Limit the size of incoming /register request bodies to avoid DoS via unbounded reads.
12+
max_body_bytes: int = DEFAULT_MAX_BODY_BYTES
913

1014

1115
class RevocationOptions(BaseModel):

src/mcp/server/http_body.py

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
from __future__ import annotations
2+
3+
from dataclasses import dataclass
4+
5+
from starlette.requests import Request
6+
7+
DEFAULT_MAX_BODY_BYTES = 1_000_000
8+
9+
10+
@dataclass(frozen=True)
11+
class BodyTooLargeError(Exception):
12+
max_body_bytes: int
13+
14+
def __str__(self) -> str:
15+
return f"Request body exceeds max_body_bytes={self.max_body_bytes}"
16+
17+
18+
async def read_request_body(request: Request, *, max_body_bytes: int | None = DEFAULT_MAX_BODY_BYTES) -> bytes:
19+
"""Read an HTTP request body with a hard cap.
20+
21+
Notes:
22+
- This avoids unbounded buffering of the request body in Python.
23+
- If the body exceeds max_body_bytes, this raises BodyTooLargeError as soon
24+
as possible.
25+
"""
26+
if max_body_bytes is None:
27+
return await request.body()
28+
29+
if max_body_bytes <= 0:
30+
raise ValueError("max_body_bytes must be positive or None")
31+
32+
# Fast-path: reject based on Content-Length when provided.
33+
content_length = request.headers.get("content-length")
34+
if content_length is not None:
35+
try:
36+
if int(content_length) > max_body_bytes:
37+
raise BodyTooLargeError(max_body_bytes)
38+
except ValueError:
39+
# Ignore invalid Content-Length; we'll enforce while streaming.
40+
pass
41+
42+
body = bytearray()
43+
async for chunk in request.stream():
44+
if not chunk:
45+
continue
46+
47+
# Never buffer more than max_body_bytes bytes.
48+
remaining = max_body_bytes - len(body)
49+
if remaining <= 0:
50+
raise BodyTooLargeError(max_body_bytes)
51+
if len(chunk) > remaining:
52+
body.extend(chunk[:remaining])
53+
raise BodyTooLargeError(max_body_bytes)
54+
55+
body.extend(chunk)
56+
57+
return bytes(body)
58+

src/mcp/server/lowlevel/server.py

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -93,6 +93,7 @@ async def main():
9393
from mcp.server.auth.settings import AuthSettings
9494
from mcp.server.context import ServerRequestContext
9595
from mcp.server.experimental.request_context import Experimental
96+
from mcp.server.http_body import DEFAULT_MAX_BODY_BYTES
9697
from mcp.server.lowlevel.experimental import ExperimentalHandlers
9798
from mcp.server.lowlevel.func_inspection import create_call_wrapper
9899
from mcp.server.lowlevel.helper_types import ReadResourceContents
@@ -810,14 +811,20 @@ def streamable_http_app(
810811
event_store: EventStore | None = None,
811812
retry_interval: int | None = None,
812813
transport_security: TransportSecuritySettings | None = None,
814+
max_body_bytes: int | None = DEFAULT_MAX_BODY_BYTES,
813815
host: str = "127.0.0.1",
814816
auth: AuthSettings | None = None,
815817
token_verifier: TokenVerifier | None = None,
816818
auth_server_provider: OAuthAuthorizationServerProvider[Any, Any, Any] | None = None,
817819
custom_starlette_routes: list[Route] | None = None,
818820
debug: bool = False,
819821
) -> Starlette:
820-
"""Return an instance of the StreamableHTTP server app."""
822+
"""Return an instance of the StreamableHTTP server app.
823+
824+
Args:
825+
max_body_bytes: Maximum size (in bytes) for JSON POST request bodies. Defaults
826+
to 1_000_000. Set to None to disable this guard.
827+
"""
821828
# Auto-enable DNS rebinding protection for localhost (IPv4 and IPv6)
822829
if transport_security is None and host in ("127.0.0.1", "localhost", "::1"):
823830
transport_security = TransportSecuritySettings(
@@ -833,6 +840,7 @@ def streamable_http_app(
833840
json_response=json_response,
834841
stateless=stateless_http,
835842
security_settings=transport_security,
843+
max_body_bytes=max_body_bytes,
836844
)
837845
self._session_manager = session_manager
838846

src/mcp/server/sse.py

Lines changed: 18 additions & 2 deletions
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 import types
54+
from mcp.server.http_body import DEFAULT_MAX_BODY_BYTES, BodyTooLargeError, read_request_body
5455
from mcp.server.transport_security import (
5556
TransportSecurityMiddleware,
5657
TransportSecuritySettings,
@@ -75,14 +76,21 @@ class SseServerTransport:
7576
_read_stream_writers: dict[UUID, MemoryObjectSendStream[SessionMessage | Exception]]
7677
_security: TransportSecurityMiddleware
7778

78-
def __init__(self, endpoint: str, security_settings: TransportSecuritySettings | None = None) -> None:
79+
def __init__(
80+
self,
81+
endpoint: str,
82+
security_settings: TransportSecuritySettings | None = None,
83+
max_body_bytes: int | None = DEFAULT_MAX_BODY_BYTES,
84+
) -> None:
7985
"""Creates a new SSE server transport, which will direct the client to POST
8086
messages to the relative path given.
8187
8288
Args:
8389
endpoint: A relative path where messages should be posted
8490
(e.g., "/messages/").
8591
security_settings: Optional security settings for DNS rebinding protection.
92+
max_body_bytes: Maximum size (in bytes) for JSON POST request bodies. Defaults
93+
to 1_000_000. Set to None to disable this guard.
8694
8795
Note:
8896
We use relative paths instead of full URLs for several reasons:
@@ -98,6 +106,8 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
98106
"""
99107

100108
super().__init__()
109+
if max_body_bytes is not None and max_body_bytes <= 0:
110+
raise ValueError("max_body_bytes must be positive or None")
101111

102112
# Validate that endpoint is a relative path and not a full URL
103113
if "://" in endpoint or endpoint.startswith("//") or "?" in endpoint or "#" in endpoint:
@@ -113,6 +123,7 @@ def __init__(self, endpoint: str, security_settings: TransportSecuritySettings |
113123
self._endpoint = endpoint
114124
self._read_stream_writers = {}
115125
self._security = TransportSecurityMiddleware(security_settings)
126+
self._max_body_bytes = max_body_bytes
116127
logger.debug(f"SseServerTransport initialized with endpoint: {endpoint}")
117128

118129
@asynccontextmanager
@@ -223,7 +234,12 @@ async def handle_post_message(self, scope: Scope, receive: Receive, send: Send)
223234
response = Response("Could not find session", status_code=404)
224235
return await response(scope, receive, send)
225236

226-
body = await request.body()
237+
try:
238+
body = await read_request_body(request, max_body_bytes=self._max_body_bytes)
239+
except BodyTooLargeError as e:
240+
response = Response("Payload too large", status_code=413, headers={"Connection": "close"})
241+
logger.warning(f"Received payload too large: {e}")
242+
return await response(scope, receive, send)
227243
logger.debug(f"Received JSON: {body}")
228244

229245
try:

src/mcp/server/streamable_http.py

Lines changed: 46 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from starlette.responses import Response
2525
from starlette.types import Receive, Scope, Send
2626

27+
from mcp.server.http_body import DEFAULT_MAX_BODY_BYTES, BodyTooLargeError, read_request_body
2728
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
2829
from mcp.shared.message import ServerMessageMetadata, SessionMessage
2930
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
@@ -132,6 +133,7 @@ def __init__(
132133
event_store: EventStore | None = None,
133134
security_settings: TransportSecuritySettings | None = None,
134135
retry_interval: int | None = None,
136+
max_body_bytes: int | None = DEFAULT_MAX_BODY_BYTES,
135137
) -> None:
136138
"""Initialize a new StreamableHTTP server transport.
137139
@@ -148,18 +150,23 @@ def __init__(
148150
retry field. When set, the server will send a retry field in
149151
SSE priming events to control client reconnection timing for
150152
polling behavior. Only used when event_store is provided.
153+
max_body_bytes: Maximum size (in bytes) for JSON POST request bodies. Defaults
154+
to 1_000_000. Set to None to disable this guard.
151155
152156
Raises:
153157
ValueError: If the session ID contains invalid characters.
154158
"""
155159
if mcp_session_id is not None and not SESSION_ID_PATTERN.fullmatch(mcp_session_id):
156160
raise ValueError("Session ID must only contain visible ASCII characters (0x21-0x7E)")
161+
if max_body_bytes is not None and max_body_bytes <= 0:
162+
raise ValueError("max_body_bytes must be positive or None")
157163

158164
self.mcp_session_id = mcp_session_id
159165
self.is_json_response_enabled = is_json_response_enabled
160166
self._event_store = event_store
161167
self._security = TransportSecurityMiddleware(security_settings)
162168
self._retry_interval = retry_interval
169+
self._max_body_bytes = max_body_bytes
163170
self._request_streams: dict[
164171
RequestId,
165172
tuple[
@@ -427,6 +434,43 @@ async def _validate_accept_header(self, request: Request, scope: Scope, send: Se
427434
return False
428435
return True
429436

437+
async def _parse_jsonrpc_message(
438+
self,
439+
request: Request,
440+
scope: Scope,
441+
receive: Receive,
442+
send: Send,
443+
) -> JSONRPCMessage | None:
444+
"""Read + parse a JSON-RPC message from an HTTP request body."""
445+
try:
446+
body = await read_request_body(request, max_body_bytes=self._max_body_bytes)
447+
except BodyTooLargeError as e:
448+
response = self._create_error_response(
449+
f"Payload too large: {e}",
450+
HTTPStatus.REQUEST_ENTITY_TOO_LARGE,
451+
headers={"Connection": "close"},
452+
)
453+
await response(scope, receive, send)
454+
return None
455+
456+
try:
457+
raw_message = pydantic_core.from_json(body)
458+
except ValueError as e:
459+
response = self._create_error_response(f"Parse error: {str(e)}", HTTPStatus.BAD_REQUEST, PARSE_ERROR)
460+
await response(scope, receive, send)
461+
return None
462+
463+
try:
464+
return jsonrpc_message_adapter.validate_python(raw_message, by_name=False)
465+
except ValidationError as e: # pragma: no cover
466+
response = self._create_error_response(
467+
f"Validation error: {str(e)}",
468+
HTTPStatus.BAD_REQUEST,
469+
INVALID_PARAMS,
470+
)
471+
await response(scope, receive, send)
472+
return None
473+
430474
async def _handle_post_request(self, scope: Scope, request: Request, receive: Receive, send: Send) -> None:
431475
"""Handle POST requests containing JSON-RPC messages."""
432476
writer = self._read_stream_writer
@@ -446,25 +490,8 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
446490
await response(scope, receive, send)
447491
return
448492

449-
# Parse the body - only read it once
450-
body = await request.body()
451-
452-
try:
453-
raw_message = pydantic_core.from_json(body)
454-
except ValueError as e:
455-
response = self._create_error_response(f"Parse error: {str(e)}", HTTPStatus.BAD_REQUEST, PARSE_ERROR)
456-
await response(scope, receive, send)
457-
return
458-
459-
try:
460-
message = jsonrpc_message_adapter.validate_python(raw_message, by_name=False)
461-
except ValidationError as e: # pragma: no cover
462-
response = self._create_error_response(
463-
f"Validation error: {str(e)}",
464-
HTTPStatus.BAD_REQUEST,
465-
INVALID_PARAMS,
466-
)
467-
await response(scope, receive, send)
493+
message = await self._parse_jsonrpc_message(request, scope, receive, send)
494+
if message is None:
468495
return
469496

470497
# Check if this is an initialization request

src/mcp/server/streamable_http_manager.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from starlette.responses import Response
1616
from starlette.types import Receive, Scope, Send
1717

18+
from mcp.server.http_body import DEFAULT_MAX_BODY_BYTES
1819
from mcp.server.streamable_http import (
1920
MCP_SESSION_ID_HEADER,
2021
EventStore,
@@ -56,6 +57,8 @@ class StreamableHTTPSessionManager:
5657
security_settings: Optional transport security settings.
5758
retry_interval: Retry interval in milliseconds to suggest to clients in SSE
5859
retry field. Used for SSE polling behavior.
60+
max_body_bytes: Maximum size (in bytes) for JSON POST request bodies. Defaults
61+
to 1_000_000. Set to None to disable this guard.
5962
"""
6063

6164
def __init__(
@@ -66,13 +69,15 @@ def __init__(
6669
stateless: bool = False,
6770
security_settings: TransportSecuritySettings | None = None,
6871
retry_interval: int | None = None,
72+
max_body_bytes: int | None = DEFAULT_MAX_BODY_BYTES,
6973
):
7074
self.app = app
7175
self.event_store = event_store
7276
self.json_response = json_response
7377
self.stateless = stateless
7478
self.security_settings = security_settings
7579
self.retry_interval = retry_interval
80+
self.max_body_bytes = max_body_bytes
7681

7782
# Session tracking (only used if not stateless)
7883
self._session_creation_lock = anyio.Lock()
@@ -147,6 +152,7 @@ async def _handle_stateless_request(self, scope: Scope, receive: Receive, send:
147152
is_json_response_enabled=self.json_response,
148153
event_store=None, # No event store in stateless mode
149154
security_settings=self.security_settings,
155+
max_body_bytes=self.max_body_bytes,
150156
)
151157

152158
# Start server in a new task
@@ -198,6 +204,7 @@ async def _handle_stateful_request(self, scope: Scope, receive: Receive, send: S
198204
event_store=self.event_store, # May be None (no resumability)
199205
security_settings=self.security_settings,
200206
retry_interval=self.retry_interval,
207+
max_body_bytes=self.max_body_bytes,
201208
)
202209

203210
assert http_transport.mcp_session_id is not None

tests/server/auth/test_error_handling.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -116,6 +116,31 @@ async def test_registration_error_handling(client: httpx.AsyncClient, oauth_prov
116116
assert data["error_description"] == "The redirect URI is invalid"
117117

118118

119+
@pytest.mark.anyio
120+
async def test_registration_rejects_payload_too_large(oauth_provider: MockOAuthProvider):
121+
client_registration_options = ClientRegistrationOptions(enabled=True, max_body_bytes=10)
122+
revocation_options = RevocationOptions(enabled=False)
123+
124+
auth_routes = create_auth_routes(
125+
oauth_provider,
126+
issuer_url=AnyHttpUrl("http://localhost"),
127+
client_registration_options=client_registration_options,
128+
revocation_options=revocation_options,
129+
)
130+
app = Starlette(routes=auth_routes)
131+
132+
transport = ASGITransport(app=app)
133+
async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as client:
134+
body = b'{"a":"' + (b"x" * 20) + b'"}'
135+
response = await client.post(
136+
"/register",
137+
content=body,
138+
headers={"Content-Type": "application/json"},
139+
)
140+
assert response.status_code == 413, response.content
141+
assert response.text == "Payload too large"
142+
143+
119144
@pytest.mark.anyio
120145
async def test_authorize_error_handling(
121146
client: httpx.AsyncClient,

0 commit comments

Comments
 (0)