Skip to content

Commit f27c1ed

Browse files
committed
Apply the request body limit regardless of HTTP method
RequestBodyLimitMiddleware only inspected POST requests, but some of the routes it wraps accept other methods whose handlers read the body as well (OPTIONS on the token, registration and revocation endpoints, HEAD on the authorization endpoint). Enforce the limit for every HTTP request. Also take the limit back out of cors_middleware, which returns to plain CORS wrapping, and compose the CORS and body-limit wrappers explicitly where the OAuth routes are declared.
1 parent a6f2d65 commit f27c1ed

5 files changed

Lines changed: 89 additions & 37 deletions

File tree

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: 19 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -52,22 +52,24 @@ def validate_issuer_url(url: AnyHttpUrl):
5252
ID_JAG_GRANT_PROFILE = "urn:ietf:params:oauth:grant-profile:id-jag"
5353

5454

55-
def _body_limited(handler: Callable[[Request], Response | Awaitable[Response]]) -> ASGIApp:
56-
"""Wrap an endpoint so POST bodies over the default limit are answered with 413 before it runs."""
57-
return RequestBodyLimitMiddleware(request_response(handler), DEFAULT_MAX_REQUEST_BODY_SIZE)
55+
def _cors(app: ASGIApp, allow_methods: list[str]) -> ASGIApp:
56+
return CORSMiddleware(
57+
app=app,
58+
allow_origins="*",
59+
allow_methods=allow_methods,
60+
allow_headers=[MCP_PROTOCOL_VERSION_HEADER],
61+
)
62+
63+
64+
def _body_limited(app: ASGIApp) -> ASGIApp:
65+
return RequestBodyLimitMiddleware(app, DEFAULT_MAX_REQUEST_BODY_SIZE)
5866

5967

6068
def cors_middleware(
6169
handler: Callable[[Request], Response | Awaitable[Response]],
6270
allow_methods: list[str],
6371
) -> ASGIApp:
64-
cors_app = CORSMiddleware(
65-
app=_body_limited(handler),
66-
allow_origins="*",
67-
allow_methods=allow_methods,
68-
allow_headers=[MCP_PROTOCOL_VERSION_HEADER],
69-
)
70-
return cors_app
72+
return _cors(request_response(handler), allow_methods)
7173

7274

7375
def create_auth_routes(
@@ -90,11 +92,13 @@ def create_auth_routes(
9092
supports_identity_assertion=identity_assertion_enabled,
9193
)
9294
client_authenticator = ClientAuthenticator(provider)
95+
token_handler = TokenHandler(provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled)
9396

9497
# Create routes
9598
# Allow CORS requests for endpoints meant to be hit by the OAuth client
9699
# (with the client secret). This is intended to support things like MCP Inspector,
97-
# 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.
98102
routes = [
99103
Route(
100104
"/.well-known/oauth-authorization-server",
@@ -108,17 +112,12 @@ def create_auth_routes(
108112
AUTHORIZATION_PATH,
109113
# do not allow CORS for authorization endpoint;
110114
# clients should just redirect to this
111-
endpoint=_body_limited(AuthorizationHandler(provider).handle),
115+
endpoint=_body_limited(request_response(AuthorizationHandler(provider).handle)),
112116
methods=["GET", "POST"],
113117
),
114118
Route(
115119
TOKEN_PATH,
116-
endpoint=cors_middleware(
117-
TokenHandler(
118-
provider, client_authenticator, identity_assertion_enabled=identity_assertion_enabled
119-
).handle,
120-
["POST", "OPTIONS"],
121-
),
120+
endpoint=_cors(_body_limited(request_response(token_handler.handle)), ["POST", "OPTIONS"]),
122121
methods=["POST", "OPTIONS"],
123122
),
124123
]
@@ -131,10 +130,7 @@ def create_auth_routes(
131130
routes.append(
132131
Route(
133132
REGISTRATION_PATH,
134-
endpoint=cors_middleware(
135-
registration_handler.handle,
136-
["POST", "OPTIONS"],
137-
),
133+
endpoint=_cors(_body_limited(request_response(registration_handler.handle)), ["POST", "OPTIONS"]),
138134
methods=["POST", "OPTIONS"],
139135
)
140136
)
@@ -144,10 +140,7 @@ def create_auth_routes(
144140
routes.append(
145141
Route(
146142
REVOCATION_PATH,
147-
endpoint=cors_middleware(
148-
revocation_handler.handle,
149-
["POST", "OPTIONS"],
150-
),
143+
endpoint=_cors(_body_limited(request_response(revocation_handler.handle)), ["POST", "OPTIONS"]),
151144
methods=["POST", "OPTIONS"],
152145
)
153146
)

src/mcp/server/streamable_http_manager.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,7 @@ class StreamableHTTPSessionManager:
7070
retry_interval is also configured, ensure the idle timeout comfortably exceeds the retry interval to
7171
avoid reaping sessions during normal SSE polling gaps. Default is None (no timeout). A value of 1800
7272
(30 minutes) is recommended for most deployments.
73-
max_request_body_size: Maximum size in bytes for Streamable HTTP POST request bodies. Requests that
73+
max_request_body_size: Maximum size in bytes for Streamable HTTP request bodies. Requests that
7474
exceed this limit receive a 413 response before parsing or session creation. Defaults to 4 MiB.
7575
"""
7676

@@ -379,7 +379,7 @@ def __init__(self, app: ASGIApp, max_body_size: int) -> None:
379379
self.max_body_size = max_body_size
380380

381381
async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None:
382-
if scope["type"] != "http" or scope["method"] != "POST":
382+
if scope["type"] != "http":
383383
await self.app(scope, receive, send)
384384
return
385385

tests/server/auth/test_error_handling.py

Lines changed: 32 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -296,13 +296,25 @@ async def test_token_error_handling_refresh_token(
296296

297297
@pytest.mark.anyio
298298
@pytest.mark.parametrize(
299-
("path", "content_type"),
300-
[("/token", _FORM), ("/revoke", _FORM), ("/register", "application/json"), ("/authorize", _FORM)],
299+
("method", "path", "content_type"),
300+
[
301+
("POST", "/token", _FORM),
302+
("POST", "/revoke", _FORM),
303+
("POST", "/register", "application/json"),
304+
("POST", "/authorize", _FORM),
305+
# The other methods these routes accept reach the same body-reading handlers.
306+
("OPTIONS", "/token", _FORM),
307+
("OPTIONS", "/revoke", _FORM),
308+
("OPTIONS", "/register", "application/json"),
309+
("HEAD", "/authorize", _FORM),
310+
],
301311
)
302-
async def test_oversized_request_body_returns_413(client: httpx2.AsyncClient, path: str, content_type: str):
303-
"""Each endpoint that reads a request body rejects one over 4 MiB before parsing it."""
304-
response = await client.post(
305-
path, content=b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1), headers={"Content-Type": content_type}
312+
async def test_oversized_request_body_returns_413(
313+
client: httpx2.AsyncClient, method: str, path: str, content_type: str
314+
):
315+
"""Each endpoint that reads a request body rejects one over 4 MiB before parsing it, whatever the method."""
316+
response = await client.request(
317+
method, path, content=b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1), headers={"Content-Type": content_type}
306318
)
307319
assert response.status_code == 413
308320

@@ -316,10 +328,22 @@ async def test_request_body_within_the_limit_is_still_parsed(client: httpx2.Asyn
316328

317329

318330
@pytest.mark.anyio
319-
async def test_options_preflight_is_not_body_limited(client: httpx2.AsyncClient):
320-
"""CORS preflight requests still get their CORS answer; only POST bodies are limited."""
331+
async def test_cors_preflight_is_still_answered(client: httpx2.AsyncClient):
332+
"""A CORS preflight to a body-limited endpoint is answered by the CORS layer as before."""
321333
response = await client.options(
322334
"/token", headers={"Origin": "https://client.example.com", "Access-Control-Request-Method": "POST"}
323335
)
324336
assert response.status_code == 200
325337
assert response.headers["access-control-allow-origin"] == "*"
338+
339+
340+
@pytest.mark.anyio
341+
async def test_oversized_cross_origin_request_gets_413_with_cors_headers(client: httpx2.AsyncClient):
342+
"""The 413 is produced inside the CORS layer, so a browser client can still read it."""
343+
response = await client.post(
344+
"/token",
345+
content=b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1),
346+
headers={"Content-Type": _FORM, "Origin": "https://client.example.com"},
347+
)
348+
assert response.status_code == 413
349+
assert response.headers["access-control-allow-origin"] == "*"

tests/server/test_streamable_http_manager.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -219,6 +219,41 @@ async def app(scope: Scope, receive: Receive, send: Send) -> None:
219219
assert received_messages == [{"type": "http.request", "body": b"123456", "more_body": False}]
220220

221221

222+
@pytest.mark.anyio
223+
@pytest.mark.parametrize("method", ["GET", "PUT", "OPTIONS", "HEAD", "DELETE"])
224+
async def test_request_body_limit_applies_to_every_method(method: str) -> None:
225+
"""SDK-defined: the limit is a property of the request body, not of the method that carries it."""
226+
app = AsyncMock()
227+
sent_messages: list[Message] = []
228+
receive = AsyncMock(return_value={"type": "http.request", "body": b"123456789", "more_body": False})
229+
230+
async def send(message: Message) -> None:
231+
sent_messages.append(message)
232+
233+
scope: Scope = {"type": "http", "method": method, "path": "/mcp", "headers": []}
234+
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
235+
236+
await middleware(scope, receive, send)
237+
238+
assert [message["status"] for message in sent_messages if message["type"] == "http.response.start"] == [413]
239+
app.assert_not_awaited()
240+
241+
242+
@pytest.mark.anyio
243+
async def test_request_body_limit_leaves_non_http_scopes_alone() -> None:
244+
"""SDK-defined: only HTTP requests carry a body to limit; other ASGI scopes go straight to the app."""
245+
app = AsyncMock()
246+
receive = AsyncMock()
247+
send = AsyncMock()
248+
scope: Scope = {"type": "lifespan"}
249+
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
250+
251+
await middleware(scope, receive, send)
252+
253+
app.assert_awaited_once_with(scope, receive, send)
254+
receive.assert_not_awaited()
255+
256+
222257
def test_request_body_limit_defaults_to_four_mib() -> None:
223258
"""SDK-defined: Streamable HTTP request bodies are limited to 4 MiB by default."""
224259
manager = StreamableHTTPSessionManager(app=Server("test-default-size-limit"))

0 commit comments

Comments
 (0)