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
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:
@@ -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"]
|
||||
@@ -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"]]
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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?"])
|
||||
Reference in New Issue
Block a user