[v1.x] Apply the request body limit to the SSE and OAuth endpoints (#3344)
This commit is contained in:
parent
ae3338f6b3
commit
4dc224fab2
1 changed files with 426 additions and 161 deletions
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Reference in a new issue