Add Streamable HTTP request body limits (#3095)
This commit is contained in:
parent
2713b53b12
commit
03aaebd3aa
3 changed files with 280 additions and 19 deletions
|
|
@ -43,6 +43,7 @@ async def test_streamable_http_app_takes_runs_options_except_port() -> None:
|
|||
"stateless_http",
|
||||
"event_store",
|
||||
"retry_interval",
|
||||
"max_request_body_size",
|
||||
"transport_security",
|
||||
"host",
|
||||
}
|
||||
|
|
@ -56,6 +57,17 @@ async def test_a_request_before_the_session_manager_runs_is_rejected() -> None:
|
|||
await http.post("/mcp")
|
||||
|
||||
|
||||
async def test_streamable_http_app_applies_the_configured_request_body_limit() -> None:
|
||||
"""The documented `max_request_body_size` option rejects larger requests with HTTP 413."""
|
||||
server = MCPServer("Notes")
|
||||
app = server.streamable_http_app(max_request_body_size=8)
|
||||
transport = httpx2.ASGITransport(app=app)
|
||||
async with server.session_manager.run():
|
||||
async with httpx2.AsyncClient(transport=transport, base_url="http://localhost") as http:
|
||||
response = await http.post("/mcp", content=b"123456789")
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
async def test_mounting_at_the_root_keeps_the_default_path() -> None:
|
||||
"""tutorial002: `Mount("/")` plus the default `streamable_http_path` leaves the endpoint at `/mcp`."""
|
||||
(mount,) = tutorial002.app.routes
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ def test_streamable_http_app_has_no_era_knob() -> None:
|
|||
"stateless_http",
|
||||
"event_store",
|
||||
"retry_interval",
|
||||
"max_request_body_size",
|
||||
"transport_security",
|
||||
"host",
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import Iterator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
|
|
@ -9,7 +10,7 @@ import anyio
|
|||
import httpx2
|
||||
import pytest
|
||||
from mcp_types import INVALID_REQUEST, ListToolsResult, PaginatedRequestParams
|
||||
from starlette.types import Message, Scope
|
||||
from starlette.types import Message, Receive, Scope, Send
|
||||
|
||||
from mcp import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
|
|
@ -17,7 +18,11 @@ from mcp.server import Server, ServerRequestContext, streamable_http_manager
|
|||
from mcp.server.auth.middleware.bearer_auth import AuthenticatedUser
|
||||
from mcp.server.auth.provider import AccessToken
|
||||
from mcp.server.streamable_http import MCP_SESSION_ID_HEADER, StreamableHTTPServerTransport
|
||||
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
|
||||
from mcp.server.streamable_http_manager import (
|
||||
DEFAULT_MAX_REQUEST_BODY_SIZE,
|
||||
RequestBodyLimitMiddleware,
|
||||
StreamableHTTPSessionManager,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
|
|
@ -71,9 +76,9 @@ async def test_handle_request_without_run_raises_error():
|
|||
manager = StreamableHTTPSessionManager(app=app)
|
||||
|
||||
# Mock ASGI parameters
|
||||
scope = {"type": "http", "method": "POST", "path": "/test"}
|
||||
scope: Scope = {"type": "http", "method": "POST", "path": "/test", "headers": []}
|
||||
|
||||
async def receive(): # pragma: no cover
|
||||
async def receive() -> Message:
|
||||
return {"type": "http.request", "body": b""}
|
||||
|
||||
async def send(message: Message): # pragma: no cover
|
||||
|
|
@ -86,6 +91,148 @@ async def test_handle_request_without_run_raises_error():
|
|||
assert "Task group is not initialized. Make sure to use run()." in str(excinfo.value)
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
async def test_oversized_content_length_is_rejected_before_body_read_or_session_creation() -> None:
|
||||
"""SDK-defined: an oversized declared body gets HTTP 413 before the server reads it or creates a session."""
|
||||
manager = StreamableHTTPSessionManager(app=Server("test-size-limit"), max_request_body_size=8)
|
||||
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": "POST",
|
||||
"path": "/mcp",
|
||||
"headers": [(b"content-length", b"9")],
|
||||
}
|
||||
async with manager.run():
|
||||
await manager.handle_request(scope, receive, send)
|
||||
assert manager._server_instances == {}
|
||||
|
||||
response_start = next(message for message in sent_messages if message["type"] == "http.response.start")
|
||||
assert response_start["status"] == 413
|
||||
receive.assert_not_awaited()
|
||||
|
||||
|
||||
@pytest.mark.anyio
|
||||
@pytest.mark.parametrize("headers", [[], [(b"content-length", b"invalid")], [(b"content-length", b"8")]])
|
||||
async def test_oversized_streamed_body_is_rejected_before_session_creation(
|
||||
headers: list[tuple[bytes, bytes]],
|
||||
) -> None:
|
||||
"""SDK-defined: streamed bodies enforce the limit with missing, invalid, or understated length."""
|
||||
manager = StreamableHTTPSessionManager(app=Server("test-streamed-size-limit"), max_request_body_size=8)
|
||||
sent_messages: list[Message] = []
|
||||
request_messages: Iterator[Message] = iter(
|
||||
[
|
||||
{"type": "http.request", "body": b"1234", "more_body": True},
|
||||
{"type": "http.request", "body": b"56789", "more_body": False},
|
||||
]
|
||||
)
|
||||
|
||||
async def receive() -> Message:
|
||||
return next(request_messages)
|
||||
|
||||
async def send(message: Message) -> None:
|
||||
sent_messages.append(message)
|
||||
|
||||
scope: Scope = {"type": "http", "method": "POST", "path": "/mcp", "headers": headers}
|
||||
async with manager.run():
|
||||
await manager.asgi_app(scope, receive, send)
|
||||
assert manager._server_instances == {}
|
||||
|
||||
response_start = next(message for message in sent_messages if message["type"] == "http.response.start")
|
||||
assert response_start["status"] == 413
|
||||
|
||||
|
||||
@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_client_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
|
||||
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},
|
||||
]
|
||||
)
|
||||
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())
|
||||
|
||||
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}]
|
||||
|
||||
|
||||
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"))
|
||||
assert manager.max_request_body_size == DEFAULT_MAX_REQUEST_BODY_SIZE == 4 * 1024 * 1024
|
||||
|
||||
|
||||
@pytest.mark.parametrize("max_request_body_size", [0, -1])
|
||||
def test_request_body_limit_rejects_non_positive_values(max_request_body_size: int) -> None:
|
||||
"""SDK-defined: callers cannot disable request-size protection with a non-positive value."""
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
StreamableHTTPSessionManager(app=Server("test-invalid-size-limit"), max_request_body_size=max_request_body_size)
|
||||
assert str(exc_info.value) == "max_request_body_size must be a positive number of bytes"
|
||||
|
||||
|
||||
class TestException(Exception):
|
||||
__test__ = False # Prevent pytest from collecting this as a test class
|
||||
pass
|
||||
|
|
@ -122,7 +269,7 @@ async def test_stateful_session_cleanup_on_graceful_exit(running_manager: tuple[
|
|||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
|
||||
async def mock_receive(): # pragma: no cover
|
||||
async def mock_receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
# Trigger session creation
|
||||
|
|
@ -180,7 +327,7 @@ async def test_stateful_session_cleanup_on_exception(running_manager: tuple[Stre
|
|||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
|
||||
async def mock_receive(): # pragma: no cover
|
||||
async def mock_receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
# Trigger session creation
|
||||
|
|
@ -298,7 +445,7 @@ async def test_unknown_session_id_returns_404(caplog: pytest.LogCaptureFixture):
|
|||
}
|
||||
|
||||
async def mock_receive():
|
||||
return {"type": "http.request", "body": b"{}", "more_body": False} # pragma: no cover
|
||||
return {"type": "http.request", "body": b"{}", "more_body": False}
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
await manager.handle_request(scope, mock_receive, mock_send)
|
||||
|
|
@ -378,7 +525,7 @@ async def test_idle_session_is_reaped(caplog: pytest.LogCaptureFixture, request:
|
|||
"headers": [(b"content-type", b"application/json")],
|
||||
}
|
||||
|
||||
async def mock_receive(): # pragma: no cover
|
||||
async def mock_receive():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
await manager.handle_request(scope, mock_receive, mock_send)
|
||||
|
|
|
|||
Loading…
Reference in a new issue