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) Waiting to run

This commit is contained in:
wehub-resource-sync
2026-07-13 12:10:27 +08:00
commit 49b9bb6724
992 changed files with 161690 additions and 0 deletions
+9
View File
@@ -0,0 +1,9 @@
"""Transport-specific interaction tests, and the in-process streaming bridge they are built on.
`StreamingASGITransport` is re-exported here as the sanctioned import point for test code
outside this suite (the bridge module itself is suite-private).
"""
from tests.interaction.transports._bridge import StreamingASGITransport
__all__ = ["StreamingASGITransport"]
+172
View File
@@ -0,0 +1,172 @@
"""An in-process, full-duplex HTTP transport for driving ASGI applications from httpx.
`httpx.ASGITransport` runs the application to completion and only then hands the buffered
response to the caller, so a server that streams its response — the streamable HTTP transport's
SSE responses — can never converse with the client mid-request: a server-initiated request
nested inside a still-open call deadlocks. `StreamingASGITransport` removes that limitation by
running the application as a background task and forwarding every `http.response.body` chunk to
the client the moment it is sent. Everything happens on the one event loop: no sockets, no
threads, no sleeps, no extra dependencies.
The behavioural contract, pinned by `test_bridge.py`:
- The request body is buffered before the application is invoked (MCP requests are small JSON
documents); the response streams chunk by chunk.
- Closing the response — or the whole client — delivers `http.disconnect` to the application,
exactly as a real server sees when its peer goes away.
- An exception the application raises before sending `http.response.start` fails the originating
request with that same exception. After the response has started, a failure is visible to the
client only through the response itself (status code, truncated body) — the same signal a real
server over a real socket would give.
The transport owns an anyio task group for the application tasks; it is opened and closed by
`httpx.AsyncClient`'s own context manager, so use the client as a context manager (the suite
always does). Closing the transport cancels every running application task by default; set
`cancel_on_close=False` to wait for the application's own disconnect handling instead.
"""
import math
from collections.abc import AsyncIterator
from types import TracebackType
import anyio
import anyio.abc
import httpx
from anyio.streams.memory import MemoryObjectReceiveStream
from starlette.types import ASGIApp, Message, Scope
from mcp.shared._compat import resync_tracer
class _StreamingResponseBody(httpx.AsyncByteStream):
"""A response body that yields chunks as the application produces them.
Closing it tells the application the client has gone away (`http.disconnect`), mirroring a
peer that drops the connection mid-response.
"""
def __init__(self, chunks: MemoryObjectReceiveStream[bytes], client_disconnected: anyio.Event) -> None:
self._chunks = chunks
self._client_disconnected = client_disconnected
async def __aiter__(self) -> AsyncIterator[bytes]:
async for chunk in self._chunks:
yield chunk
async def aclose(self) -> None:
self._client_disconnected.set()
await self._chunks.aclose()
class StreamingASGITransport(httpx.AsyncBaseTransport):
"""Drive an ASGI application in-process, streaming each response as it is produced.
With `cancel_on_close` (the default), closing the transport cancels every application task
still running so harness teardown can never hang. Setting it to False makes the transport wait
for the application's own disconnect handling to complete instead, which is the path the legacy
SSE server transport relies on for resource cleanup.
"""
_task_group: anyio.abc.TaskGroup
def __init__(self, app: ASGIApp, *, cancel_on_close: bool = True) -> None:
self._app = app
self._cancel_on_close = cancel_on_close
async def __aenter__(self) -> "StreamingASGITransport":
self._task_group = anyio.create_task_group()
await self._task_group.__aenter__()
return self
async def __aexit__(
self,
exc_type: type[BaseException] | None = None,
exc_value: BaseException | None = None,
traceback: TracebackType | None = None,
) -> None:
# httpx closes every streamed response before closing the transport, so by now each
# application task has been delivered `http.disconnect`. Either cancel immediately, or wait
# for the application's own disconnect handling to unwind.
if self._cancel_on_close:
self._task_group.cancel_scope.cancel()
await self._task_group.__aexit__(exc_type, exc_value, traceback)
await resync_tracer()
async def handle_async_request(self, request: httpx.Request) -> httpx.Response:
assert isinstance(request.stream, httpx.AsyncByteStream)
request_body = b"".join([chunk async for chunk in request.stream])
scope: Scope = {
"type": "http",
"asgi": {"version": "3.0"},
"http_version": "1.1",
"method": request.method,
"scheme": request.url.scheme,
"path": request.url.path,
"raw_path": request.url.raw_path.split(b"?", maxsplit=1)[0],
"query_string": request.url.query,
"root_path": "",
"headers": [(name.lower(), value) for name, value in request.headers.raw],
"server": (request.url.host, request.url.port),
"client": ("127.0.0.1", 1234),
}
request_delivered = False
client_disconnected = anyio.Event()
response_started = anyio.Event()
response_status = 0
response_headers: list[tuple[bytes, bytes]] = []
application_error: Exception | None = None
chunk_writer, chunk_reader = anyio.create_memory_object_stream[bytes](math.inf)
async def receive_request() -> Message:
nonlocal request_delivered
if not request_delivered:
request_delivered = True
return {"type": "http.request", "body": request_body, "more_body": False}
await client_disconnected.wait()
return {"type": "http.disconnect"}
async def send_response(message: Message) -> None:
nonlocal response_status, response_headers
if message["type"] == "http.response.start":
response_status = message["status"]
response_headers = list(message.get("headers", []))
response_started.set()
return
assert message["type"] == "http.response.body"
body: bytes = message.get("body", b"")
if body:
await chunk_writer.send(body)
if not message.get("more_body", False):
await chunk_writer.aclose()
async def run_application() -> None:
nonlocal application_error
try:
await self._app(scope, receive_request, send_response)
except Exception as exc: # The bridge is the application's outermost boundary: a crash
# must fail the originating request (or show up in the already-started response),
# never tear down the task group shared with every other in-flight request.
application_error = exc
finally:
response_started.set()
await chunk_writer.aclose()
self._task_group.start_soon(run_application)
try:
await response_started.wait()
if application_error is not None:
raise application_error
except BaseException:
# No response will be built, so close the reader the response body would have owned
# and tell the application its peer has gone away.
client_disconnected.set()
await chunk_reader.aclose()
raise
return httpx.Response(
status_code=response_status,
headers=response_headers,
stream=_StreamingResponseBody(chunk_reader, client_disconnected),
request=request,
)
@@ -0,0 +1,55 @@
"""A predictable event store for resumability tests.
The SDK's `EventStore` interface lets a streamable-HTTP server stamp every SSE event with an ID
and replay missed events when a client reconnects with `Last-Event-ID`. This implementation
issues sequential integer IDs starting at "1" so tests can assert exact IDs (the example store
uses uuid4, which cannot be snapshotted) and is small enough that every line is exercised by the
resumability tests themselves.
"""
import anyio
from mcp_types import JSONRPCMessage
from mcp.server.streamable_http import EventCallback, EventId, EventMessage, EventStore, StreamId
class SequencedEventStore(EventStore):
"""Stores every event in order and replays the same-stream tail after a given ID."""
def __init__(self) -> None:
self._events: list[tuple[StreamId, JSONRPCMessage | None]] = []
self._milestones: dict[int, anyio.Event] = {}
async def store_event(self, stream_id: StreamId, message: JSONRPCMessage | None) -> EventId:
self._events.append((stream_id, message))
count = len(self._events)
milestone = self._milestones.pop(count, None)
if milestone is not None:
milestone.set()
return str(count)
async def wait_until_stored(self, count: int) -> None:
"""Block until at least `count` events have been stored.
Tests use this to wait for the server's message router (which runs in another task) to
finish storing a known set of events before issuing a replay, so the replay's content is
deterministic rather than depending on task scheduling order.
"""
if len(self._events) >= count:
return
milestone = self._milestones.setdefault(count, anyio.Event())
await milestone.wait()
async def replay_events_after(self, last_event_id: EventId, send_callback: EventCallback) -> StreamId | None:
try:
cursor = int(last_event_id)
except ValueError:
return None
if not 0 < cursor <= len(self._events):
return None
stream_id, _ = self._events[cursor - 1]
for index in range(cursor, len(self._events)):
event_stream_id, message = self._events[index]
if event_stream_id == stream_id and message is not None:
await send_callback(EventMessage(message, str(index + 1)))
return stream_id
@@ -0,0 +1,90 @@
"""A real low-level Server over the stdio transport, for the suite's one subprocess test.
Runnable as `python -m tests.interaction.transports._stdio_server` from the repo root; the test
launches it that way via `stdio_client`. Kept separate from the test module so the server lives in
its own importable file (subprocess coverage applies) while the test file follows the suite's
test-only-functions convention.
"""
import sys
import warnings
import anyio
import coverage
from mcp_types import (
CallToolRequestParams,
CallToolResult,
EmptyResult,
ListToolsResult,
PaginatedRequestParams,
SetLevelRequestParams,
TextContent,
Tool,
)
from mcp.server import Server, ServerRequestContext
from mcp.server.stdio import stdio_server
from mcp.shared.exceptions import MCPDeprecationWarning
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(
tools=[
Tool(
name="echo",
input_schema={"type": "object", "properties": {"text": {"type": "string"}}, "required": ["text"]},
)
]
)
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
assert params.arguments is not None
text = params.arguments["text"]
with warnings.catch_warnings():
warnings.simplefilter("ignore", MCPDeprecationWarning)
await ctx.session.send_log_message(level="info", data=f"echoing {text}", logger="echo") # pyright: ignore[reportDeprecated]
return CallToolResult(content=[TextContent(text=text)])
async def set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
with warnings.catch_warnings():
warnings.simplefilter("ignore", MCPDeprecationWarning)
server = Server( # pyright: ignore[reportDeprecated]
"stdio-echo", on_list_tools=list_tools, on_call_tool=call_tool, on_set_logging_level=set_logging_level
)
async def main() -> None:
async with stdio_server() as (read_stream, write_stream):
await server.run(read_stream, write_stream, server.create_initialization_options())
# Flush this process's coverage data before the clean-exit line below. Without this, the
# data is only written by coverage's atexit hook during interpreter teardown -- and on a
# slow Windows runner that can overrun the transport's termination grace, so the kill
# silently destroys the data file and the 100% gate trips on this module's subprocess-only
# lines. Saving here puts the write before the line the test synchronizes on: once the
# parent has seen "clean exit", the data is durably on disk and the escalation is harmless.
# Nothing measured may execute after the save (it would be unrecordable by construction),
# hence the excluded lines below. The branch is pragma'd because under coverage the
# instance always exists, and without coverage nothing is measured anyway.
cov = getattr(coverage.process_startup, "coverage", None)
if cov is not None: # pragma: no branch
# stop() is load-bearing twice over: it ends tracing, making itself the last
# recordable line, and it leaves nothing new for coverage's atexit re-save to flush --
# so a kill landing during interpreter teardown cannot corrupt the file save() wrote
# (coverage opens it with sqlite journaling off; a torn rewrite would not roll back).
cov.stop()
cov.save() # pragma: lax no cover - untraced: stop() above already ended measurement
# Reached only when the run loop exits because stdin closed; if the process were terminated
# the test's stderr capture would not see this line. lax no cover: runs after the coverage
# save by design, so it can never appear covered.
print("stdio-echo: clean exit", file=sys.stderr, flush=True) # pragma: lax no cover
if __name__ == "__main__":
anyio.run(main)
@@ -0,0 +1,94 @@
"""Contract tests for the suite's streaming ASGI bridge.
These pin what `StreamingASGITransport` itself guarantees — chunk-by-chunk delivery, disconnect
propagation, and failure handling — against minimal hand-written ASGI applications, so the MCP
transport tests built on top of it never have to wonder what the harness provides. They are
harness self-tests, not interaction-model tests, and are exempted from the requirement-coverage
contract in `test_coverage.py`.
"""
import anyio
import httpx
import pytest
from starlette.types import Message, Receive, Scope, Send
from tests.interaction.transports._bridge import StreamingASGITransport
pytestmark = pytest.mark.anyio
async def test_response_chunks_arrive_as_the_application_sends_them() -> None:
"""Each body chunk is delivered as sent, empty chunks are skipped, and the stream ends with the application."""
async def chunked_app(scope: Scope, receive: Receive, send: Send) -> None:
assert scope["type"] == "http"
assert (await receive())["type"] == "http.request"
await send({"type": "http.response.start", "status": 200, "headers": [(b"content-type", b"text/plain")]})
await send({"type": "http.response.body", "body": b"first", "more_body": True})
await send({"type": "http.response.body", "body": b"", "more_body": True})
await send({"type": "http.response.body", "body": b"second", "more_body": False})
async with (
httpx.AsyncClient(transport=StreamingASGITransport(chunked_app), base_url="http://bridge") as http,
http.stream("GET", "/chunks") as response,
):
with anyio.fail_after(5):
chunks = [chunk async for chunk in response.aiter_raw()]
assert response.status_code == 200
assert response.headers["content-type"] == "text/plain"
assert chunks == [b"first", b"second"]
async def test_closing_the_response_delivers_a_disconnect_to_the_application() -> None:
"""A client that closes the response early is seen by the application as an http.disconnect."""
seen_after_request: list[Message] = []
disconnect_seen = anyio.Event()
async def waiting_app(scope: Scope, receive: Receive, send: Send) -> None:
assert scope["type"] == "http"
assert (await receive())["type"] == "http.request"
await send({"type": "http.response.start", "status": 200, "headers": []})
seen_after_request.append(await receive())
disconnect_seen.set()
async with httpx.AsyncClient(transport=StreamingASGITransport(waiting_app), base_url="http://bridge") as http:
async with http.stream("GET", "/wait") as response:
assert response.status_code == 200
# Leaving the stream block closes the response while the application is still mid-response.
with anyio.fail_after(5):
await disconnect_seen.wait()
assert seen_after_request == [{"type": "http.disconnect"}]
async def test_an_application_failure_before_the_response_starts_fails_the_request() -> None:
"""An exception raised before http.response.start reaches the caller as that same exception."""
async def broken_app(scope: Scope, receive: Receive, send: Send) -> None:
raise RuntimeError("the demo application is broken")
async with httpx.AsyncClient(transport=StreamingASGITransport(broken_app), base_url="http://bridge") as http:
with pytest.raises(RuntimeError, match="the demo application is broken"):
await http.get("/broken")
async def test_disabling_cancel_on_close_lets_the_application_finish_after_disconnect() -> None:
"""With cancel_on_close=False, an application that runs cleanup after seeing http.disconnect
completes that cleanup before the transport finishes closing."""
cleanup_ran = anyio.Event()
async def lingering_app(scope: Scope, receive: Receive, send: Send) -> None:
assert scope["type"] == "http"
await receive()
await send({"type": "http.response.start", "status": 200, "headers": []})
assert (await receive())["type"] == "http.disconnect"
cleanup_ran.set()
transport = StreamingASGITransport(lingering_app, cancel_on_close=False)
with anyio.fail_after(5):
async with httpx.AsyncClient(transport=transport, base_url="http://bridge") as http:
async with http.stream("GET", "/linger") as response:
assert response.status_code == 200
assert not cleanup_ran.is_set()
assert cleanup_ran.is_set()
@@ -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"]]
+129
View File
@@ -0,0 +1,129 @@
"""Transport-level composed flows: multi-client isolation, reconnection, and dual-transport hosting.
These scenarios are about how the transport layer holds together across more than one connection
or more than one transport, so they connect real `Client`s against one mounted server rather than
running over the matrix.
"""
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import CallToolResult, LoggingMessageNotificationParams, TextContent
from mcp.client.session import LoggingFnT
from mcp.server.mcpserver import Context, MCPServer
from tests.interaction._connect import client_via_http, connect_over_sse, mounted_app
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
@requirement("flow:multi-client:stateful-isolation")
async def test_concurrent_clients_on_one_stateful_server_receive_only_their_own_notifications() -> None:
"""Two clients on one stateful manager each receive only the notifications their own request produced.
Complements `test_terminating_one_session_leaves_others_working` (which proves session
independence under termination) with the notification-isolation dimension: a notification
emitted by one session's handler does not leak to another session's client.
"""
mcp = MCPServer("multi")
@mcp.tool()
async def announce(label: str, ctx: Context) -> str:
"""Emit one info-level log carrying the caller's label, then return it."""
await ctx.info(label) # pyright: ignore[reportDeprecated]
return label
received_a: list[object] = []
received_b: list[object] = []
async def collect_a(params: LoggingMessageNotificationParams) -> None:
received_a.append(params.data)
async def collect_b(params: LoggingMessageNotificationParams) -> None:
received_b.append(params.data)
async with mounted_app(mcp) as (http, _):
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def call(label: str, collect: LoggingFnT) -> None:
async with client_via_http(http, logging_callback=collect) as client:
await client.call_tool("announce", {"label": label})
tg.start_soon(call, "a", collect_a)
tg.start_soon(call, "b", collect_b)
assert received_a == ["a"]
assert received_b == ["b"]
@requirement("flow:session:terminate-then-reconnect")
async def test_a_fresh_connection_after_termination_obtains_a_new_session_and_operates() -> None:
"""After a client terminates, a fresh connection to the same manager gets a distinct session.
Steps: (1) connect a client and call list_tools, (2) the client exits (its DELETE fires),
(3) connect a second client to the same mounted app, (4) the second client's call_tool
succeeds and the recorded session ids show two distinct sessions were issued.
"""
mcp = MCPServer("reconnectable")
@mcp.tool()
def echo(text: str) -> str:
"""Return the input unchanged."""
return text
session_ids: list[str] = []
async def record(request: httpx.Request) -> None:
session_id = request.headers.get("mcp-session-id")
if session_id is not None:
session_ids.append(session_id)
async with mounted_app(mcp, on_request=record) as (http, _):
async with client_via_http(http) as first:
first_result = await first.list_tools()
async with client_via_http(http) as second:
second_result = await second.call_tool("echo", {"text": "again"})
assert {tool.name for tool in first_result.tools} == {"echo"}
assert second_result == snapshot(
CallToolResult(content=[TextContent(text="again")], structured_content={"result": "again"})
)
distinct = set(session_ids)
assert len(distinct) == 2, f"expected two distinct session ids across the two connections, saw {distinct}"
@requirement("flow:compat:dual-transport-server")
async def test_one_server_serves_streamable_http_and_sse_clients_concurrently() -> None:
"""One MCPServer instance serves a streamable-HTTP client and a legacy-SSE client at the same time.
The two transports have independent connection management (the streamable-HTTP session manager
versus a per-connection SSE handler), but both dispatch into the same server's request
handlers. The test connects one client over each transport against the same instance and
proves both reach the same tool. Uses MCPServer because the low-level Server has no SSE
convenience; the entry is about hosting composition, not the low-level API.
"""
mcp = MCPServer("dual")
@mcp.tool()
def echo(text: str) -> str:
"""Return the input unchanged."""
return text
async with (
mounted_app(mcp) as (http, _),
connect_over_sse(mcp) as sse_client,
client_via_http(http) as shttp_client,
):
with anyio.fail_after(5):
shttp_result = await shttp_client.call_tool("echo", {"text": "via http"})
sse_result = await sse_client.call_tool("echo", {"text": "via sse"})
assert shttp_result == snapshot(
CallToolResult(content=[TextContent(text="via http")], structured_content={"result": "via http"})
)
assert sse_result == snapshot(
CallToolResult(content=[TextContent(text="via sse")], structured_content={"result": "via sse"})
)
@@ -0,0 +1,381 @@
"""Streamable HTTP semantics: status codes, header validation, message routing, and security.
These tests speak HTTP directly to the server's mounted ASGI app via the in-process bridge,
asserting the wire contract -- which status code answers which condition, which stream a message
travels on -- that the SDK client never exposes. Transport-agnostic behaviour is covered by the
`connect`-fixture matrix.
"""
import anyio
import pytest
from anyio.lowlevel import checkpoint
from httpx_sse import ServerSentEvent, aconnect_sse
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_PARAMS,
PARSE_ERROR,
PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolRequestParams,
CallToolResult,
EmptyResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ListResourcesResult,
ListToolsResult,
PaginatedRequestParams,
SetLevelRequestParams,
SubscribeRequestParams,
TextContent,
)
from mcp_types.version import MODERN_PROTOCOL_VERSIONS
from mcp.server import Server, ServerRequestContext
from mcp.server.transport_security import TransportSecuritySettings
from tests.interaction._connect import (
base_headers,
initialize_body,
initialize_via_http,
mounted_app,
parse_sse_messages,
)
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _server() -> Server:
"""A low-level server with one tool that emits a related and an unrelated notification."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
"""Registered only so the tools capability is advertised; never called."""
raise NotImplementedError
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "narrate"
await ctx.session.send_log_message(level="info", data="related", logger=None, related_request_id=ctx.request_id) # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///watched.txt")
return CallToolResult(content=[TextContent(text="done")])
async def set_logging_level(ctx: ServerRequestContext, params: SetLevelRequestParams) -> EmptyResult:
"""Registered so the logging capability is advertised; the client never sets a level."""
raise NotImplementedError
async def list_resources(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListResourcesResult:
"""Registered so the resources capability is advertised; the client never lists resources."""
raise NotImplementedError
async def subscribe_resource(ctx: ServerRequestContext, params: SubscribeRequestParams) -> EmptyResult:
"""Registered so the resources subscribe sub-capability is advertised; the client never subscribes."""
raise NotImplementedError
return Server( # pyright: ignore[reportDeprecated]
"hosted",
on_list_tools=list_tools,
on_call_tool=call_tool,
on_set_logging_level=set_logging_level,
on_list_resources=list_resources,
on_subscribe_resource=subscribe_resource,
)
@requirement("hosting:http:method-405")
async def test_unsupported_http_methods_return_405() -> None:
"""PUT and PATCH on the MCP endpoint return 405 with an Allow header naming the supported methods."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
put = await http.put("/mcp", json={}, headers=base_headers(session_id=session_id))
patch = await http.patch("/mcp", json={}, headers=base_headers(session_id=session_id))
assert (put.status_code, put.headers.get("allow")) == snapshot((405, "GET, POST, DELETE"))
assert (patch.status_code, patch.headers.get("allow")) == snapshot((405, "GET, POST, DELETE"))
@requirement("hosting:http:accept-406")
async def test_missing_accept_media_types_return_406() -> None:
"""A POST whose Accept header lacks both required types, or a GET lacking text/event-stream, returns 406."""
async with mounted_app(_server()) as (http, _):
post = await http.post(
"/mcp", json=initialize_body(), headers={"accept": "text/plain", "mcp-protocol-version": "2025-11-25"}
)
session_id = await initialize_via_http(http)
get = await http.get(
"/mcp",
headers={"accept": "application/json", "mcp-protocol-version": "2025-11-25", "mcp-session-id": session_id},
)
assert (post.status_code, post.json()["error"]["message"]) == snapshot(
(406, "Not Acceptable: Client must accept both application/json and text/event-stream")
)
assert (get.status_code, get.json()["error"]["message"]) == snapshot(
(406, "Not Acceptable: Client must accept text/event-stream")
)
@requirement("hosting:http:content-type-415")
async def test_non_json_content_type_is_rejected() -> None:
"""A POST with a non-JSON Content-Type is rejected before reaching the transport.
See the divergence on the requirement: the security middleware rejects with 400, so the
transport's own 415 path is unreachable through any public entry point.
"""
async with mounted_app(_server()) as (http, _):
response = await http.post(
"/mcp", content=b"<not-json/>", headers=base_headers() | {"content-type": "text/plain"}
)
assert (response.status_code, response.text) == snapshot((400, "Invalid Content-Type header"))
@requirement("hosting:http:parse-error-400")
@requirement("hosting:http:batch")
async def test_malformed_and_batched_bodies_return_400() -> None:
"""A non-JSON body returns 400 Parse error; a JSON array of requests returns 400 Invalid params."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
not_json = await http.post(
"/mcp",
content=b"this is not json",
headers=base_headers(session_id=session_id) | {"content-type": "application/json"},
)
batched = await http.post(
"/mcp",
json=[
{"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
{"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
],
headers=base_headers(session_id=session_id),
)
assert not_json.status_code == 400
assert JSONRPCError.model_validate_json(not_json.text).error.code == PARSE_ERROR
assert batched.status_code == 400
assert JSONRPCError.model_validate_json(batched.text).error.code == INVALID_PARAMS
@requirement("hosting:http:protocol-version-400")
@requirement("hosting:http:protocol-version-default")
async def test_protocol_version_header_is_validated() -> None:
"""An unsupported MCP-Protocol-Version header returns 400; an absent header is accepted as the default.
An unrecognised header value routes to the modern entry (which owns rejection of unknown
versions), and a request without the per-request envelope is rejected at the first ladder
rung. Only known initialize-handshake versions and an absent header reach the legacy path.
"""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
bad = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "1991-01-01"},
)
# Only Accept and the session ID -- no MCP-Protocol-Version header at all.
defaulted = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": 0, "progress": 1}},
headers={"accept": "application/json, text/event-stream", "mcp-session-id": session_id},
)
assert bad.status_code == 400
assert JSONRPCError.model_validate_json(bad.text).error.code == INVALID_PARAMS
# 202 proves the request was accepted under the assumed default version (2025-03-26).
assert defaulted.status_code == 202
@requirement("hosting:http:protocol-version-rejection-literal")
async def test_unsupported_protocol_version_rejection_body_contains_the_sniffed_literal() -> None:
"""The 400 body for an unsupported MCP-Protocol-Version contains the substring peer SDKs sniff.
SDK-defined: other SDKs detect this rejection by substring-matching ``Unsupported protocol
version`` in the response body, so the literal must survive any rewording of the surrounding
message. The unsupported value must appear in both the header and the envelope so the
classifier reaches its version-supported rung rather than reporting a header mismatch first.
"""
bad = "1991-01-01"
meta = {
PROTOCOL_VERSION_META_KEY: bad,
CLIENT_INFO_META_KEY: {"name": "t", "version": "0"},
CLIENT_CAPABILITIES_META_KEY: {},
}
async with mounted_app(_server()) as (http, _):
response = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list", "params": {"_meta": meta}},
headers=base_headers() | {"mcp-protocol-version": bad, "mcp-method": "tools/list"},
)
assert response.status_code == 400
error = JSONRPCError.model_validate_json(response.text).error
assert error.code == UNSUPPORTED_PROTOCOL_VERSION
assert "Unsupported protocol version" in response.text
assert error.data == {"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": bad}
@requirement("hosting:http:json-response-mode")
async def test_json_response_mode_answers_with_application_json_not_sse() -> None:
"""With JSON response mode enabled, request POSTs are answered with a single application/json body.
Asserted at the wire level because the SDK client parses either representation, so a
Client-driven round trip cannot distinguish a JSON response from an SSE one.
"""
async with mounted_app(_server(), json_response=True) as (http, _):
initialized = await http.post("/mcp", json=initialize_body(), headers=base_headers())
session_id = initialized.headers["mcp-session-id"]
ping = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
headers=base_headers(session_id=session_id),
)
assert initialized.status_code == 200
assert initialized.headers["content-type"].split(";", 1)[0] == "application/json"
assert JSONRPCResponse.model_validate(initialized.json()).id == 1
assert ping.status_code == 200
assert ping.headers["content-type"].split(";", 1)[0] == "application/json"
assert JSONRPCResponse.model_validate(ping.json()).id == 2
@requirement("hosting:http:notifications-202")
async def test_notification_post_returns_202_with_no_body() -> None:
"""A POST containing only a notification (no request ID) returns 202 Accepted with no body."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
response = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "method": "notifications/progress", "params": {"progressToken": 0, "progress": 1}},
headers=base_headers(session_id=session_id),
)
assert (response.status_code, response.content) == snapshot((202, b""))
@requirement("hosting:http:second-sse-rejected")
async def test_a_second_standalone_get_stream_on_the_same_session_returns_409() -> None:
"""Opening a second standalone GET SSE stream while one is already established returns 409 Conflict."""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as first:
assert first.response.status_code == 200
# The standalone-stream writer registers its key as its first action, then parks
# awaiting messages; one yield to the loop lets that registration complete before the
# second GET is dispatched.
await checkpoint()
second = await http.get("/mcp", headers=base_headers(session_id=session_id))
assert (second.status_code, second.json()["error"]["message"]) == snapshot(
(409, "Conflict: Only one SSE stream is allowed per session")
)
@requirement("hosting:http:standalone-sse")
@requirement("hosting:http:standalone-sse-no-response")
@requirement("hosting:http:response-same-connection")
@requirement("hosting:http:sse-close-after-response")
@requirement("hosting:http:no-broadcast")
async def test_messages_are_routed_to_exactly_one_stream() -> None:
"""Each server message travels on exactly one SSE stream and is never broadcast.
A streamable-HTTP session has two kinds of server-to-client SSE stream: one short-lived stream
per POST request, carrying that request's response and any notifications related to it, and one
long-lived standalone stream (opened by GET) for notifications not tied to any request. The
spec's routing rule is that the POST stream delivers the response (and its related
notifications) and then closes, the standalone stream carries only unrelated notifications and
never a JSON-RPC response, and no message appears on both. The test opens both streams, calls a
tool whose handler emits one related and one unrelated notification, and asserts each message's
routing.
"""
async with mounted_app(_server()) as (http, _):
session_id = await initialize_via_http(http)
post_events: list[ServerSentEvent] = []
get_events: list[ServerSentEvent] = []
async def read_standalone_stream() -> None:
async with aconnect_sse(http, "GET", "/mcp", headers=base_headers(session_id=session_id)) as get:
assert get.response.status_code == 200
standalone_ready.set()
async for event in get.aiter_sse():
get_events.append(event)
seen_on_standalone.set()
standalone_ready = anyio.Event()
seen_on_standalone = anyio.Event()
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(read_standalone_stream)
await standalone_ready.wait()
params = CallToolRequestParams(name="narrate", arguments={})
body = JSONRPCRequest(jsonrpc="2.0", id=5, method="tools/call", params=params.model_dump())
async with aconnect_sse(
http,
"POST",
"/mcp",
json=body.model_dump(by_alias=True, exclude_none=True),
headers=base_headers(session_id=session_id),
) as post:
assert post.response.status_code == 200
# The POST stream iterator ends when the server closes the stream after the response.
post_events = [event async for event in post.aiter_sse()]
await seen_on_standalone.wait()
tg.cancel_scope.cancel()
post_messages = parse_sse_messages(post_events)
get_messages = parse_sse_messages(get_events)
# POST stream: the related log notification, then the response, then the iterator ends (close).
assert [type(m).__name__ for m in post_messages] == snapshot(["JSONRPCNotification", "JSONRPCResponse"])
assert isinstance(post_messages[0], JSONRPCNotification)
assert (post_messages[0].method, post_messages[0].params) == snapshot(
("notifications/message", {"level": "info", "data": "related"})
)
assert isinstance(post_messages[1], JSONRPCResponse)
assert post_messages[1].id == 5
# Standalone stream: only the unrelated resource-updated notification, never a response.
assert [type(m).__name__ for m in get_messages] == snapshot(["JSONRPCNotification"])
assert isinstance(get_messages[0], JSONRPCNotification)
assert get_messages[0].method == snapshot("notifications/resources/updated")
@requirement("hosting:http:dns-rebinding")
@requirement("transport:streamable-http:origin-validation")
async def test_origin_validation_rejects_disallowed_origins_when_enabled() -> None:
"""A disallowed Origin returns 403 (and Host 421) with protection enabled; disabled lets both through.
See the divergence on hosting:http:dns-rebinding: the spec's Origin validation is an
unconditional MUST, but the SDK enables it only when the host is localhost (or settings are
passed explicitly) and additionally checks the Host header (returning 421), which the spec
does not require.
"""
# transport_security=None triggers the localhost auto-enable behaviour.
async with mounted_app(Server("guarded"), transport_security=None) as (http, _):
bad_origin = await http.post(
"/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"}
)
bad_host = await http.post("/mcp", json=initialize_body(), headers=base_headers() | {"host": "evil.example"})
async with aconnect_sse(
http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://127.0.0.1:8000"}
) as ok:
assert ok.response.status_code == 200
assert [event async for event in ok.aiter_sse()]
assert (bad_origin.status_code, bad_origin.text) == snapshot((403, "Invalid Origin header"))
assert (bad_host.status_code, bad_host.text) == snapshot((421, "Invalid Host header"))
async with mounted_app(
Server("unguarded"), transport_security=TransportSecuritySettings(enable_dns_rebinding_protection=False)
) as (http, _):
async with aconnect_sse(
http, "POST", "/mcp", json=initialize_body(), headers=base_headers() | {"origin": "http://evil.example"}
) as unguarded:
status = unguarded.response.status_code
assert [event async for event in unguarded.aiter_sse()]
assert status == 200
@@ -0,0 +1,616 @@
"""Streamable HTTP at protocol version 2026-07-28: the single-exchange stateless serving entry.
These tests speak HTTP directly to the server's mounted ASGI app via the in-process bridge,
asserting the wire contract for a 2026-07-28 POST -- one self-contained request, no initialize
handshake, no ``Mcp-Session-Id``, JSON response body -- and that 2025-era traffic on the same
endpoint is byte-unchanged. The SDK client never exposes the response headers or the raw
result-envelope shape, so every assertion here is necessarily wire-level.
"""
import json
from collections.abc import Callable
from typing import Any, Literal
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CLIENT_CAPABILITIES_META_KEY,
HEADER_MISMATCH,
INTERNAL_ERROR,
INVALID_PARAMS,
METHOD_NOT_FOUND,
MISSING_REQUIRED_CLIENT_CAPABILITY,
CallToolRequestParams,
CallToolResult,
DiscoverResult,
EmptyResult,
Implementation,
JSONRPCError,
JSONRPCResponse,
ListToolsResult,
PaginatedRequestParams,
Request,
RequestParams,
Result,
ServerCapabilities,
TextContent,
Tool,
)
from mcp_types.version import LATEST_MODERN_VERSION
from mcp import MCPError
from mcp.client.client import Client
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import BASE_URL, base_headers, initialize_via_http, mounted_app
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _modern_headers(*, method: str, name: str | None = None) -> dict[str, str]:
"""Request headers for a 2026-07-28 POST.
The Accept/Content-Type baseline plus the ``MCP-Protocol-Version`` routing header and the
``Mcp-Method`` / ``Mcp-Name`` advisory headers a 2026-era client always sends.
"""
headers = base_headers() | {"mcp-protocol-version": LATEST_MODERN_VERSION, "mcp-method": method}
if name is not None:
headers["mcp-name"] = name
return headers
def _meta_envelope() -> dict[str, object]:
"""The per-request ``_meta`` envelope a 2026-07-28 client stamps on every request.
Replaces the 2025-era initialize handshake: protocol version, client info, and client
capabilities travel on each request instead of once per session.
"""
return {
"io.modelcontextprotocol/protocolVersion": LATEST_MODERN_VERSION,
"io.modelcontextprotocol/clientInfo": {"name": "raw", "version": "0.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
def _server(*, on_meta: Callable[[dict[str, Any]], None] | None = None) -> Server:
"""A low-level server with one ``add`` tool for the raw-httpx tests below."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
tool = Tool(name="add", input_schema={"type": "object"})
return ListToolsResult(tools=[tool], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "add"
assert params.arguments is not None
if on_meta is not None:
assert ctx.meta is not None
on_meta(dict(ctx.meta))
return CallToolResult(content=[TextContent(text=str(params.arguments["a"] + params.arguments["b"]))])
return Server("modern", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("hosting:http:modern:tools-call-stateless")
async def test_modern_tools_call_returns_result_type_complete_without_initialize() -> None:
"""A 2026-07-28 tools/call is served without an initialize handshake and returns resultType: complete.
Spec-mandated under the draft transport: the per-request ``_meta`` envelope replaces initialize,
and ``resultType`` is the 2026 result-envelope discriminator (``complete`` for the monolith
result). Asserted at the wire because the SDK client never surfaces ``resultType`` and because
the absence of any prior request on the connection is the assertion.
"""
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()},
}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="add"))
assert response.status_code == 200
assert response.headers["content-type"].split(";", 1)[0] == "application/json"
parsed = JSONRPCResponse.model_validate(response.json())
assert parsed.id == 1
assert parsed.result == snapshot(
{"content": [{"text": "5", "type": "text"}], "isError": False, "resultType": "complete"}
)
@requirement("hosting:http:modern:no-session-id")
async def test_modern_response_carries_no_session_id_header() -> None:
"""A 2026-07-28 response never sets ``Mcp-Session-Id``.
Spec-mandated under the draft transport: the 2026-07-28 exchange is sessionless by definition,
so the header that the 2025-era transport always sets on responses must be absent. Asserted at
the wire because the SDK client never exposes response headers.
"""
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "add", "arguments": {"a": 2, "b": 3}, "_meta": _meta_envelope()},
}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="add"))
assert response.status_code == 200
assert "mcp-session-id" not in response.headers
@requirement("hosting:http:modern:initialize-removed")
async def test_modern_initialize_is_method_not_found() -> None:
"""A 2026-07-28 initialize request that carries a valid envelope is answered METHOD_NOT_FOUND at HTTP 404.
Spec-mandated under the draft: initialize is not a defined method at 2026-07-28, so the kernel's
method/version gate rejects it before any handler runs. The body must carry the per-request
``_meta`` envelope so the classifier ladder admits it as far as kernel dispatch -- without the
envelope the request is INVALID_PARAMS at rung 1, never METHOD_NOT_FOUND. Asserted at the wire
because the SDK client at 2026-07-28 never sends initialize, so only a raw POST can drive the
negative.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "initialize", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="initialize"))
assert response.status_code == 404
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
@requirement("hosting:http:modern:legacy-fallthrough")
async def test_legacy_version_header_falls_through_and_unrecognised_header_routes_to_modern() -> None:
"""SDK-defined under the draft versioning rules: only the known initialize-handshake protocol
versions reach the legacy transport, so a 2025-era ``initialize`` on the same endpoint still
completes unchanged. Any other ``MCP-Protocol-Version`` value routes to the modern entry,
where the validation ladder rejects it (a request without the per-request envelope fails the
first rung). The modern entry is therefore the single owner of unknown-version rejection.
"""
async with mounted_app(_server()) as (http, _):
# 2025-era initialize through the same endpoint: the modern branch must not intercept it.
session_id = await initialize_via_http(http)
unrecognised = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "ping"},
headers=base_headers(session_id=session_id) | {"mcp-protocol-version": "9999-01-01"},
)
assert unrecognised.status_code == 400
assert JSONRPCError.model_validate_json(unrecognised.text).error.code == INVALID_PARAMS
@requirement("hosting:http:modern:handler-exception-internal-error")
async def test_modern_handler_exception_maps_to_internal_error_without_leaking_the_message() -> None:
"""A handler exception on the 2026-07-28 path returns -32603 with a generic message.
Spec-mandated for the code: -32603 is the JSON-RPC Internal error code. SDK-defined for the
message: the 2026-07-28 entry deliberately does not echo ``str(exc)`` (the legacy dispatcher's
code-0 leak is the recorded divergence on ``protocol:error:internal-error``). Asserted at the
wire because the SDK client surfaces only the error object, not the HTTP status it travelled on.
"""
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "boom"
raise RuntimeError("kaboom")
body = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {"name": "boom", "arguments": {}, "_meta": _meta_envelope()},
}
async with mounted_app(Server("modern", on_call_tool=call_tool)) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/call", name="boom"))
assert response.status_code == 200
error = JSONRPCError.model_validate(response.json()).error
assert error.code == INTERNAL_ERROR
assert "kaboom" not in error.message
@requirement("hosting:http:modern:discover-response-shape")
async def test_modern_server_discover_returns_capabilities_and_supported_versions() -> None:
"""A 2026-07-28 server/discover POST returns capabilities, serverInfo, and supportedVersions.
Spec-mandated under the draft: server/discover is the 2026 advertisement method that replaces
the initialize-response payload, and ``supportedVersions`` is the field a client picks its
per-request envelope version from. Asserted at the wire because the SDK client never exposes
the raw result body.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "server/discover", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="server/discover"))
assert response.status_code == 200
result = JSONRPCResponse.model_validate(response.json()).result
assert result["supportedVersions"] == snapshot(["2026-07-28"])
assert result["serverInfo"]["name"] == "modern"
assert "capabilities" in result
@requirement("hosting:http:modern:removed-method-status-404")
async def test_modern_removed_method_is_method_not_found_at_http_404() -> None:
"""A 2026-07-28 ping (removed at 2026) is answered METHOD_NOT_FOUND and the HTTP status is 404.
Spec-mandated for the error code: ping is not a defined method at 2026-07-28 so the kernel's
method/version gate rejects it. SDK-defined for the HTTP status: kernel-origin METHOD_NOT_FOUND
travels through the same error-code-to-status table as classifier-origin errors. Asserted at the
wire because the HTTP status is the assertion.
"""
body = {"jsonrpc": "2.0", "id": 1, "method": "ping", "params": {"_meta": _meta_envelope()}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="ping"))
assert response.status_code == 404
assert JSONRPCError.model_validate(response.json()).error.code == METHOD_NOT_FOUND
@requirement("hosting:http:modern:envelope-missing-key-status-400")
async def test_modern_envelope_missing_required_meta_key_is_invalid_params_at_http_400() -> None:
"""A 2026-07-28 request whose ``_meta`` envelope omits a required key is INVALID_PARAMS at HTTP 400.
Spec-mandated under the draft transport: the per-request envelope must carry every reserved key,
so a missing ``clientCapabilities`` fails the classifier's first rung before any kernel dispatch.
Asserted at the wire because the HTTP status is the assertion.
"""
incomplete = _meta_envelope()
del incomplete[CLIENT_CAPABILITIES_META_KEY]
body = {"jsonrpc": "2.0", "id": 1, "method": "tools/list", "params": {"_meta": incomplete}}
async with mounted_app(_server()) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="tools/list"))
assert response.status_code == 400
assert JSONRPCError.model_validate(response.json()).error.code == INVALID_PARAMS
@requirement("hosting:http:modern:handler-error-status-via-table")
async def test_modern_handler_raised_mcperror_maps_to_status_via_error_code_table() -> None:
"""A handler-raised ``MCPError`` reaches the wire as a top-level JSON-RPC error at the table-mapped HTTP status.
SDK-defined for the HTTP status: the modern entry maps every JSON-RPC ``error.code`` -- whether
classifier-origin or handler-origin -- through one error-code-to-status table, so a handler
raising ``MISSING_REQUIRED_CLIENT_CAPABILITY`` produces HTTP 400 with ``error.data`` preserved.
Spec-mandated for the error code: the named code and its ``requiredCapabilities`` data shape are
the spec's capability-gating contract. Registered via the low-level ``add_request_handler`` so
the high-level tool wrapper's error-swallowing is not on the path.
"""
async def cap_check(ctx: ServerRequestContext, params: RequestParams) -> EmptyResult:
raise MCPError(
code=MISSING_REQUIRED_CLIENT_CAPABILITY,
message="sampling required",
data={"requiredCapabilities": ["sampling"]},
)
server = _server()
server.add_request_handler("test/cap-check", RequestParams, cap_check)
body = {"jsonrpc": "2.0", "id": 1, "method": "test/cap-check", "params": {"_meta": _meta_envelope()}}
async with mounted_app(server) as (http, _):
response = await http.post("/mcp", json=body, headers=_modern_headers(method="test/cap-check"))
assert response.status_code == 400
error = JSONRPCError.model_validate(response.json()).error
assert error.code == MISSING_REQUIRED_CLIENT_CAPABILITY
assert error.data == {"requiredCapabilities": ["sampling"]}
@requirement("hosting:http:modern:tools-call-stateless")
@requirement("lifecycle:stateless:request-envelope")
@requirement("lifecycle:stateless:caller-meta-preserved")
@requirement("client-transport:http:body-derived-headers")
async def test_pinned_client_stateless_tools_call_round_trips_against_the_modern_entry() -> None:
"""First end-to-end exercise of the 2026-07-28 stateless request style: SDK client to SDK server.
Spec-mandated under the draft stateless transport: the pinned ``ClientSession`` and the
single-exchange serving entry compose so that ``call_tool`` returns ``resultType: complete``
with no ``initialize`` ever sent, no ``Mcp-Session-Id`` on any request or response, and every
POST carrying the body-derived ``MCP-Protocol-Version`` / ``Mcp-Method`` / ``Mcp-Name`` headers
plus the three-key ``io.modelcontextprotocol/*`` ``_meta`` envelope. The caller passes a
``custom-key`` under ``meta=`` and the server handler captures the incoming ``ctx.meta``,
proving the envelope merge is additive: the caller's key sits alongside the three envelope keys
on the wire and inside the handler. Asserted at the wire via the ``mounted_app`` httpx event
hooks because none of the headers, the envelope, or the handshake-absence is observable through
the public client API. The recorded log shows two POSTs: the ``tools/call`` itself and the
client's implicit ``tools/list`` output-schema fetch (see ``client:output-schema:auto-list``),
both of which must satisfy the stateless contract.
"""
observed_metas: list[dict[str, Any]] = []
server = _server(on_meta=observed_metas.append)
requests: list[httpx.Request] = []
responses: list[httpx.Response] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
async def on_response(response: httpx.Response) -> None:
responses.append(response)
client_info = Implementation(name="e2e-client", version="1.0.0")
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request, on_response=on_response) as (http, _),
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (read, write),
ClientSession(read, write, client_info=client_info) as session,
):
session.adopt(
DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
)
result = await session.call_tool(
"add",
{"a": 2, "b": 3},
meta={"custom-key": "x", "io.modelcontextprotocol/protocolVersion": "evil"},
)
assert result.model_dump(by_alias=True, mode="json", exclude_none=True) == snapshot(
{"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"}
)
# Exactly the tools/call POST and the implicit tools/list POST -- no initialize, no
# notifications/initialized, no standalone GET stream, no closing DELETE.
bodies = [json.loads(r.content) for r in requests]
assert [(r.method, body["method"]) for r, body in zip(requests, bodies, strict=True)] == snapshot(
[("POST", "tools/call"), ("POST", "tools/list")]
)
assert all("initialize" not in body["method"] for body in bodies)
# The tools/call POST carries the body-derived headers, and its _meta envelope overwrites the
# caller's colliding io.modelcontextprotocol/* key while preserving the non-colliding caller key.
call = requests[0]
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-")} == snapshot(
{"mcp-protocol-version": "2026-07-28", "mcp-method": "tools/call", "mcp-name": "add"}
)
assert bodies[0]["params"]["_meta"] == snapshot(
{
"custom-key": "x",
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
)
# The implicit tools/list carries the envelope but no caller meta: proves the envelope is
# stamped on every request, not just on requests where the caller passed meta=.
assert bodies[1]["params"]["_meta"] == snapshot(
{
"io.modelcontextprotocol/protocolVersion": "2026-07-28",
"io.modelcontextprotocol/clientInfo": {"name": "e2e-client", "version": "1.0.0"},
"io.modelcontextprotocol/clientCapabilities": {},
}
)
# The server handler observed the same merged _meta on ctx.meta.
assert observed_metas == [bodies[0]["params"]["_meta"]]
# No session id on any request or response: the exchange is sessionless end to end.
assert len(responses) == len(requests)
assert all("mcp-session-id" not in r.headers for r in requests)
assert all("mcp-session-id" not in r.headers for r in responses)
_CUSTOM_HEADER_TOOL = Tool(
name="run",
input_schema={
"type": "object",
"properties": {
"region": {"type": "string", "x-mcp-header": "Region"},
"priority": {"type": "integer", "x-mcp-header": "Priority"},
"verbose": {"type": "boolean", "x-mcp-header": "Verbose"},
"note": {"type": "string", "x-mcp-header": "Note"},
"query": {"type": "string"},
},
"required": ["region"],
},
)
def _custom_header_server() -> Server:
"""A server with one tool whose schema annotates four args with `x-mcp-header` and leaves `query` plain."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[_CUSTOM_HEADER_TOOL], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])
return Server("custom-headers", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_mirrors_x_mcp_header_args_into_mcp_param_headers() -> None:
"""A tools/call mirrors the tool's `x-mcp-header` arguments into `Mcp-Param-*` headers.
After `list_tools` caches the tool's annotations, the client renders each annotated argument into
its header per the spec's Value Encoding rules: `region` verbatim, `priority` as a decimal, `verbose`
as `false`, and the non-ASCII `note` base64-sentinel-wrapped. The unannotated `query` and the omitted
`verbose`-sibling stay out of the headers, and every mirrored value remains in the request body. Asserted
at the wire because the client never surfaces the outgoing headers.
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
await client.list_tools()
await client.call_tool("run", {"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"})
call = next(r for r in requests if json.loads(r.content)["method"] == "tools/call")
assert {k: v for k, v in call.headers.items() if k.startswith("mcp-param-")} == snapshot(
{
"mcp-param-region": "us-west1",
"mcp-param-priority": "42",
"mcp-param-verbose": "false",
"mcp-param-note": "=?base64?aMOpbGxv?=",
}
)
# Mirroring is additive: the arguments are unchanged in the body.
assert json.loads(call.content)["params"]["arguments"] == snapshot(
{"region": "us-west1", "priority": 42, "verbose": False, "note": "héllo"}
)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_emits_no_param_headers_for_an_unlisted_tool() -> None:
"""A `tools/call` for a tool the client never listed carries no `Mcp-Param-*` headers.
The spec lets a client that lacks the tool's `inputSchema` send the request without custom headers.
The call is made with no prior `list_tools`, so the first `tools/call` POST -- captured before the
implicit output-schema `list_tools` runs -- has no cached annotations and emits no `Mcp-Param-*` header.
The server validates `Mcp-Param-*` against its own catalog and rejects as the spec's scenario table
requires for an omitted header (the relist-and-retry recovery is a SHOULD the client does not implement yet).
"""
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(_custom_header_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
with pytest.raises(MCPError) as excinfo: # pragma: no branch
await client.call_tool("run", {"region": "us-west1"})
assert excinfo.value.error.code == HEADER_MISMATCH
assert len(requests) == 1
assert json.loads(requests[0].content)["method"] == "tools/call"
assert not any(k.startswith("mcp-param-") for k in requests[0].headers)
@requirement("client-transport:http:custom-param-headers")
async def test_modern_client_stops_mirroring_after_a_re_list_drops_the_tool() -> None:
"""A re-list that drops a previously valid tool stops mirroring its `x-mcp-header` args.
The tool is first listed with a valid annotation (so a call mirrors `Mcp-Param-Region`), then re-listed
with an invalid annotation -- the modern client drops it and evicts the cached map, so a later `tools/call`
by name carries no `Mcp-Param-*` header. Asserted at the wire, where the eviction is observable.
"""
schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "Region"}}}
bad_schema = {"type": "object", "properties": {"a": {"type": "string", "x-mcp-header": "bad name"}}}
valid = Tool(name="run", input_schema=schema)
invalid = Tool(name="run", input_schema=bad_schema)
# First listing valid, every later one invalid; the count is not pinned because the server also
# reads its own catalog on each tools/call.
listings: list[None] = []
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
listings.append(None)
return ListToolsResult(tools=[valid if len(listings) == 1 else invalid], ttl_ms=0, cache_scope="public")
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
return CallToolResult(content=[TextContent(text="ok")])
server = Server("evict", on_list_tools=list_tools, on_call_tool=call_tool)
tool_calls: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
if json.loads(request.content)["method"] == "tools/call":
tool_calls.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
assert [t.name for t in (await client.list_tools()).tools] == ["run"]
await client.call_tool("run", {"a": "x"})
assert [t.name for t in (await client.list_tools()).tools] == []
await client.call_tool("run", {"a": "x"})
before, after = tool_calls
assert before.headers.get("mcp-param-region") == "x"
assert not any(k.startswith("mcp-param-") for k in after.headers)
class _JobParams(RequestParams):
job_id: str
class _JobStatusRequest(Request[_JobParams, Literal["com.example/jobs.status"]]):
method: Literal["com.example/jobs.status"] = "com.example/jobs.status"
name_param = "jobId"
class _JobStatusResult(Result):
status: str
@requirement("client-transport:http:vendor-name-param-header")
async def test_vendor_request_with_name_param_carries_mcp_name_on_the_wire() -> None:
"""`send_request` mirrors an unregistered vendor request's `name_param` value into the
`Mcp-Name` header while the body keeps the params key unchanged."""
async def job_status(ctx: ServerRequestContext, params: _JobParams) -> _JobStatusResult:
assert params.job_id == "job-7"
return _JobStatusResult(status="running")
server = _server()
server.add_request_handler("com.example/jobs.status", _JobParams, job_status)
requests: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
requests.append(request)
discover = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(),
server_info=Implementation(name="srv", version="0"),
)
with anyio.fail_after(5):
async with (
mounted_app(server, on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=discover,
) as client,
):
request = _JobStatusRequest(params=_JobParams(job_id="job-7"))
result = await client.session.send_request(request, _JobStatusResult)
assert result.status == "running"
[wire_request] = requests
assert wire_request.headers["mcp-name"] == "job-7"
assert json.loads(wire_request.content)["params"]["jobId"] == "job-7"
@@ -0,0 +1,449 @@
"""Resumability over the streamable HTTP transport, exercised entirely in process.
These tests configure the server with an event store, so every SSE event is stamped with an ID
and a client that loses its connection can resume by sending `Last-Event-ID`. The wire-level
tests (`mounted_app` + raw httpx) assert exactly what travels on the wire; the end-to-end test
drives the SDK client through a server-initiated stream close and proves the call still
completes. The bridge's `aclose()` delivers `http.disconnect` to the running application, so
closing a streaming response mid-read is a deterministic in-process disconnect -- no sockets,
no real time. Every server here uses `retry_interval=0` so reconnection waits are no-ops.
"""
import json
import anyio
import httpx
import pytest
from httpx_sse import EventSource, ServerSentEvent
from inline_snapshot import snapshot
from mcp_types import (
CallToolRequest,
CallToolRequestParams,
CallToolResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
LoggingMessageNotificationParams,
TextContent,
jsonrpc_message_adapter,
)
from mcp_types.version import LATEST_HANDSHAKE_VERSION
from mcp.client.session import ClientSession
from mcp.client.streamable_http import streamable_http_client
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.message import ClientMessageMetadata
from tests.interaction._connect import (
BASE_URL,
base_headers,
connect_over_streamable_http,
initialize_via_http,
mounted_app,
parse_sse_messages,
)
from tests.interaction._requirements import requirement
from tests.interaction.transports._event_store import SequencedEventStore
pytestmark = pytest.mark.anyio
def _counting_server() -> MCPServer:
"""A server with one tool that emits related notifications and one unrelated notification."""
mcp = MCPServer("resumable")
@mcp.tool()
async def count(ctx: Context, n: int) -> str:
"""Emit n log notifications related to this call, plus one unrelated resource update."""
for i in range(1, n + 1):
await ctx.info(f"tick {i}") # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///elsewhere.txt")
return f"counted to {n}"
return mcp
def _tools_call(request_id: int, name: str, arguments: dict[str, object]) -> str:
"""A serialized tools/call JSON-RPC request body."""
return JSONRPCRequest(
jsonrpc="2.0", id=request_id, method="tools/call", params={"name": name, "arguments": arguments}
).model_dump_json(by_alias=True, exclude_none=True)
async def _read_events(response: httpx.Response, count: int) -> list[ServerSentEvent]:
"""Read exactly `count` SSE events from a streaming response without closing it."""
source = EventSource(response).aiter_sse()
return [await anext(source) for _ in range(count)]
@requirement("hosting:resume:event-ids")
@requirement("hosting:resume:priming")
async def test_a_post_sse_stream_begins_with_a_priming_event_and_stamps_every_event() -> None:
"""A request's SSE stream opens with a priming event (id, empty data, retry) then stamps each message."""
async with mounted_app(_counting_server(), event_store=SequencedEventStore(), retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream( # pragma: no branch
"POST", "/mcp", content=_tools_call(1, "count", {"n": 2}), headers=base_headers(session_id=session_id)
) as response:
assert response.status_code == 200
events = await _read_events(response, 4)
priming, first, second, result = events
# The priming event is the only event a client could have seen before any work happened, so it
# is the resumption anchor: it carries an ID and empty data. The SDK attaches the retry hint
# to this event (see the divergence on hosting:resume:priming).
assert (priming.id, priming.data, priming.retry) == snapshot(("3", "", 0))
assert priming.event == snapshot("message")
# Every subsequent event carries an event-store ID; the related notifications and the response
# all ride this stream and close it after the response.
assert [event.id for event in (first, second, result)] == snapshot(["4", "5", "7"])
assert [json.loads(event.data)["method"] for event in (first, second)] == snapshot(
["notifications/message", "notifications/message"]
)
assert jsonrpc_message_adapter.validate_json(result.data) == snapshot(
JSONRPCResponse(
jsonrpc="2.0",
id=1,
result={
"content": [{"type": "text", "text": "counted to 2"}],
"structuredContent": {"result": "counted to 2"},
"isError": False,
},
)
)
@requirement("hosting:resume:priming")
async def test_the_priming_row_is_stored_before_any_handler_output_for_that_stream() -> None:
"""The priming cursor is the first row the event store records for a request's stream.
The POST handler stores the priming row before dispatching the request, so by construction
it precedes anything `message_router` can store for that stream id.
"""
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def burst(ctx: Context) -> str:
await ctx.info("a") # pyright: ignore[reportDeprecated]
await ctx.info("b") # pyright: ignore[reportDeprecated]
await ctx.info("c") # pyright: ignore[reportDeprecated]
return "done"
async with mounted_app(mcp, event_store=store) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream( # pragma: no branch
"POST", "/mcp", content=_tools_call(2, "burst", {}), headers=base_headers(session_id=session_id)
) as response:
await _read_events(response, 5)
# initialize wrote two rows (its own priming + response); everything after is this call.
call_rows = store._events[2:]
stream_id = call_rows[0][0]
assert [(s, None if m is None else type(m).__name__) for s, m in call_rows] == [
(stream_id, None),
(stream_id, "JSONRPCNotification"),
(stream_id, "JSONRPCNotification"),
(stream_id, "JSONRPCNotification"),
(stream_id, "JSONRPCResponse"),
]
@requirement("hosting:resume:replay")
@requirement("hosting:resume:stream-scoped")
@requirement("hosting:resume:buffered-replay")
async def test_get_with_last_event_id_replays_only_that_streams_missed_events() -> None:
"""Reconnecting with Last-Event-ID returns the missed events from that one stream, in order.
The handler also emits an unrelated notification (which the server stores under the
standalone-stream key); replay must not return it, proving replay is scoped to the stream
the given event ID belongs to.
Steps: (1) initialize; (2) POST a tool call and read events until the first notification is
captured; (3) close the response mid-stream -- the bridge delivers `http.disconnect`, the
handler keeps running; (4) release the handler so it emits the remaining messages, which the
server buffers in the event store; (5) wait on the event store for the handler's response to
be stored, so the replay's content is independent of task scheduling; (6) GET with
`Last-Event-ID` and assert the replay is exactly the missed events from this request's stream.
"""
release = anyio.Event()
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def count(ctx: Context) -> str:
"""Emit one related notification, wait for the test, then emit two more plus an unrelated one."""
await ctx.info("tick 1") # pyright: ignore[reportDeprecated]
await release.wait()
await ctx.info("tick 2") # pyright: ignore[reportDeprecated]
await ctx.info("tick 3") # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///elsewhere.txt")
return "counted"
async with mounted_app(mcp, event_store=store, retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream(
"POST", "/mcp", content=_tools_call(1, "count", {}), headers=base_headers(session_id=session_id)
) as response:
# Read the priming event and the first notification, then drop the connection.
priming, first = await _read_events(response, 2)
assert (priming.id, first.id) == snapshot(("3", "4"))
last_seen = first.id
release.set()
# The handler keeps running after the disconnect; its remaining messages are stored.
# The first wait returns immediately (the priming and first tick are already stored);
# the second blocks until the response itself is stored so the replay content is fixed.
await store.wait_until_stored(4)
await store.wait_until_stored(8)
replay_headers = base_headers(session_id=session_id) | {"last-event-id": last_seen}
async with http.stream("GET", "/mcp", headers=replay_headers) as replay: # pragma: no branch
assert replay.status_code == 200
missed = await _read_events(replay, 3)
decoded = parse_sse_messages(missed)
# Exactly the two remaining related notifications and the response, with their original IDs.
assert [event.id for event in missed] == snapshot(["5", "6", "8"])
assert [type(message).__name__ for message in decoded] == snapshot(
["JSONRPCNotification", "JSONRPCNotification", "JSONRPCResponse"]
)
assert isinstance(decoded[2], JSONRPCResponse)
assert decoded[2].id == 1
# The unrelated resource-updated notification was stored under the standalone-stream key, not
# this request's stream, so it must not appear in the replay.
assert all(
not (isinstance(message, JSONRPCNotification) and message.method == "notifications/resources/updated")
for message in decoded
)
@requirement("hosting:resume:priming")
async def test_a_pre_2025_11_25_reconnect_replays_without_minting_a_priming_event() -> None:
"""A pre-2025-11-25 client reconnecting via Last-Event-ID gets the replay with no priming row.
The store-length assertion is the load-bearing proof that no priming cursor was minted.
"""
release = anyio.Event()
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def count(ctx: Context) -> str:
await ctx.info("tick 1") # pyright: ignore[reportDeprecated]
await release.wait()
await ctx.info("tick 2") # pyright: ignore[reportDeprecated]
return "counted"
async with mounted_app(mcp, event_store=store, retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream(
"POST", "/mcp", content=_tools_call(1, "count", {}), headers=base_headers(session_id=session_id)
) as response:
_, first = await _read_events(response, 2)
release.set()
await store.wait_until_stored(6)
old_client_headers = base_headers(session_id=session_id) | {
"mcp-protocol-version": "2025-06-18",
"last-event-id": first.id,
}
async with http.stream("GET", "/mcp", headers=old_client_headers) as replay: # pragma: no branch
assert replay.status_code == 200
missed = await _read_events(replay, 2)
assert [(event.id, bool(event.data)) for event in missed] == snapshot([("5", True), ("6", True)])
# No priming cursor was minted on reconnect: the store still holds only the six rows
# written before the GET (init priming+response, POST priming, tick 1, tick 2, result).
assert len(store._events) == 6
@requirement("hosting:resume:bad-event-id")
async def test_an_unknown_last_event_id_yields_an_empty_replay_stream() -> None:
"""A Last-Event-ID the event store cannot map produces an empty SSE stream rather than an error.
See the divergence on hosting:resume:bad-event-id: this pins current behaviour.
"""
async with mounted_app(_counting_server(), event_store=SequencedEventStore(), retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
for unknown in ("no-such-event", "0"):
headers = base_headers(session_id=session_id) | {"last-event-id": unknown}
async with http.stream("GET", "/mcp", headers=headers) as replay:
assert replay.status_code == 200
assert replay.headers["content-type"].startswith("text/event-stream")
events = [event async for event in EventSource(replay).aiter_sse()]
assert events == []
@requirement("hosting:http:disconnect-not-cancel")
async def test_dropping_the_connection_mid_request_does_not_cancel_the_handler() -> None:
"""Closing the request's SSE connection while the handler is running leaves the handler running.
The handler signals when it has started and when it has finished; the test drops the
connection in between and then releases the handler. If the disconnect cancelled the handler,
`finished` would never be set and the test would time out.
"""
started = anyio.Event()
release = anyio.Event()
finished = anyio.Event()
mcp = MCPServer("resumable")
@mcp.tool()
async def hold(ctx: Context) -> str:
"""Signal start, wait for the test, signal completion."""
started.set()
await release.wait()
await ctx.info("released") # pyright: ignore[reportDeprecated]
finished.set()
return "held"
async with mounted_app(mcp, event_store=SequencedEventStore(), retry_interval=0) as (http, _):
session_id = await initialize_via_http(http)
with anyio.fail_after(5):
async with http.stream(
"POST", "/mcp", content=_tools_call(1, "hold", {}), headers=base_headers(session_id=session_id)
) as response:
await _read_events(response, 1)
await started.wait()
assert not finished.is_set()
release.set()
await finished.wait()
# This test intentionally carries every automatic-reconnection requirement: the
# close-then-resume scenario is indivisible, so splitting it would mean five near-identical bodies.
@requirement("hosting:resume:close-stream")
@requirement("transport:streamable-http:resumability")
@requirement("client-transport:http:reconnect-post-priming")
@requirement("client-transport:http:reconnect-retry-value")
@requirement("flow:resume:tool-call-resumption-token")
async def test_a_call_whose_stream_the_server_closes_is_resumed_by_the_client() -> None:
"""A server-closed request stream is reconnected by the client and the call completes.
The handler emits one notification, closes its own SSE stream, then (once released) emits
another and returns. The client observed the priming event (so it has a Last-Event-ID and a
retry hint of 0ms), sees the stream end, reconnects via GET with Last-Event-ID, and receives
the post-close notification and the result over the replay stream. The shared events make the
test deterministic: the handler only proceeds once the test knows the first notification has
arrived (and so the client's reconnection has begun).
"""
received: list[object] = []
before_seen = anyio.Event()
gate = anyio.Event()
done = anyio.Event()
mcp = MCPServer("resumable")
@mcp.tool()
async def interrupt(ctx: Context) -> str:
"""Emit, close this call's SSE stream, then emit again after the test releases the gate."""
await ctx.info("before close") # pyright: ignore[reportDeprecated]
await ctx.close_sse_stream()
await gate.wait()
await ctx.info("after close") # pyright: ignore[reportDeprecated]
done.set()
return "resumed"
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params.data)
if params.data == "before close":
before_seen.set()
result: list[CallToolResult] = []
async with connect_over_streamable_http(
mcp, event_store=SequencedEventStore(), retry_interval=0, logging_callback=collect
) as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
async def call() -> None:
result.append(await client.call_tool("interrupt", {}))
tg.start_soon(call)
await before_seen.wait()
gate.set()
await done.wait()
assert result == snapshot(
[CallToolResult(content=[TextContent(text="resumed")], structured_content={"result": "resumed"})]
)
assert received == snapshot(["before close", "after close"])
@requirement("client-transport:http:resume-stream-api")
async def test_a_captured_resumption_token_replays_missed_messages_on_a_new_connection() -> None:
"""A resumption token captured via on_resumption_token_update on one connection lets a fresh
connection retrieve the messages it missed by passing resumption_token to send_request.
This is the explicit ClientMessageMetadata API, distinct from the automatic reconnection the
previous test covers: the transport dispatches a resumption_token request as a GET with
Last-Event-ID instead of POSTing the body, and remaps the replayed response onto the new
request's id. Client.call_tool does not expose ClientMessageMetadata, so the test drives a
bare ClientSession via session.send_request -- the sanctioned drop-down for behaviour Client
cannot express. The second connection carries the original session id but does not initialize
(the server-side session already is), modelling a caller that resumes after a process restart.
"""
captured: list[str] = []
received: list[object] = []
first_seen = anyio.Event()
token_seen = anyio.Event()
release = anyio.Event()
store = SequencedEventStore()
mcp = MCPServer("resumable")
@mcp.tool()
async def hold(ctx: Context) -> str:
"""Emit one notification, wait for the test, emit another, return."""
await ctx.info("first") # pyright: ignore[reportDeprecated]
await release.wait()
await ctx.info("second") # pyright: ignore[reportDeprecated]
return "done"
async def on_token(token: str) -> None:
captured.append(token)
if len(captured) >= 2:
token_seen.set()
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params.data)
first_seen.set()
call = CallToolRequest(params=CallToolRequestParams(name="hold", arguments={}))
capture = ClientMessageMetadata(on_resumption_token_update=on_token)
async with mounted_app(mcp, event_store=store, retry_interval=0) as (http, manager):
with anyio.fail_after(5): # pragma: no branch
async with ( # pragma: no branch
streamable_http_client(f"{BASE_URL}/mcp", http_client=http, terminate_on_close=False) as (r1, w1),
ClientSession(r1, w1, logging_callback=collect) as first,
anyio.create_task_group() as tg,
):
await first.initialize()
tg.start_soon(first.send_request, call, CallToolResult, None, capture)
await first_seen.wait()
await token_seen.wait()
assert captured == snapshot(["3", "4"])
assert received == snapshot(["first"])
# The session id is only observable via the manager (the client transport does not expose it).
(session_id,) = manager._server_instances
http.headers["mcp-session-id"] = session_id
http.headers["mcp-protocol-version"] = LATEST_HANDSHAKE_VERSION
tg.cancel_scope.cancel()
with anyio.fail_after(5): # pragma: no branch
release.set() # pragma: lax no cover — python/cpython#106749: 3.11 drops this line event
# init priming + init response + call priming + "first" + "second" + result = 6 stored events.
await store.wait_until_stored(6)
async with ( # pragma: no branch
streamable_http_client(f"{BASE_URL}/mcp", http_client=http) as (r2, w2),
ClientSession(r2, w2, logging_callback=collect) as second,
):
result = await second.send_request(
call, CallToolResult, metadata=ClientMessageMetadata(resumption_token=captured[-1])
)
assert result == snapshot(CallToolResult(content=[TextContent(text="done")], structured_content={"result": "done"}))
assert received == snapshot(["first", "second"])
@@ -0,0 +1,202 @@
"""Streamable HTTP session lifecycle: creation, routing, termination, and stateless mode.
A test here speaks raw HTTP only when its assertion is the wire contract -- which header is
issued, which status code answers which condition -- that the SDK `Client` cannot observe.
Everything else is `Client`-driven against the same mounted session manager. Transport-agnostic
behaviour is covered by the `connect`-fixture matrix.
"""
import re
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import JSONRPCResponse, ListToolsResult, PaginatedRequestParams, Tool
from mcp.server import Server, ServerRequestContext
from tests.interaction._connect import (
base_headers,
client_via_http,
initialize_body,
initialize_via_http,
mounted_app,
post_jsonrpc,
)
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _server() -> Server:
"""A minimal low-level server with one tool, so subsequent-request routing can be observed."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="noop", description="Does nothing.", input_schema={"type": "object"})])
return Server("hosted", on_list_tools=list_tools)
@requirement("hosting:session:create")
@requirement("hosting:session:id-charset")
async def test_initialize_issues_a_visible_ascii_session_id() -> None:
"""An initialize POST without a session ID creates a session and returns a visible-ASCII Mcp-Session-Id."""
async with mounted_app(_server()) as (http, _):
response, messages = await post_jsonrpc(http, initialize_body())
assert response.status_code == 200
session_id = response.headers.get("mcp-session-id")
assert session_id is not None
# The spec requires the session ID to consist only of visible ASCII (0x21-0x7E).
assert re.fullmatch(r"[\x21-\x7E]+", session_id)
assert isinstance(messages[0], JSONRPCResponse)
assert messages[0].id == 1
@requirement("hosting:session:reuse")
async def test_subsequent_requests_with_the_session_id_route_to_the_same_session() -> None:
"""Requests carrying the issued Mcp-Session-Id reuse that session's transport rather than creating another."""
async with mounted_app(_server()) as (http, manager):
async with client_via_http(http) as client:
await client.list_tools()
await client.list_tools()
# The session count is the only signal that distinguishes routing-to-existing from
# silently creating a second session: both produce a successful result.
assert len(manager._server_instances) == 1
@requirement("hosting:session:unknown-id")
async def test_requests_with_an_unknown_session_id_return_404() -> None:
"""POST, GET, and DELETE each carrying an unknown Mcp-Session-Id are answered 404 by the manager."""
async with mounted_app(_server()) as (http, _):
post = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 1, "method": "tools/list"},
headers=base_headers(session_id="not-a-session"),
)
get = await http.get("/mcp", headers=base_headers(session_id="not-a-session"))
delete = await http.delete("/mcp", headers=base_headers(session_id="not-a-session"))
assert (post.status_code, post.json()) == snapshot(
(404, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Session not found"}})
)
assert (get.status_code, delete.status_code) == (404, 404)
@requirement("hosting:session:missing-id")
async def test_non_initialize_post_without_a_session_id_returns_400() -> None:
"""A non-initialize POST that omits Mcp-Session-Id in stateful mode is rejected with 400."""
async with mounted_app(_server()) as (http, _):
await initialize_via_http(http)
response = await http.post(
"/mcp", json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"}, headers=base_headers()
)
assert (response.status_code, response.json()) == snapshot(
(400, {"jsonrpc": "2.0", "id": None, "error": {"code": -32600, "message": "Bad Request: Missing session ID"}})
)
@requirement("hosting:session:delete")
@requirement("hosting:session:post-termination-404")
async def test_delete_terminates_the_session_and_subsequent_requests_return_404() -> None:
"""DELETE with a valid Mcp-Session-Id terminates the session; further requests on that ID return 404."""
async with mounted_app(_server()) as (http, manager):
session_id = await initialize_via_http(http)
delete = await http.delete("/mcp", headers=base_headers(session_id=session_id))
assert delete.status_code == 200
# The manager keeps the terminated transport registered, so the next request reaches the
# transport's own _terminated check rather than the manager's unknown-session path.
assert session_id in manager._server_instances
post = await http.post(
"/mcp",
json={"jsonrpc": "2.0", "id": 2, "method": "tools/list"},
headers=base_headers(session_id=session_id),
)
assert (post.status_code, post.json()) == snapshot(
(
404,
{
"jsonrpc": "2.0",
"id": None,
"error": {"code": -32600, "message": "Not Found: Session has been terminated"},
},
)
)
@requirement("hosting:session:isolation")
async def test_terminating_one_session_leaves_others_working() -> None:
"""Terminating one session on a manager does not disturb a concurrent session on the same manager."""
async with mounted_app(_server()) as (http, manager):
async with client_via_http(http) as survivor:
async with client_via_http(http) as terminated:
await terminated.list_tools()
assert len(manager._server_instances) == 2
# `terminated` has exited (its DELETE has been sent); `survivor` still answers.
result = await survivor.list_tools()
assert result.tools[0].name == "noop"
@requirement("hosting:session:reinitialize")
async def test_second_initialize_on_an_existing_session_is_accepted() -> None:
"""A second initialize POST carrying an existing session ID is processed rather than rejected.
See the divergence on the requirement: the entry expects a rejection, but the SDK forwards the
second initialize to the running server, which answers it as a fresh handshake.
"""
async with mounted_app(_server()) as (http, manager):
session_id = await initialize_via_http(http)
response, messages = await post_jsonrpc(http, initialize_body(request_id=2), session_id=session_id)
assert len(manager._server_instances) == 1
assert response.status_code == snapshot(200)
assert isinstance(messages[0], JSONRPCResponse)
assert messages[0].id == 2
@requirement("hosting:stateless:no-session-id")
@requirement("hosting:stateless:no-reuse")
async def test_stateless_mode_never_issues_a_session_id() -> None:
"""A stateless server issues no Mcp-Session-Id and creates no persistent transport.
The recording proves no request the SDK client sent carried an Mcp-Session-Id (the server
cannot have issued one, or the client would echo it); the empty instance map proves the
manager kept no transport between requests.
"""
requests: list[httpx.Request] = []
async def record(request: httpx.Request) -> None:
requests.append(request)
async with mounted_app(_server(), stateless_http=True, on_request=record) as (http, manager):
async with client_via_http(http) as client:
result = await client.list_tools()
assert manager._server_instances == {}
assert result.tools[0].name == "noop"
assert all("mcp-session-id" not in request.headers for request in requests)
assert "DELETE" not in {request.method for request in requests}
@requirement("hosting:stateless:concurrent-clients")
async def test_stateless_mode_serves_concurrent_clients_independently() -> None:
"""Two clients connected concurrently to the same stateless app each complete a round trip."""
results: dict[str, ListToolsResult] = {}
async with mounted_app(_server(), stateless_http=True) as (http, _):
async def list_via(label: str) -> None:
async with client_via_http(http) as client:
results[label] = await client.list_tools()
with anyio.fail_after(5):
async with anyio.create_task_group() as tg: # pragma: no branch
tg.start_soon(list_via, "a")
tg.start_soon(list_via, "b")
assert results["a"].tools[0].name == "noop"
assert results["b"].tools[0].name == "noop"
@@ -0,0 +1,85 @@
"""Legacy-wire protection: a 2025-era streamable-HTTP exchange stays free of 2026 vocabulary.
Records a full SDK client -> SDK server round trip at both seams (HTTP request/response headers
via httpx event hooks; JSON-RPC frames in both directions via the recording transport) and runs
the result through :func:`tests.interaction._modern_vocab.assert_no_modern_vocabulary`. The test
pins today's wire so any future 2026-07-28 work that leaks new fields, `_meta` keys, or headers
onto a connection negotiated at the current protocol version fails here.
"""
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolRequestParams,
CallToolResult,
ListToolsResult,
PaginatedRequestParams,
TextContent,
Tool,
)
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.shared.message import SessionMessage
from tests.interaction._connect import BASE_URL, mounted_app
from tests.interaction._helpers import RecordingTransport
from tests.interaction._modern_vocab import RecordedExchange, assert_no_modern_vocabulary
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _server() -> Server:
"""A low-level server with one echo tool, so the recorded exchange covers tools/list and tools/call."""
async def list_tools(ctx: ServerRequestContext, params: PaginatedRequestParams | None) -> ListToolsResult:
return ListToolsResult(tools=[Tool(name="echo", description="Echo text.", input_schema={"type": "object"})])
async def call_tool(ctx: ServerRequestContext, params: CallToolRequestParams) -> CallToolResult:
assert params.name == "echo"
assert params.arguments is not None
return CallToolResult(content=[TextContent(text=str(params.arguments["text"]))])
return Server("legacy", on_list_tools=list_tools, on_call_tool=call_tool)
@requirement("hosting:http:legacy-no-modern-vocabulary")
async def test_legacy_streamable_http_exchange_carries_no_modern_protocol_vocabulary() -> None:
"""A 2025-era client/server round trip emits none of the 2026-07-28 wire vocabulary.
SDK-defined under the draft versioning rules: pins the current wire so future 2026 work cannot
leak `resultType` / `ttlMs` / `cacheScope`, `io.modelcontextprotocol/*` `_meta` keys, the
`2026-07-28` literal, or `Mcp-Method` / `Mcp-Name` / `Mcp-Param-*` headers onto a connection
negotiated at the current protocol version. Recorded at the HTTP seam (every request and
response header) and the transport seam (every JSON-RPC frame in either direction); the SDK
client never exposes either, so the assertion is necessarily wire-level.
"""
recorded = RecordedExchange(requests=[], responses=[], frames=[])
async def on_request(request: httpx.Request) -> None:
recorded.requests.append(request)
async def on_response(response: httpx.Response) -> None:
recorded.responses.append(response)
async with mounted_app(_server(), on_request=on_request, on_response=on_response) as (http, _):
recording = RecordingTransport(streamable_http_client(f"{BASE_URL}/mcp", http_client=http))
async with Client(recording, mode="legacy") as client:
result = await client.call_tool("echo", {"text": "legacy"})
assert result == snapshot(CallToolResult(content=[TextContent(text="legacy")]))
recorded.frames.extend(m.message for m in recording.sent)
recorded.frames.extend(m.message for m in recording.received if isinstance(m, SessionMessage))
# The handshake, the implicit tools/list (output-schema cache), tools/call, the standalone GET
# stream, and the closing DELETE all crossed the HTTP seam; the transport seam saw a JSON-RPC
# frame for each direction of each. Asserting non-empty so the vocabulary scan cannot pass on
# nothing recorded.
assert {r.method for r in recorded.requests} == snapshot({"POST", "GET", "DELETE"})
assert len(recorded.responses) == len(recorded.requests)
assert len(recorded.frames) >= 6
assert_no_modern_vocabulary(recorded)
+90
View File
@@ -0,0 +1,90 @@
"""Behaviour specific to the legacy HTTP+SSE transport, exercised entirely in process.
Transport-agnostic behaviour is covered by the `connect`-fixture matrix, which runs the rest of
the suite over this transport as well; this file pins only what is observable on the SSE wiring
itself: the GET-then-POST connection lifecycle, the endpoint event, and how the message endpoint
rejects requests it cannot route to a session. Every test drives the server's real Starlette app
through the suite's streaming ASGI bridge.
"""
from uuid import UUID, uuid4
import anyio
import httpx
import pytest
from inline_snapshot import snapshot
from mcp_types import EmptyResult
from mcp.client.client import Client
from mcp.client.sse import sse_client
from mcp.server import Server
from tests.interaction._connect import BASE_URL, build_sse_app
from tests.interaction._requirements import requirement
from tests.interaction.transports._bridge import StreamingASGITransport
pytestmark = pytest.mark.anyio
@requirement("transport:sse")
@requirement("transport:sse:endpoint-event")
async def test_endpoint_event_names_the_message_endpoint_with_a_fresh_session_id() -> None:
"""Connecting opens a GET stream whose first event names the POST endpoint and a fresh
session id; messages POSTed there are answered on that stream, and disconnecting releases the
server's session entry."""
app, sse = build_sse_app(Server("legacy"))
captured_session_id: list[str] = []
def httpx_client_factory(
headers: dict[str, str] | None = None,
timeout: httpx.Timeout | None = None,
auth: httpx.Auth | None = None,
) -> httpx.AsyncClient:
return httpx.AsyncClient(
transport=StreamingASGITransport(app, cancel_on_close=False),
base_url=BASE_URL,
headers=headers,
timeout=timeout,
auth=auth,
)
transport = sse_client(
f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory, on_session_created=captured_session_id.append
)
with anyio.fail_after(5):
async with Client(transport, mode="legacy") as client:
assert len(captured_session_id) == 1
assert UUID(hex=captured_session_id[0]) in sse._read_stream_writers
assert await client.send_ping() == snapshot(EmptyResult()) # pyright: ignore[reportDeprecated]
assert sse._read_stream_writers == {}
@requirement("transport:sse:post:session-routing")
async def test_post_without_a_session_id_is_rejected() -> None:
"""A POST to the message endpoint with no session_id query parameter is answered 400."""
app, _ = build_sse_app(Server("legacy"))
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
response = await http.post("/messages/", json={"jsonrpc": "2.0", "method": "ping", "id": 1})
assert (response.status_code, response.text) == snapshot((400, "session_id is required"))
@requirement("transport:sse:post:session-routing")
async def test_post_with_a_malformed_session_id_is_rejected() -> None:
"""A POST whose session_id query parameter is not a UUID is answered 400."""
app, _ = build_sse_app(Server("legacy"))
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
response = await http.post(
"/messages/", params={"session_id": "not-a-uuid"}, json={"jsonrpc": "2.0", "method": "ping", "id": 1}
)
assert (response.status_code, response.text) == snapshot((400, "Invalid session ID"))
@requirement("transport:sse:post:session-routing")
async def test_post_for_an_unknown_session_is_rejected() -> None:
"""A POST naming a well-formed session_id that no SSE stream owns is answered 404."""
app, _ = build_sse_app(Server("legacy"))
async with httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http:
response = await http.post(
"/messages/", params={"session_id": uuid4().hex}, json={"jsonrpc": "2.0", "method": "ping", "id": 1}
)
assert (response.status_code, response.text) == snapshot((404, "Could not find session"))
+152
View File
@@ -0,0 +1,152 @@
"""The stdio transport: one subprocess end-to-end test and one in-process framing test.
The subprocess test proves the client-server round trip over the transport's real process
boundary; its server lives in `_stdio_server.py` and is launched via `python -m` so subprocess
coverage measurement applies. The framing test drives `stdio_server` over injected in-process
streams instead.
stdio is deliberately not a leg of the `connect`-fixture matrix: a subprocess per test would be
slow, and the matrix already proves transport-agnosticism in-process. Process-lifecycle edge
cases (terminate/kill escalation, parse errors) stay in `tests/client/test_stdio.py`.
"""
import io
import json
import os
import sys
import tempfile
from pathlib import Path
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
CallToolResult,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
LoggingMessageNotificationParams,
TextContent,
)
from mcp_types.jsonrpc import jsonrpc_message_adapter
from mcp.client import stdio
from mcp.client.client import Client
from mcp.client.stdio import StdioServerParameters, stdio_client
from mcp.server.stdio import stdio_server
from mcp.shared.message import SessionMessage
from tests.interaction._connect import initialize_body
from tests.interaction._requirements import requirement
from tests.interaction.transports import _stdio_server
pytestmark = pytest.mark.anyio
_REPO_ROOT = Path(__file__).parents[3]
@requirement("transport:stdio")
@requirement("transport:stdio:clean-shutdown")
@requirement("transport:stdio:stderr-passthrough")
async def test_tool_call_and_notification_round_trip_over_a_stdio_subprocess(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""A stdio-subprocess Client round-trips a tool call, a notification, and a clean exit.
The Client initializes, calls a tool with arguments, and receives the server's log
notification before the call returns; the server exits when the transport closes its
stdin.
"""
# After stdin closes, the child must unwind, flush its subprocess coverage data, and write
# the clean-exit line before escalation (the server saves coverage *before* printing, so a
# post-print kill can no longer silently lose the data file -- see _stdio_server.main). The
# production 2s default is too tight for the unwind+save tail on loaded Windows runners
# (measured in-situ p99 of the whole test is ~7s); a kill before the print fails the stderr
# assertion below loudly rather than tripping the coverage gate. The 20s grace covers even a
# badly starved runner (a >10s stall has been seen once in CI) and costs nothing when the
# child exits promptly. Not under test.
monkeypatch.setattr(stdio, "PROCESS_TERMINATION_TIMEOUT", 20.0)
received: list[LoggingMessageNotificationParams] = []
async def collect(params: LoggingMessageNotificationParams) -> None:
received.append(params)
with tempfile.TemporaryFile(mode="w+") as errlog:
transport = stdio_client(
StdioServerParameters(
command=sys.executable,
args=["-m", _stdio_server.__name__],
cwd=str(_REPO_ROOT),
# stdio_client filters the inherited environment, dropping the variables
# coverage.py's subprocess support uses; pass them through so the server module is
# measured. PYTHONWARNINGS: the child recompiles anyio (pytest's pyc tag differs),
# and on 3.14 anyio's return-in-finally SyntaxWarning would land on the snapshot stderr.
env={key: value for key, value in os.environ.items() if key.startswith("COVERAGE_")}
| {"PYTHONWARNINGS": "ignore::SyntaxWarning"},
),
errlog=errlog,
)
# Must exceed session time plus the patched PROCESS_TERMINATION_TIMEOUT (20s).
with anyio.fail_after(30):
async with Client(transport, mode="legacy", logging_callback=collect) as client:
assert client.server_info.name == "stdio-echo"
result = await client.call_tool("echo", {"text": "across\nprocesses"})
errlog.seek(0)
captured_stderr = errlog.read()
assert result == snapshot(CallToolResult(content=[TextContent(text="across\nprocesses")]))
# stdio carries one ordered server-to-client stream, so the same notification-before-response
# guarantee holds here as for the in-memory transport.
assert received == snapshot(
[LoggingMessageNotificationParams(level="info", logger="echo", data="echoing across\nprocesses")]
)
# The server writes this line only after its run loop returns on stdin close: seeing it proves
# a self-exit, not the terminate escalation. The capture itself proves stderr passthrough.
assert captured_stderr == snapshot("stdio-echo: clean exit\n")
@requirement("transport:stdio:stream-purity")
@requirement("transport:stdio:no-embedded-newlines")
async def test_stdio_server_writes_one_jsonrpc_message_per_line() -> None:
"""Every `stdio_server` write is one valid JSON-RPC message on its own line.
Each line is newline-terminated with payload newlines JSON-escaped. This proves the
transport's own framing; it does not guard `sys.stdout` against handler code (see the
divergence on `transport:stdio:stream-purity`).
"""
captured = io.StringIO()
sent_line = json.dumps(initialize_body(request_id=1)) + "\n"
with anyio.fail_after(5):
async with (
stdio_server(stdin=anyio.wrap_file(io.StringIO(sent_line)), stdout=anyio.wrap_file(captured)) as (
read_stream,
write_stream,
),
read_stream,
write_stream,
):
received = await read_stream.receive()
assert isinstance(received, SessionMessage)
assert isinstance(received.message, JSONRPCRequest)
assert received.message.method == "initialize"
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={"text": "line\nbreak"})
notification = JSONRPCNotification(
jsonrpc="2.0", method="notifications/message", params={"level": "info", "data": "two\nlines"}
)
await write_stream.send(SessionMessage(response))
await write_stream.send(SessionMessage(notification))
output = captured.getvalue()
assert output.endswith("\n")
lines = output.removesuffix("\n").split("\n")
assert len(lines) == 2
messages = [jsonrpc_message_adapter.validate_json(line) for line in lines]
assert [type(message).__name__ for message in messages] == snapshot(["JSONRPCResponse", "JSONRPCNotification"])
# The newline inside the payload is JSON-escaped on the wire, not a literal newline that would
# break the one-message-per-line framing.
assert r"line\nbreak" in lines[0]
assert r"two\nlines" in lines[1]
@@ -0,0 +1,170 @@
"""Behaviour specific to the streamable HTTP transport, exercised entirely in process.
Transport-agnostic behaviour is covered by the `connect`-fixture matrix, which runs the rest of
the suite over this transport as well; this file only pins what cannot be observed in memory: the
server's stateless and JSON-response modes, the standalone GET stream, and the full-duplex
server-initiated exchange on a still-open call. Every test drives the server's real Starlette app
through the suite's streaming ASGI bridge — no sockets, threads, or subprocesses.
"""
import anyio
import pytest
from inline_snapshot import snapshot
from mcp_types import (
INVALID_REQUEST,
CallToolResult,
ElicitRequestParams,
ElicitResult,
LoggingMessageNotification,
LoggingMessageNotificationParams,
ResourceUpdatedNotification,
ResourceUpdatedNotificationParams,
TextContent,
)
from pydantic import BaseModel
from mcp.client import ClientRequestContext
from mcp.server.elicitation import AcceptedElicitation
from mcp.server.mcpserver import Context, MCPServer
from mcp.shared.exceptions import MCPError
from tests.interaction._connect import connect_over_streamable_http
from tests.interaction._helpers import IncomingMessage
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _smoke_server() -> MCPServer:
"""A server exercising each message shape the transport-specific tests need."""
mcp = MCPServer("smoke", instructions="Talk to the smoke server.")
@mcp.tool()
def echo(text: str) -> str:
"""Echo the text back."""
return text
class Confirmation(BaseModel):
confirmed: bool
@mcp.tool()
async def ask(ctx: Context) -> str:
"""Elicit a confirmation from the client and report the outcome."""
answer = await ctx.elicit("Proceed?", Confirmation)
# In stateless mode the elicit raises before this point: there is no session to call back through.
assert isinstance(answer, AcceptedElicitation)
return f"confirmed={answer.data.confirmed}"
@mcp.tool()
async def announce(ctx: Context) -> str:
"""Send one notification related to this request and one that is not."""
await ctx.info("about to announce") # pyright: ignore[reportDeprecated]
await ctx.session.send_resource_updated("file:///watched.txt")
return "announced"
return mcp
@requirement("transport:streamable-http:json-response")
@requirement("client-transport:http:json-response-parsed")
async def test_tool_call_over_streamable_http_with_json_responses() -> None:
"""The round trip works when the server answers with a single JSON body instead of an SSE stream."""
async with connect_over_streamable_http(_smoke_server(), json_response=True) as client:
assert client.server_info.name == "smoke"
result = await client.call_tool("echo", {"text": "as json"})
assert result == snapshot(
CallToolResult(content=[TextContent(text="as json")], structured_content={"result": "as json"})
)
@requirement("transport:streamable-http:stateless")
async def test_tool_calls_over_stateless_streamable_http() -> None:
"""Consecutive requests each succeed against a stateless server with no session to share."""
async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
first = await client.call_tool("echo", {"text": "first"})
second = await client.call_tool("echo", {"text": "second"})
assert first == snapshot(
CallToolResult(content=[TextContent(text="first")], structured_content={"result": "first"})
)
assert second == snapshot(
CallToolResult(content=[TextContent(text="second")], structured_content={"result": "second"})
)
@requirement("transport:streamable-http:stateless-restrictions")
async def test_stateless_streamable_http_rejects_server_initiated_requests() -> None:
"""A handler that tries to call back to the client in stateless mode fails: there is no
back-channel for server-initiated requests. The resulting ``NoBackChannelError`` is an
``MCPError``, so it surfaces as a top-level JSON-RPC error rather than an
``isError`` result."""
async with connect_over_streamable_http(_smoke_server(), stateless_http=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("ask", {})
assert exc_info.value.error.code == INVALID_REQUEST
@requirement("transport:streamable-http:notifications")
@requirement("transport:streamable-http:unrelated-messages")
@requirement("hosting:http:standalone-sse")
async def test_unrelated_server_messages_arrive_on_the_standalone_stream() -> None:
"""A server message with no related request reaches the client through the standalone GET stream.
The log notification is related to the tool call and travels on that call's own SSE stream;
the resource-updated notification is not related to any request, so the only way it can reach
the client is the standalone stream the client opens after initialization. Delivery order
across the two streams is not guaranteed, so the unrelated message is awaited rather than
assumed to beat the tool result.
"""
received: list[IncomingMessage] = []
resource_update_seen = anyio.Event()
async def collect(message: IncomingMessage) -> None:
received.append(message)
if isinstance(message, ResourceUpdatedNotification):
resource_update_seen.set()
async with connect_over_streamable_http(_smoke_server(), message_handler=collect) as client:
result = await client.call_tool("announce", {})
with anyio.fail_after(5):
await resource_update_seen.wait()
assert result == snapshot(
CallToolResult(content=[TextContent(text="announced")], structured_content={"result": "announced"})
)
# The related log notification rides the call's stream; the unrelated resource-updated
# notification rides the standalone stream. Both arrive, nothing else does.
assert [message for message in received if isinstance(message, LoggingMessageNotification)] == snapshot(
[LoggingMessageNotification(params=LoggingMessageNotificationParams(level="info", data="about to announce"))]
)
assert [message for message in received if isinstance(message, ResourceUpdatedNotification)] == snapshot(
[ResourceUpdatedNotification(params=ResourceUpdatedNotificationParams(uri="file:///watched.txt"))]
)
assert len(received) == 2
@requirement("transport:streamable-http:stateful")
@requirement("transport:streamable-http:server-to-client")
async def test_server_initiated_elicitation_round_trips_during_a_tool_call() -> None:
"""An elicitation issued mid-call reaches the client and its answer reaches the handler over stateful HTTP.
The elicitation request travels on the still-open SSE response of the tool call that triggered
it, and the client's answer arrives as a separate POST -- the full-duplex exchange the
streamable HTTP transport exists to provide.
"""
asked: list[ElicitRequestParams] = []
async def answer(context: ClientRequestContext, params: ElicitRequestParams) -> ElicitResult:
asked.append(params)
return ElicitResult(action="accept", content={"confirmed": True})
async with connect_over_streamable_http(_smoke_server(), elicitation_callback=answer) as client:
# Bounded because a harness regression here historically meant deadlock, not failure.
with anyio.fail_after(5):
result = await client.call_tool("ask", {})
assert result == snapshot(
CallToolResult(content=[TextContent(text="confirmed=True")], structured_content={"result": "confirmed=True"})
)
assert [params.message for params in asked] == snapshot(["Proceed?"])