|
| 1 | +"""Unit tests for the experimental 2026-07-28 single-exchange HTTP serving entry. |
| 2 | +
|
| 3 | +The interaction suite under ``tests/interaction/transports/test_hosting_http_modern.py`` pins |
| 4 | +the wire contract end to end; these tests cover the module's internal seams directly -- |
| 5 | +the closed back-channel on the dispatcher and dispatch context, the exception-to-error |
| 6 | +mapping in ``handle()``, and the request-validation ladder in ``handle_modern_request``. |
| 7 | +""" |
| 8 | + |
| 9 | +from collections.abc import Mapping |
| 10 | +from typing import Any |
| 11 | + |
| 12 | +import httpx |
| 13 | +import pytest |
| 14 | +from starlette.requests import Request |
| 15 | +from starlette.types import Receive, Scope, Send |
| 16 | + |
| 17 | +from mcp.server import Server |
| 18 | +from mcp.server._experimental.streamable_http_modern import ( |
| 19 | + SingleExchangeDispatcher, |
| 20 | + _SingleExchangeDispatchContext, |
| 21 | + handle_modern_request, |
| 22 | +) |
| 23 | +from mcp.server.transport_security import TransportSecuritySettings |
| 24 | +from mcp.shared.dispatcher import DispatchContext |
| 25 | +from mcp.shared.exceptions import NoBackChannelError |
| 26 | +from mcp.shared.transport_context import TransportContext |
| 27 | +from mcp.types import INVALID_PARAMS, PARSE_ERROR, JSONRPCError, JSONRPCRequest |
| 28 | + |
| 29 | +pytestmark = pytest.mark.anyio |
| 30 | + |
| 31 | + |
| 32 | +def _request() -> Request: |
| 33 | + return Request({"type": "http", "method": "POST", "headers": []}) |
| 34 | + |
| 35 | + |
| 36 | +async def test_single_exchange_dispatcher_has_no_back_channel_and_is_never_driven() -> None: |
| 37 | + """The dispatcher refuses server-initiated requests, drops notifications, and is not run-driven. |
| 38 | +
|
| 39 | + A 2026-07-28 POST has no channel for the server to push to the client, and ``ServerRunner`` |
| 40 | + never calls ``run()`` on this dispatcher -- ``handle()`` is invoked directly per request. |
| 41 | + """ |
| 42 | + dispatcher = SingleExchangeDispatcher(_request()) |
| 43 | + with pytest.raises(NoBackChannelError): |
| 44 | + await dispatcher.send_raw_request("sampling/createMessage", None) |
| 45 | + assert await dispatcher.notify("notifications/message", None) is None |
| 46 | + |
| 47 | + async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: |
| 48 | + raise AssertionError("unreachable") # pragma: no cover |
| 49 | + |
| 50 | + async def on_notify(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> None: |
| 51 | + raise AssertionError("unreachable") # pragma: no cover |
| 52 | + |
| 53 | + with pytest.raises(RuntimeError, match="never driven"): |
| 54 | + await dispatcher.run(on_request, on_notify) |
| 55 | + |
| 56 | + |
| 57 | +async def test_single_exchange_dispatch_context_has_no_back_channel() -> None: |
| 58 | + """The per-request dispatch context refuses server-initiated requests and drops notify/progress.""" |
| 59 | + dctx = _SingleExchangeDispatchContext( |
| 60 | + transport=TransportContext(kind="streamable-http", can_send_request=False), |
| 61 | + request_id=1, |
| 62 | + message_metadata=None, |
| 63 | + ) |
| 64 | + assert dctx.can_send_request is False |
| 65 | + with pytest.raises(NoBackChannelError): |
| 66 | + await dctx.send_raw_request("roots/list", None) |
| 67 | + assert await dctx.notify("notifications/message", None) is None |
| 68 | + assert await dctx.progress(0.5, total=1.0, message="half") is None |
| 69 | + |
| 70 | + |
| 71 | +async def test_handle_maps_validation_error_to_invalid_params() -> None: |
| 72 | + """A handler raising ``ValidationError`` is mapped to a ``-32602`` JSON-RPC error. |
| 73 | +
|
| 74 | + Mirrors ``JSONRPCDispatcher``'s exception-to-wire boundary: a Pydantic validation failure |
| 75 | + inside the handler becomes ``INVALID_PARAMS`` rather than the generic internal error. |
| 76 | + """ |
| 77 | + |
| 78 | + async def on_request(ctx: DispatchContext[Any], method: str, params: Mapping[str, Any] | None) -> dict[str, Any]: |
| 79 | + JSONRPCRequest.model_validate({}) # raises ValidationError |
| 80 | + raise AssertionError("unreachable") # pragma: no cover |
| 81 | + |
| 82 | + dispatcher = SingleExchangeDispatcher(_request()) |
| 83 | + msg = await dispatcher.handle(JSONRPCRequest(jsonrpc="2.0", id=7, method="tools/call", params={}), on_request) |
| 84 | + assert isinstance(msg, JSONRPCError) |
| 85 | + assert msg.id == 7 |
| 86 | + assert msg.error.code == INVALID_PARAMS |
| 87 | + |
| 88 | + |
| 89 | +def _asgi_client(server: Server[Any], security_settings: TransportSecuritySettings | None = None) -> httpx.AsyncClient: |
| 90 | + async def app(scope: Scope, receive: Receive, send: Send) -> None: |
| 91 | + await handle_modern_request(server, security_settings, scope, receive, send) |
| 92 | + |
| 93 | + return httpx.AsyncClient(transport=httpx.ASGITransport(app=app), base_url="http://testserver") |
| 94 | + |
| 95 | + |
| 96 | +async def test_handle_modern_request_rejects_non_post_with_405() -> None: |
| 97 | + """A GET on the 2026-07-28 entry is answered with 405 before any body is read.""" |
| 98 | + async with _asgi_client(Server("test")) as http: |
| 99 | + response = await http.get("/mcp") |
| 100 | + assert response.status_code == 405 |
| 101 | + |
| 102 | + |
| 103 | +async def test_handle_modern_request_rejects_malformed_body_with_parse_error() -> None: |
| 104 | + """A POST whose body is not a valid ``JSONRPCRequest`` returns 400 with ``-32700``.""" |
| 105 | + async with _asgi_client(Server("test")) as http: |
| 106 | + response = await http.post("/mcp", content=b"not json", headers={"content-type": "application/json"}) |
| 107 | + assert response.status_code == 400 |
| 108 | + assert response.headers["content-type"].split(";", 1)[0] == "application/json" |
| 109 | + assert response.json() == {"jsonrpc": "2.0", "error": {"code": PARSE_ERROR, "message": "Parse error"}} |
| 110 | + |
| 111 | + |
| 112 | +async def test_handle_modern_request_returns_transport_security_error_response() -> None: |
| 113 | + """The transport-security middleware's error response is sent verbatim and short-circuits.""" |
| 114 | + settings = TransportSecuritySettings(enable_dns_rebinding_protection=True, allowed_hosts=["good.example"]) |
| 115 | + async with _asgi_client(Server("test"), security_settings=settings) as http: |
| 116 | + response = await http.post("/mcp", json={}, headers={"content-type": "application/json"}) |
| 117 | + assert response.status_code == 421 |
| 118 | + assert response.text == "Invalid Host header" |
0 commit comments