Skip to content

Commit c5ec9f7

Browse files
committed
Add coverage tests for the experimental modern HTTP entry
Unit tests for SingleExchangeDispatcher (NoBackChannelError, no-op notify, run() raises) and _SingleExchangeDispatchContext, plus handle_modern_request edge paths (non-POST 405, malformed-body PARSE_ERROR, transport-security rejection, ValidationError mapping). One additional _body_derived_headers case covers the name-absent branch. Claude-Session: https://claude.ai/code/session_017S3aJaxEHeMvftp6whnHWK
1 parent d2db241 commit c5ec9f7

2 files changed

Lines changed: 122 additions & 0 deletions

File tree

tests/client/test_streamable_http.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,10 @@
2929
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={"_meta": _ENVELOPE}),
3030
snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/list"}),
3131
),
32+
(
33+
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/call", params={"_meta": _ENVELOPE}),
34+
snapshot({"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call"}),
35+
),
3236
(
3337
JSONRPCRequest(jsonrpc="2.0", id=3, method="tools/call", params={"name": "add", "arguments": {}}),
3438
snapshot({}),
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
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

Comments
 (0)