chore: import upstream snapshot with attribution
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,352 @@
|
||||
"""Behaviour of the streamable-HTTP client transport itself, observed at the wire.
|
||||
|
||||
These tests connect a real `Client` to a real server over the in-process bridge, recording every
|
||||
HTTP request the SDK client issues, so the assertions are about what the transport sends (headers,
|
||||
methods, ordering) rather than what the protocol layer on top of it returns. The recording is the
|
||||
wire-level instrument; the SDK client never exposes these details.
|
||||
"""
|
||||
|
||||
import json
|
||||
from collections.abc import AsyncIterator
|
||||
|
||||
import anyio
|
||||
import httpx
|
||||
import mcp_types as types
|
||||
import pytest
|
||||
from inline_snapshot import snapshot
|
||||
from mcp_types import INVALID_REQUEST, CallToolResult, ErrorData, ListToolsResult, TextContent, Tool
|
||||
from starlette.types import Receive, Scope, Send
|
||||
|
||||
from mcp import MCPError
|
||||
from mcp.client.client import Client
|
||||
from mcp.client.streamable_http import streamable_http_client
|
||||
from mcp.server import Server, ServerRequestContext
|
||||
from tests.interaction._connect import BASE_URL, NO_DNS_REBINDING_PROTECTION, client_via_http, mounted_app
|
||||
from tests.interaction._requirements import requirement
|
||||
from tests.interaction.transports._bridge import StreamingASGITransport
|
||||
from tests.interaction.transports._event_store import SequencedEventStore
|
||||
|
||||
pytestmark = pytest.mark.anyio
|
||||
|
||||
|
||||
def _tooled_server() -> Server:
|
||||
"""A low-level server with one echo tool, used by every test in this file."""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[Tool(name="echo", description="Echo text.", input_schema={"type": "object"})])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
assert params.name == "echo"
|
||||
assert params.arguments is not None
|
||||
return CallToolResult(content=[TextContent(text=str(params.arguments["text"]))])
|
||||
|
||||
return Server("echoer", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def recorded() -> AsyncIterator[list[httpx.Request]]:
|
||||
"""Connect a `Client` over a recording HTTP client, list tools, exit, and yield every request sent.
|
||||
|
||||
The HTTP client carries one caller-supplied header (`x-trace`) so its propagation can be
|
||||
asserted; the recording captures the closing DELETE because it is read after the `Client` has
|
||||
fully exited.
|
||||
"""
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def record(request: httpx.Request) -> None:
|
||||
requests.append(request)
|
||||
|
||||
async with mounted_app(_tooled_server(), on_request=record, headers={"x-trace": "abc"}) as (http, _):
|
||||
async with client_via_http(http) as client:
|
||||
result = await client.list_tools()
|
||||
assert [tool.name for tool in result.tools] == ["echo"]
|
||||
|
||||
yield requests
|
||||
|
||||
|
||||
def _after_initialize(recorded: list[httpx.Request]) -> list[httpx.Request]:
|
||||
"""Every recorded request after the initialize POST (which carries no session yet)."""
|
||||
assert recorded[0].method == "POST"
|
||||
assert "mcp-session-id" not in recorded[0].headers
|
||||
return recorded[1:]
|
||||
|
||||
|
||||
@requirement("client-transport:http:custom-client")
|
||||
@requirement("client-transport:http:custom-headers")
|
||||
async def test_the_client_uses_the_supplied_http_client_and_propagates_its_headers(
|
||||
recorded: list[httpx.Request],
|
||||
) -> None:
|
||||
"""A caller-supplied `httpx.AsyncClient` is used for every request and carries its own headers.
|
||||
|
||||
The recording itself proves the supplied client is the one in use; the propagated header
|
||||
proves the SDK transport does not replace the caller's client configuration.
|
||||
"""
|
||||
# Exact ordering past the first request is not guaranteed (the standalone GET stream is
|
||||
# scheduled concurrently with later POSTs), so methods are asserted as a multiset.
|
||||
assert sorted(request.method for request in recorded) == snapshot(["DELETE", "GET", "POST", "POST", "POST"])
|
||||
assert all(request.headers["x-trace"] == "abc" for request in recorded)
|
||||
|
||||
|
||||
@requirement("client-transport:http:session-stored")
|
||||
async def test_every_request_after_initialize_carries_the_issued_session_id(recorded: list[httpx.Request]) -> None:
|
||||
"""The session id from the initialize response is sent on every subsequent request."""
|
||||
session_ids = {request.headers["mcp-session-id"] for request in _after_initialize(recorded)}
|
||||
assert len(session_ids) == 1
|
||||
(session_id,) = session_ids
|
||||
assert session_id
|
||||
|
||||
|
||||
@requirement("client-transport:http:protocol-version-stored")
|
||||
@requirement("client-transport:http:protocol-version-header")
|
||||
async def test_every_request_after_initialize_carries_the_negotiated_protocol_version(
|
||||
recorded: list[httpx.Request],
|
||||
) -> None:
|
||||
"""The negotiated protocol version is sent on every subsequent request (and not on initialize)."""
|
||||
assert "mcp-protocol-version" not in recorded[0].headers
|
||||
versions = {request.headers["mcp-protocol-version"] for request in _after_initialize(recorded)}
|
||||
assert versions == snapshot({"2025-11-25"})
|
||||
|
||||
|
||||
@requirement("client-transport:http:accept-header-post")
|
||||
@requirement("client-transport:http:accept-header-get")
|
||||
async def test_accept_headers_cover_the_response_representations_the_transport_handles(
|
||||
recorded: list[httpx.Request],
|
||||
) -> None:
|
||||
"""POSTs accept both JSON and SSE; the standalone GET stream accepts SSE."""
|
||||
for request in recorded:
|
||||
if request.method == "POST":
|
||||
assert "application/json" in request.headers["accept"]
|
||||
assert "text/event-stream" in request.headers["accept"]
|
||||
if request.method == "GET":
|
||||
assert "text/event-stream" in request.headers["accept"]
|
||||
|
||||
|
||||
@requirement("client-transport:http:no-reconnect-after-close")
|
||||
async def test_closing_the_client_sends_delete_and_does_not_reconnect(recorded: list[httpx.Request]) -> None:
|
||||
"""Client teardown sends DELETE and issues no further requests (no resumption GET)."""
|
||||
assert recorded[-1].method == "DELETE"
|
||||
assert all("last-event-id" not in request.headers for request in recorded)
|
||||
|
||||
|
||||
@requirement("client-transport:http:concurrent-streams")
|
||||
async def test_concurrent_tool_calls_each_open_a_post_stream_and_receive_their_own_response() -> None:
|
||||
"""Three tool calls issued at once each open their own POST stream and get the right answer."""
|
||||
requests: list[httpx.Request] = []
|
||||
results: dict[int, CallToolResult] = {}
|
||||
|
||||
async def record(request: httpx.Request) -> None:
|
||||
requests.append(request)
|
||||
|
||||
async with mounted_app(_tooled_server(), on_request=record) as (http, _), client_via_http(http) as client:
|
||||
|
||||
async def call(n: int) -> None:
|
||||
results[n] = await client.call_tool("echo", {"text": str(n)})
|
||||
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
async with anyio.create_task_group() as tg: # pragma: no branch
|
||||
for n in (1, 2, 3):
|
||||
tg.start_soon(call, n)
|
||||
|
||||
assert results == snapshot(
|
||||
{
|
||||
1: CallToolResult(content=[TextContent(text="1")]),
|
||||
2: CallToolResult(content=[TextContent(text="2")]),
|
||||
3: CallToolResult(content=[TextContent(text="3")]),
|
||||
}
|
||||
)
|
||||
tools_call_posts = [r for r in requests if r.method == "POST" and b'"tools/call"' in r.content]
|
||||
assert len(tools_call_posts) == 3
|
||||
|
||||
|
||||
@requirement("client-transport:http:sse-405-tolerated")
|
||||
@requirement("client-transport:http:terminate-405-ok")
|
||||
async def test_client_tolerates_405_on_get_and_delete() -> None:
|
||||
"""A 405 on the standalone GET stream or the closing DELETE does not fail the connection.
|
||||
|
||||
The GET-stream task swallows the failure and schedules a reconnect that the closing cancel
|
||||
interrupts before it ever sleeps the full default delay; the DELETE 405 is logged and ignored.
|
||||
Neither surfaces to the caller.
|
||||
"""
|
||||
server = _tooled_server()
|
||||
real_app = server.streamable_http_app(transport_security=NO_DNS_REBINDING_PROTECTION)
|
||||
|
||||
async def filter_methods(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] == "http" and scope["method"] in ("GET", "DELETE"):
|
||||
await send({"type": "http.response.start", "status": 405, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
return
|
||||
await real_app(scope, receive, send)
|
||||
|
||||
async with (
|
||||
server.session_manager.run(),
|
||||
httpx.AsyncClient(transport=StreamingASGITransport(filter_methods), base_url=BASE_URL) as http_client,
|
||||
):
|
||||
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
async with Client(transport, mode="legacy") as client: # pragma: no branch
|
||||
result = await client.list_tools()
|
||||
|
||||
assert [tool.name for tool in result.tools] == ["echo"]
|
||||
|
||||
|
||||
@requirement("client-transport:http:no-reconnect-after-response")
|
||||
async def test_a_completed_post_stream_is_not_reconnected() -> None:
|
||||
"""A POST stream that delivered its response closes without a resumption GET.
|
||||
|
||||
With an event store the server stamps every SSE event with an ID, so the client transport has a
|
||||
Last-Event-ID it could resume from -- the test proves it does not, because the response arrived
|
||||
and the stream completed normally.
|
||||
"""
|
||||
requests: list[httpx.Request] = []
|
||||
|
||||
async def record(request: httpx.Request) -> None:
|
||||
requests.append(request)
|
||||
|
||||
server = _tooled_server()
|
||||
async with (
|
||||
mounted_app(server, event_store=SequencedEventStore(), retry_interval=0, on_request=record) as (http, _),
|
||||
client_via_http(http) as client,
|
||||
):
|
||||
with anyio.fail_after(5):
|
||||
result = await client.list_tools()
|
||||
|
||||
assert [tool.name for tool in result.tools] == ["echo"]
|
||||
resumption_gets = [r for r in requests if r.method == "GET" and "last-event-id" in r.headers]
|
||||
assert resumption_gets == []
|
||||
|
||||
|
||||
@requirement("client-transport:http:404-surfaces")
|
||||
async def test_a_404_mid_session_surfaces_as_a_session_terminated_error() -> None:
|
||||
"""A 404 in response to a request after initialization is reported to the caller as an MCP error.
|
||||
|
||||
The spec says the client MUST start a new session in this situation; the SDK instead surfaces a
|
||||
`Session terminated` error to the caller. The spec's MUST is tracked at
|
||||
client-transport:http:session-404-reinitialize; this test pins the SDK's current behaviour.
|
||||
"""
|
||||
server = _tooled_server()
|
||||
real_app = server.streamable_http_app(transport_security=NO_DNS_REBINDING_PROTECTION)
|
||||
initialize_seen = anyio.Event()
|
||||
|
||||
async def first_post_then_404(scope: Scope, receive: Receive, send: Send) -> None:
|
||||
if scope["type"] == "http" and scope["method"] == "POST" and initialize_seen.is_set():
|
||||
await send({"type": "http.response.start", "status": 404, "headers": []})
|
||||
await send({"type": "http.response.body", "body": b""})
|
||||
return
|
||||
if scope["type"] == "http" and scope["method"] == "POST":
|
||||
initialize_seen.set()
|
||||
await real_app(scope, receive, send)
|
||||
|
||||
async with (
|
||||
server.session_manager.run(),
|
||||
httpx.AsyncClient(transport=StreamingASGITransport(first_post_then_404), base_url=BASE_URL) as http_client,
|
||||
):
|
||||
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
|
||||
with anyio.fail_after(5): # pragma: no branch
|
||||
async with Client(transport, mode="legacy") as client: # pragma: no branch
|
||||
with pytest.raises(MCPError) as exc_info: # pragma: no branch
|
||||
await client.list_tools()
|
||||
|
||||
assert exc_info.value.error == snapshot(ErrorData(code=INVALID_REQUEST, message="Session terminated"))
|
||||
|
||||
|
||||
def _blocking_server(started: anyio.Event, cancelled: anyio.Event) -> Server:
|
||||
"""A server whose `block` tool parks until cancelled; `echo` answers normally."""
|
||||
|
||||
async def list_tools(ctx: ServerRequestContext, params: types.PaginatedRequestParams | None) -> ListToolsResult:
|
||||
return ListToolsResult(tools=[Tool(name=name, input_schema={"type": "object"}) for name in ("block", "echo")])
|
||||
|
||||
async def call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
|
||||
if params.name == "block":
|
||||
started.set()
|
||||
try:
|
||||
await anyio.Event().wait() # parked until the client's abandonment cancels it
|
||||
except anyio.get_cancelled_exc_class():
|
||||
cancelled.set()
|
||||
raise
|
||||
assert params.name == "echo"
|
||||
return CallToolResult(content=[TextContent(text="ok")])
|
||||
|
||||
return Server("blocker", on_list_tools=list_tools, on_call_tool=call_tool)
|
||||
|
||||
|
||||
@requirement("client-transport:http:cancel-closes-stream")
|
||||
async def test_at_2026_abandoning_a_call_closes_its_stream_and_posts_nothing() -> None:
|
||||
"""At 2026-07-28, abandoning an in-flight call aborts that call's own POST - the server sees
|
||||
the disconnect and cancels exactly that handler - and no notifications/cancelled is POSTed.
|
||||
|
||||
The follow-up echo call bounds the negative: POSTs leave the client's writer serially, so a
|
||||
cancel frame would have to appear before the echo's POST.
|
||||
"""
|
||||
handler_started = anyio.Event()
|
||||
handler_cancelled = anyio.Event()
|
||||
requests: list[tuple[str, bytes]] = []
|
||||
|
||||
async def record(request: httpx.Request) -> None:
|
||||
requests.append((request.method, request.content))
|
||||
|
||||
server = _blocking_server(handler_started, handler_cancelled)
|
||||
async with mounted_app(server, on_request=record) as (http, _):
|
||||
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http)
|
||||
async with Client(transport, mode="2026-07-28") as client:
|
||||
await client.list_tools() # settles the schema cache so the calls below add no refresh POST
|
||||
abandon = anyio.CancelScope()
|
||||
|
||||
async def call_and_abandon() -> None:
|
||||
with abandon:
|
||||
await client.call_tool("block", {})
|
||||
raise NotImplementedError # unreachable: the call never resolves
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(call_and_abandon)
|
||||
with anyio.fail_after(5):
|
||||
await handler_started.wait()
|
||||
abandon.cancel()
|
||||
with anyio.fail_after(5):
|
||||
await handler_cancelled.wait()
|
||||
|
||||
result = await client.call_tool("echo", {})
|
||||
assert result.content == [TextContent(text="ok")]
|
||||
|
||||
wire = [(method, json.loads(body)["method"] if body else None) for method, body in requests]
|
||||
assert wire == snapshot([("POST", "tools/list"), ("POST", "tools/call"), ("POST", "tools/call")])
|
||||
|
||||
|
||||
@requirement("client-transport:http:cancel-posts-frame")
|
||||
async def test_at_2025_abandoning_a_call_posts_exactly_one_cancelled_frame() -> None:
|
||||
"""At 2025-era revisions, abandoning an in-flight call POSTs one notifications/cancelled
|
||||
naming the abandoned request's id - the frame is the legacy HTTP spelling of cancellation,
|
||||
and it interrupts the server-side handler.
|
||||
"""
|
||||
handler_started = anyio.Event()
|
||||
handler_cancelled = anyio.Event()
|
||||
requests: list[tuple[str, bytes]] = []
|
||||
|
||||
async def record(request: httpx.Request) -> None:
|
||||
requests.append((request.method, request.content))
|
||||
|
||||
server = _blocking_server(handler_started, handler_cancelled)
|
||||
async with mounted_app(server, on_request=record) as (http, _):
|
||||
async with client_via_http(http) as client:
|
||||
abandon = anyio.CancelScope()
|
||||
|
||||
async def call_and_abandon() -> None:
|
||||
with abandon:
|
||||
await client.call_tool("block", {})
|
||||
raise NotImplementedError # unreachable: the call never resolves
|
||||
|
||||
async with anyio.create_task_group() as tg:
|
||||
tg.start_soon(call_and_abandon)
|
||||
with anyio.fail_after(5):
|
||||
await handler_started.wait()
|
||||
abandon.cancel()
|
||||
with anyio.fail_after(5):
|
||||
await handler_cancelled.wait()
|
||||
# Let the abandoned call's late error response arrive and be dropped while the
|
||||
# client is still open, so teardown never races its delivery.
|
||||
await anyio.wait_all_tasks_blocked()
|
||||
|
||||
posts = [json.loads(body) for method, body in requests if method == "POST" and body]
|
||||
block_calls = [p for p in posts if p.get("method") == "tools/call" and p["params"]["name"] == "block"]
|
||||
cancels = [p for p in posts if p.get("method") == "notifications/cancelled"]
|
||||
assert len(block_calls) == 1
|
||||
assert [c["params"]["requestId"] for c in cancels] == [block_calls[0]["id"]]
|
||||
Reference in New Issue
Block a user