[v1.x] Apply the request body limit to the SSE and OAuth endpoints (#3344)

This commit is contained in:
Max 2026-08-21 17:02:59 +01:00 • committed by GitHub
commit 4dc224fab2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 426 additions and 161 deletions

View file

@ -14,6 +14,7 @@ from starlette.applications import Starlette
from mcp.server.auth.provider import AuthorizeError, RegistrationError, TokenError
from mcp.server.auth.routes import create_auth_routes
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE
# TODO(Marcelo): This TYPE_CHECKING shouldn't be here, but pytest doesn't seem to get the module correctly.
if TYPE_CHECKING:
@ -302,3 +303,59 @@ class TestTokenErrorHandling:
data = refresh_response.json()
assert data["error"] == "invalid_scope"
assert data["error_description"] == "The requested scope is invalid"
_FORM = "application/x-www-form-urlencoded"
@pytest.mark.anyio
@pytest.mark.parametrize(
("method", "path", "content_type"),
[
("POST", "/token", _FORM),
("POST", "/revoke", _FORM),
("POST", "/register", "application/json"),
("POST", "/authorize", _FORM),
# The other methods these routes accept reach the same body-reading handlers.
("OPTIONS", "/token", _FORM),
("OPTIONS", "/revoke", _FORM),
("OPTIONS", "/register", "application/json"),
("HEAD", "/authorize", _FORM),
],
)
async def test_oversized_request_body_returns_413(client: httpx.AsyncClient, method: str, path: str, content_type: str):
"""Each endpoint that reads a request body rejects one over 4 MiB before parsing it, whatever the method."""
response = await client.request(
method, path, content=b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1), headers={"Content-Type": content_type}
)
assert response.status_code == 413
@pytest.mark.anyio
async def test_request_body_within_the_limit_is_still_parsed(client: httpx.AsyncClient):
"""A small body is passed through to the handler intact: the form is parsed and its fields validated."""
response = await client.post("/token", data={"grant_type": "authorization_code"})
assert response.status_code == 401
assert response.json() == {"error": "unauthorized_client", "error_description": "Missing client_id"}
@pytest.mark.anyio
async def test_cors_preflight_is_still_answered(client: httpx.AsyncClient):
"""A CORS preflight to a body-limited endpoint is answered by the CORS layer as before."""
response = await client.options(
"/token", headers={"Origin": "https://client.example.com", "Access-Control-Request-Method": "POST"}
)
assert response.status_code == 200
assert response.headers["access-control-allow-origin"] == "*"
@pytest.mark.anyio
async def test_oversized_cross_origin_request_gets_413_with_cors_headers(client: httpx.AsyncClient):
"""The 413 is produced inside the CORS layer, so a browser client can still read it."""
response = await client.post(
"/token",
content=b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1),
headers={"Content-Type": _FORM, "Origin": "https://client.example.com"},
)
assert response.status_code == 413
assert response.headers["access-control-allow-origin"] == "*"

View file

@ -3,6 +3,7 @@ from pathlib import Path
from typing import TYPE_CHECKING, Any
from unittest.mock import patch
import httpx
import pytest
from pydantic import AnyUrl, BaseModel
from starlette.routing import Mount, Route
@ -1505,3 +1506,17 @@ def test_streamable_http_app_passes_the_configured_request_body_limit_to_its_man
mcp.streamable_http_app()
assert mcp.session_manager.max_request_body_size == 8
@pytest.mark.anyio
async def test_sse_app_applies_the_configured_request_body_limit() -> None:
"""FastMCP forwards its request-body setting to the SSE message endpoint: larger POSTs get HTTP 413."""
mcp = FastMCP(host="0.0.0.0", max_request_body_size=8)
transport = httpx.ASGITransport(app=mcp.sse_app())
async with httpx.AsyncClient(transport=transport, base_url="http://localhost") as http:
response = await http.post(
"/messages/?session_id=12345678123456781234567812345678",
content=b"123456789",
headers={"Content-Type": "application/json"},
)
assert response.status_code == 413

View file

@ -21,7 +21,7 @@ from mcp.server import Server
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
from mcp.server.auth.provider import AccessToken
from mcp.server.sse import SseServerTransport
from mcp.server.transport_security import TransportSecuritySettings
from mcp.server.transport_security import DEFAULT_MAX_REQUEST_BODY_SIZE, TransportSecuritySettings
from mcp.types import Tool
from tests.test_helpers import wait_for_server
@ -323,14 +323,14 @@ def _authenticated_user(client_id: str, subject: str | None = None, issuer: str
return AuthenticatedUser(AccessToken(token="token", client_id=client_id, scopes=[], subject=subject, claims=claims))
def _sse_scope(method: str, path: str, user: AuthenticatedUser | None) -> dict[str, Any]:
def _sse_scope(method: str, path: str, user: AuthenticatedUser | None, *, query_string: bytes = b"") -> dict[str, Any]:
"""Build an ASGI scope for a request to the SSE transport."""
scope: dict[str, Any] = {
"type": "http",
"method": method,
"path": path,
"root_path": "",
"query_string": b"",
"query_string": query_string,
"headers": [(b"content-type", b"application/json")],
}
if user is not None:
@ -338,24 +338,44 @@ def _sse_scope(method: str, path: str, user: AuthenticatedUser | None) -> dict[s
return scope
async def _post_message(transport: SseServerTransport, session_id: str, user: AuthenticatedUser | None) -> int:
"""POST a message to an SSE session as `user` and return the response status."""
body = b'{"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null}'
scope = _sse_scope("POST", "/messages/", user)
scope["query_string"] = f"session_id={session_id}".encode()
async def _call_message_endpoint(
transport: SseServerTransport, scope: dict[str, Any], body: bytes | list[bytes]
) -> list[Message]:
"""Send a request to the transport's message endpoint and return the ASGI messages it sent.
`body` may be a list of chunks to deliver the request body over several `http.request` messages;
no Content-Length header is set either way.
"""
sent: list[Message] = []
chunks = list(body) if isinstance(body, list) else [body]
async def receive() -> Message:
return {"type": "http.request", "body": body, "more_body": False}
chunk = chunks.pop(0)
return {"type": "http.request", "body": chunk, "more_body": bool(chunks)}
async def send(message: Message) -> None:
sent.append(message)
await transport.handle_post_message(scope, receive, send)
return sent
def _response_status(sent: list[Message]) -> int:
response_start = next(msg for msg in sent if msg["type"] == "http.response.start")
return response_start["status"]
def _response_body(sent: list[Message]) -> bytes:
return b"".join(msg.get("body", b"") for msg in sent if msg["type"] == "http.response.body")
async def _post_message(transport: SseServerTransport, session_id: str, user: AuthenticatedUser | None) -> int:
"""POST a message to an SSE session as `user` and return the response status."""
body = b'{"jsonrpc": "2.0", "id": 1, "method": "ping", "params": null}'
scope = _sse_scope("POST", "/messages/", user, query_string=f"session_id={session_id}".encode())
return _response_status(await _call_message_endpoint(transport, scope, body))
_Principal = tuple[str] | tuple[str, str] | tuple[str, str, str]
@ -426,3 +446,70 @@ async def test_sse_post_requires_the_credential_that_created_the_session(
# Once the connection is gone the session is no longer routable.
assert await _post_message(transport, session_ids[0], creator_user) == 404
# A well-formed session ID that no live session owns.
_UNKNOWN_SESSION = b"session_id=12345678123456781234567812345678"
@pytest.mark.anyio
async def test_sse_post_body_over_the_limit_returns_413():
"""A POST body larger than max_request_body_size is answered with 413 before any session handling."""
transport = SseServerTransport("/messages/", max_request_body_size=8)
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
sent = await _call_message_endpoint(transport, scope, b"123456789")
assert _response_status(sent) == 413
assert _response_body(sent) == b"Request body too large"
@pytest.mark.anyio
async def test_sse_post_body_limit_defaults_to_four_mib():
"""Without an explicit limit, a body one byte over 4 MiB (and no Content-Length) is answered with 413."""
transport = SseServerTransport("/messages/")
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
sent = await _call_message_endpoint(transport, scope, b"x" * (DEFAULT_MAX_REQUEST_BODY_SIZE + 1))
assert _response_status(sent) == 413
@pytest.mark.anyio
async def test_sse_post_streamed_body_over_the_limit_returns_413():
"""The limit counts bytes across body chunks, not just a declared Content-Length."""
transport = SseServerTransport("/messages/", max_request_body_size=8)
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
sent = await _call_message_endpoint(transport, scope, [b"1234", b"56789"])
assert _response_status(sent) == 413
@pytest.mark.anyio
async def test_sse_post_within_the_limit_reaches_session_lookup():
"""A body within the limit is passed on intact: an unknown session still gets its 404."""
transport = SseServerTransport("/messages/", max_request_body_size=64)
scope = _sse_scope("POST", "/messages/", None, query_string=_UNKNOWN_SESSION)
sent = await _call_message_endpoint(transport, scope, [b'{"jsonrpc": ', b'"2.0"}'])
assert _response_status(sent) == 404
assert _response_body(sent) == b"Could not find session"
@pytest.mark.anyio
@pytest.mark.parametrize("method", ["GET", "PUT"])
async def test_sse_message_endpoint_answers_405_to_non_post(method: str):
"""The message endpoint only accepts POST; other methods get 405 with an Allow header."""
transport = SseServerTransport("/messages/")
scope = _sse_scope(method, "/messages/", None, query_string=_UNKNOWN_SESSION)
sent = await _call_message_endpoint(transport, scope, b"{}")
assert _response_status(sent) == 405
response_start = next(msg for msg in sent if msg["type"] == "http.response.start")
assert (b"allow", b"POST") in response_start["headers"]
@pytest.mark.parametrize("max_request_body_size", [0, -1])
def test_sse_transport_rejects_a_non_positive_body_limit(max_request_body_size: int):
"""The body limit must be a positive number of bytes, matching StreamableHTTPSessionManager."""
with pytest.raises(ValueError) as exc_info:
SseServerTransport("/messages/", max_request_body_size=max_request_body_size)
assert str(exc_info.value) == "max_request_body_size must be a positive number of bytes"

View file

@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, patch
import anyio
import pytest
from starlette.types import Message, Receive, Scope, Send
from starlette.types import Message, Scope
from mcp.server import streamable_http_manager
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
@ -16,7 +16,6 @@ from mcp.server.lowlevel import Server
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
from mcp.server.streamable_http_manager import (
DEFAULT_MAX_REQUEST_BODY_SIZE,
RequestBodyLimitMiddleware,
StreamableHTTPSessionManager,
)
from mcp.types import INVALID_REQUEST
@ -143,57 +142,6 @@ async def test_oversized_streamed_body_is_rejected_before_session_creation(
assert response_start["status"] == 413
@pytest.mark.anyio
async def test_request_body_chunks_are_replayed_as_one_message() -> None:
"""SDK-defined: raw ASGI proves chunk overhead is discarded before the body reaches the transport."""
request_messages: Iterator[Message] = iter(
[
{"type": "http.request", "body": b"12", "more_body": True},
{"type": "http.request", "body": b"34", "more_body": True},
{"type": "http.request", "body": b"56", "more_body": False},
{"type": "http.disconnect"},
]
)
received_messages: list[Message] = []
async def receive() -> Message:
return next(request_messages)
async def app(scope: Scope, receive: Receive, send: Send) -> None:
received_messages.append(await receive())
received_messages.append(await receive())
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
await middleware(scope, receive, AsyncMock())
assert received_messages == [
{"type": "http.request", "body": b"123456", "more_body": False},
{"type": "http.disconnect"},
]
@pytest.mark.anyio
async def test_disconnect_before_request_body_is_replayed() -> None:
"""SDK-defined: raw ASGI proves a disconnect before the first body message reaches the transport."""
disconnect: Message = {"type": "http.disconnect"}
received_messages: list[Message] = []
async def receive() -> Message:
return disconnect
async def app(scope: Scope, receive: Receive, send: Send) -> None:
received_messages.append(await receive())
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
await middleware(scope, receive, AsyncMock())
assert received_messages == [disconnect]
def test_request_body_limit_defaults_to_four_mib() -> None:
"""SDK-defined: Streamable HTTP request bodies are limited to 4 MiB by default."""
manager = StreamableHTTPSessionManager(app=Server("test-default-size-limit"))

View file

@ -0,0 +1,122 @@
"""Tests for the request checks shared by the HTTP server transports."""
from collections.abc import Iterator
from unittest.mock import AsyncMock
import pytest
from starlette.types import Message, Receive, Scope, Send
from mcp.server.transport_security import RequestBodyLimitMiddleware
@pytest.mark.anyio
async def test_request_body_chunks_are_replayed_as_one_message() -> None:
"""SDK-defined: raw ASGI proves chunk overhead is discarded before the body reaches the transport."""
request_messages: Iterator[Message] = iter(
[
{"type": "http.request", "body": b"12", "more_body": True},
{"type": "http.request", "body": b"34", "more_body": True},
{"type": "http.request", "body": b"56", "more_body": False},
{"type": "http.disconnect"},
]
)
received_messages: list[Message] = []
async def receive() -> Message:
return next(request_messages)
async def app(scope: Scope, receive: Receive, send: Send) -> None:
received_messages.append(await receive())
received_messages.append(await receive())
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
await middleware(scope, receive, AsyncMock())
assert received_messages == [
{"type": "http.request", "body": b"123456", "more_body": False},
{"type": "http.disconnect"},
]
@pytest.mark.anyio
async def test_client_disconnect_while_streaming_request_body_is_replayed() -> None:
"""SDK-defined: raw ASGI is required to prove a disconnect before body completion reaches the transport."""
disconnect: Message = {"type": "http.disconnect"}
request_messages: Iterator[Message] = iter(
[{"type": "http.request", "body": b"1234", "more_body": True}, disconnect]
)
received_messages: list[Message] = []
async def receive() -> Message:
return next(request_messages)
async def app(scope: Scope, receive: Receive, send: Send) -> None:
received_messages.append(await receive())
received_messages.append(await receive())
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
await middleware(scope, receive, AsyncMock())
assert received_messages == [
{"type": "http.request", "body": b"1234", "more_body": True},
disconnect,
]
@pytest.mark.anyio
async def test_disconnect_before_request_body_is_replayed() -> None:
"""SDK-defined: raw ASGI proves a disconnect before the first body message reaches the transport."""
disconnect: Message = {"type": "http.disconnect"}
received_messages: list[Message] = []
async def receive() -> Message:
return disconnect
async def app(scope: Scope, receive: Receive, send: Send) -> None:
received_messages.append(await receive())
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": []}
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
await middleware(scope, receive, AsyncMock())
assert received_messages == [disconnect]
@pytest.mark.anyio
@pytest.mark.parametrize("method", ["GET", "PUT", "OPTIONS", "HEAD", "DELETE"])
async def test_request_body_limit_applies_to_every_method(method: str) -> None:
"""SDK-defined: the limit is a property of the request body, not of the method that carries it."""
app = AsyncMock()
sent_messages: list[Message] = []
receive = AsyncMock(return_value={"type": "http.request", "body": b"123456789", "more_body": False})
async def send(message: Message) -> None:
sent_messages.append(message)
scope: Scope = {"type": "http", "method": method, "path": "/mcp", "headers": []}
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
await middleware(scope, receive, send)
assert [message["status"] for message in sent_messages if message["type"] == "http.response.start"] == [413]
app.assert_not_awaited()
@pytest.mark.anyio
async def test_request_body_limit_leaves_non_http_scopes_alone() -> None:
"""SDK-defined: only HTTP requests carry a body to limit; other ASGI scopes go straight to the app."""
app = AsyncMock()
receive = AsyncMock()
send = AsyncMock()
scope: Scope = {"type": "lifespan"}
middleware = RequestBodyLimitMiddleware(app, max_body_size=8)
await middleware(scope, receive, send)
app.assert_awaited_once_with(scope, receive, send)
receive.assert_not_awaited()