Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)

This commit is contained in:
Max 2026-06-25 16:09:23 +02:00 • committed by GitHub
commit f226d00d0a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
11 changed files with 2533 additions and 993 deletions

View file

@ -3,8 +3,8 @@
from __future__ import annotations
import contextvars
from collections.abc import Iterator
from contextlib import contextmanager
from collections.abc import AsyncIterator, Iterator
from contextlib import asynccontextmanager, contextmanager
from unittest.mock import patch
import anyio
@ -13,9 +13,14 @@ from inline_snapshot import snapshot
from mcp import MCPError, types
from mcp.client._memory import InMemoryTransport
from mcp.client._transport import TransportStreams
from mcp.client.client import Client
from mcp.client.streamable_http import streamable_http_client
from mcp.server import Server, ServerRequestContext
from mcp.server.mcpserver import MCPServer
from mcp.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from mcp.shared.version import LATEST_HANDSHAKE_VERSION
from mcp.types import (
CallToolResult,
EmptyResult,
@ -37,6 +42,7 @@ from mcp.types import (
Tool,
ToolsCapability,
)
from tests.interaction._connect import BASE_URL, mounted_app
pytestmark = pytest.mark.anyio
@ -101,8 +107,8 @@ def app() -> MCPServer:
async def test_client_is_initialized(app: MCPServer):
"""Test that the client is initialized after entering context."""
async with Client(app) as client:
assert client.initialize_result.capabilities == snapshot(
async with Client(app, mode="legacy") as client:
assert client.server_capabilities == snapshot(
ServerCapabilities(
experimental={},
prompts=PromptsCapability(list_changed=False),
@ -110,13 +116,13 @@ async def test_client_is_initialized(app: MCPServer):
tools=ToolsCapability(list_changed=False),
)
)
assert client.initialize_result.server_info.name == "test"
assert client.server_info.name == "test"
async def test_client_initialize_result_exposes_negotiated_protocol_version(app: MCPServer):
async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
"""The negotiated protocol version is readable after initialization."""
async with Client(app) as client:
assert client.initialize_result.protocol_version == types.LATEST_PROTOCOL_VERSION
async with Client(app, mode="legacy") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
async def test_client_with_simple_server(simple_server: Server):
@ -131,8 +137,8 @@ async def test_client_with_simple_server(simple_server: Server):
async def test_client_send_ping(app: MCPServer):
async with Client(app) as client:
result = await client.send_ping()
async with Client(app, mode="legacy") as client:
result = await client.send_ping() # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@ -200,6 +206,40 @@ async def test_read_resource_error_propagates():
assert exc_info.value.error.code == 404
async def test_raise_exceptions_propagates_handler_error_on_modern_inproc_path():
"""`raise_exceptions=True` on the modern in-process path: an unmapped handler
exception reaches the client with its original type chained, instead of being
sanitized to an opaque `INTERNAL_ERROR`."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
raise ValueError("boom")
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28", raise_exceptions=True) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("explode", {})
# The original exception is chained — not swallowed into a generic "Internal server error".
assert isinstance(exc_info.value.__cause__, ValueError)
assert str(exc_info.value.__cause__) == "boom"
async def test_raise_exceptions_false_sanitizes_handler_error_on_modern_inproc_path():
"""`raise_exceptions=False` (the default) on the modern in-process path: an
unmapped handler exception is sanitized to an opaque `INTERNAL_ERROR` so the
in-process path matches the wire path's leak guard."""
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
raise ValueError("boom")
server = Server("test", on_call_tool=handle_call_tool)
async with Client(server, mode="2026-07-28", raise_exceptions=False) as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("explode", {})
assert exc_info.value.error.code == types.INTERNAL_ERROR
assert exc_info.value.error.message == "Internal server error"
assert exc_info.value.__cause__ is None
async def test_get_prompt(app: MCPServer):
"""Test getting a prompt."""
async with Client(app) as client:
@ -238,27 +278,28 @@ async def test_client_send_progress_notification():
server = Server(name="test_server", on_progress=handle_progress)
async with Client(server) as client:
await client.send_progress_notification(progress_token="token123", progress=50.0)
await event.wait()
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
with anyio.fail_after(5):
async with Client(server, mode="legacy") as client:
await client.send_progress_notification(progress_token="token123", progress=50.0) # pyright: ignore[reportDeprecated]
await event.wait()
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
async def test_client_subscribe_resource(simple_server: Server):
async with Client(simple_server) as client:
async with Client(simple_server, mode="legacy") as client:
result = await client.subscribe_resource("memory://test")
assert result == snapshot(EmptyResult())
async def test_client_unsubscribe_resource(simple_server: Server):
async with Client(simple_server) as client:
async with Client(simple_server, mode="legacy") as client:
result = await client.unsubscribe_resource("memory://test")
assert result == snapshot(EmptyResult())
async def test_client_set_logging_level(simple_server: Server):
"""Test setting logging level."""
async with Client(simple_server) as client:
async with Client(simple_server, mode="legacy") as client:
result = await client.set_logging_level("debug") # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())
@ -316,12 +357,12 @@ async def test_complete_with_prompt_reference(simple_server: Server):
def test_client_with_url_initializes_streamable_http_transport():
with patch("mcp.client.client.streamable_http_client") as mock:
_ = Client("http://localhost:8000/mcp")
mock.assert_called_once_with("http://localhost:8000/mcp", protocol_version=None)
mock.assert_called_once_with("http://localhost:8000/mcp")
async def test_client_uses_transport_directly(app: MCPServer):
transport = InMemoryTransport(app)
async with Client(transport) as client:
async with Client(transport, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "Transport"})
assert result == snapshot(
CallToolResult(
@ -359,3 +400,83 @@ async def test_context_propagation():
assert result.content[0].text == "client_value", ( # type: ignore[union-attr]
"Server handler did not see the sender's contextvars.Context"
)
async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Server) -> None:
"""`mode='auto'` over an in-process HTTP transport: the `server/discover` probe
reaches the modern entry and the negotiated protocol version is adopted without
an `initialize` handshake. Runs over HTTP because the in-memory runner gates
`server/discover` behind the init handshake."""
with anyio.fail_after(5):
async with (
mounted_app(simple_server) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
):
assert client.protocol_version == "2026-07-28"
assert (await client.list_resources()).resources[0].name == "Test Resource"
@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR])
async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None:
"""`mode='auto'`: any JSON-RPC error from `server/discover` makes
`Client.__aenter__` run the legacy `initialize()` handshake and land at a
handshake-era protocol version. The denylist policy treats every server-sent
rpc-error as "not modern" — including INTERNAL_ERROR, since a legacy server
may crash on the unknown method before reaching its router. A real `Server`
always implements `server/discover`, so the server side is hand-played."""
methods_seen: list[str] = []
async def scripted_server(streams: MessageStream) -> None:
server_read, server_write = streams
async for message in server_read:
assert isinstance(message, SessionMessage)
frame = message.message
assert isinstance(frame, types.JSONRPCRequest | types.JSONRPCNotification)
methods_seen.append(frame.method)
if isinstance(frame, types.JSONRPCNotification):
continue
if frame.method == "server/discover":
error = types.ErrorData(code=code, message="nope")
await server_write.send(SessionMessage(types.JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
elif frame.method == "initialize": # pragma: no branch
result = types.InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=types.Implementation(name="legacy-only", version="0.0.1"),
)
await server_write.send(
SessionMessage(
types.JSONRPCResponse(
jsonrpc="2.0",
id=frame.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
@asynccontextmanager
async def scripted_transport() -> AsyncIterator[TransportStreams]:
async with (
create_client_server_memory_streams() as ((client_read, client_write), server_streams),
anyio.create_task_group() as tg,
):
tg.start_soon(scripted_server, server_streams)
yield client_read, client_write
tg.cancel_scope.cancel()
with anyio.fail_after(5):
async with Client(scripted_transport(), mode="auto") as client:
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
assert client.server_info.name == "legacy-only"
assert methods_seen == ["server/discover", "initialize", "notifications/initialized"]
def test_client_rejects_handshake_era_mode_at_construction() -> None:
"""A handshake-era protocol-version string passed as `mode=` is rejected by
`__post_init__` with a hint to use `mode='legacy'` — the version-pin path is
modern-only."""
server = MCPServer("test")
with pytest.raises(ValueError, match=r"handshake-era version — use mode='legacy'"):
Client(server, mode="2025-06-18")
with pytest.raises(ValueError, match=r"mode must be 'legacy', 'auto', or one of"):
Client(server, mode="not-a-version")

View file

@ -64,7 +64,7 @@ async def test_list_methods_params_parameter(
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
"""
async with Client(full_featured_server) as client:
async with Client(full_featured_server, mode="legacy") as client:
spies = stream_spy()
# Test without params (omitted)

View file

@ -31,7 +31,7 @@ async def test_list_roots_callback():
return True
# Test with list_roots callback
async with Client(server, list_roots_callback=list_roots_callback) as client:
async with Client(server, list_roots_callback=list_roots_callback, mode="legacy") as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_list_roots", {"message": "test message"})
assert result.is_error is False
@ -41,7 +41,7 @@ async def test_list_roots_callback():
# Without a list_roots callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_list_roots", {"message": "test message"})
assert exc_info.value.error.code == INVALID_REQUEST

View file

@ -64,6 +64,7 @@ async def test_logging_callback():
server,
logging_callback=logging_collector,
message_handler=message_handler,
mode="legacy",
) as client:
# First verify our test tool works
result = await client.call_tool("test_tool", {})

View file

@ -204,12 +204,19 @@ async def test_invalid_json_response_sends_jsonrpc_error() -> None:
def _create_non_2xx_json_body_app(status: int, body: bytes) -> Starlette:
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests."""
"""Server that returns a fixed non-2xx status + ``application/json`` body for non-init requests.
The initialize response carries an ``mcp-session-id`` so the client treats subsequent
requests as part of an established session (needed for the 404 → session-terminated mapping).
"""
async def handle_mcp_request(request: Request) -> Response:
data = json.loads(await request.body())
if data.get("method") == "initialize":
return _init_json_response(data)
return JSONResponse(
{"jsonrpc": "2.0", "id": data["id"], "result": INIT_RESPONSE},
headers={"mcp-session-id": "test-session"},
)
if "id" not in data:
return Response(status_code=202)
return Response(content=body, status_code=status, media_type="application/json")

242
tests/client/test_probe.py Normal file
View file

@ -0,0 +1,242 @@
"""Unit tests for the connect-time auto-negotiation policy (`mcp.client._probe.negotiate_auto`).
`negotiate_auto` is a small policy function that drives a `ClientSession` through the
``server/discover`` probe and decides between ``adopt()`` (modern), ``initialize()`` (legacy
fallback), or letting the probe's exception propagate. The policy is a *denylist*: every
``MCPError`` falls back to ``initialize()``, the sole exception being -32022 with a disjoint
modern-only ``supported`` list. Any non-``MCPError`` exception (network errors, anyio
resource errors) propagates untouched — an outage is never an era verdict.
These tests pin the classifier in isolation with a stub session; the end-to-end wire shape is
covered by ``tests/interaction/lowlevel/test_client_connect.py``.
"""
from __future__ import annotations
from typing import Any, cast
import anyio
import httpx
import pytest
from mcp import types
from mcp.client._probe import _parse_supported, negotiate_auto
from mcp.client.session import ClientSession
from mcp.shared.exceptions import MCPError
from mcp.shared.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.types import (
INTERNAL_ERROR,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PARSE_ERROR,
UNSUPPORTED_PROTOCOL_VERSION,
Implementation,
ServerCapabilities,
)
pytestmark = pytest.mark.anyio
class _StubSession:
"""Minimal stand-in for `ClientSession` exposing only what `negotiate_auto` touches.
`send_discover` plays back a script (raise an exception, or return a dict);
`initialize` and `adopt` just record that they were called.
"""
def __init__(self, *script: dict[str, Any] | Exception) -> None:
self._script: list[dict[str, Any] | Exception] = list(script)
self.probed_at: list[str] = []
self.initialized: bool = False
self.adopted: types.DiscoverResult | None = None
async def send_discover(self, version: str) -> dict[str, Any]:
self.probed_at.append(version)
step = self._script.pop(0)
if isinstance(step, Exception):
raise step
return step
async def initialize(self) -> None:
self.initialized = True
def adopt(self, result: types.DiscoverResult) -> None:
self.adopted = result
async def _negotiate(session: _StubSession) -> None:
"""Drive `negotiate_auto` against the stub; cast at one seam so the tests stay suppression-free."""
await negotiate_auto(cast("ClientSession", session))
def _discover_dict(versions: list[str] | None = None) -> dict[str, Any]:
return types.DiscoverResult(
supported_versions=versions or list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
def _err_32022(supported: Any) -> MCPError:
return MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="unsupported protocol version",
data={"supported": supported, "requested": LATEST_MODERN_VERSION},
)
# --- happy path: modern server ---
async def test_a_valid_discover_result_is_adopted_without_initializing() -> None:
"""A parseable `DiscoverResult` from the probe is adopted; `initialize()` is never called."""
session = _StubSession(_discover_dict())
await _negotiate(session)
assert session.adopted is not None
assert session.adopted.server_info.name == "stub"
assert not session.initialized
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_an_unparseable_discover_result_falls_back_to_initialize() -> None:
"""A probe response that does not validate as `DiscoverResult` is not modern evidence,
so the policy falls back to the legacy handshake instead of adopting garbage."""
session = _StubSession({"not": "a discover result"})
await _negotiate(session)
assert session.initialized
assert session.adopted is None
# --- the denylist: every JSON-RPC error code falls back ---
@pytest.mark.parametrize(
"code",
[
pytest.param(METHOD_NOT_FOUND, id="method-not-found-32601"),
pytest.param(INVALID_REQUEST, id="invalid-request-32600"),
pytest.param(INTERNAL_ERROR, id="internal-error-32603"),
pytest.param(PARSE_ERROR, id="parse-error-32700"),
],
)
async def test_any_jsonrpc_error_from_the_probe_falls_back_to_initialize(code: int) -> None:
"""The denylist: every server-sent JSON-RPC error code is treated as "not modern" and
triggers the legacy `initialize()` handshake. Legacy servers reject the unknown
``server/discover`` method with various codes (-32601, -32600, -32603, -32700) depending
on where in their pipeline the request bounces."""
session = _StubSession(MCPError(code=code, message="nope"))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
# --- -32022 corrective retry ---
async def test_unsupported_version_with_a_mutual_modern_version_retries_once_then_adopts() -> None:
"""-32022 with a `supported` list naming a modern version we speak: re-probe once at
the highest mutual version, then adopt the second response."""
session = _StubSession(_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _discover_dict())
await _negotiate(session)
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.adopted is not None
assert not session.initialized
async def test_unsupported_version_naming_only_handshake_versions_falls_back_to_initialize() -> None:
"""-32022 with `supported` naming only handshake-era versions: the server is reachable
via the legacy handshake, so fall back rather than raise."""
session = _StubSession(_err_32022(list(HANDSHAKE_PROTOCOL_VERSIONS)))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_unsupported_version_with_disjoint_modern_only_supported_reraises() -> None:
"""-32022 with `supported` naming only modern versions we *don't* speak: this is the
one denylist exception — the server is modern-only and there is no mutual version, so
falling back to `initialize()` would also fail. The original `MCPError` re-raises."""
session = _StubSession(_err_32022(["2099-01-01"]))
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert not session.initialized
assert session.adopted is None
@pytest.mark.parametrize(
"data",
[
pytest.param(None, id="no-data"),
pytest.param({"supported": "not-a-list"}, id="malformed-supported"),
pytest.param({"requested": LATEST_MODERN_VERSION}, id="missing-supported"),
],
)
async def test_unsupported_version_with_unparseable_data_falls_back_to_initialize(data: Any) -> None:
"""-32022 with no/malformed `error.data`: nothing actionable, so fall through to the
denylist's `initialize()` fallback rather than guess or raise."""
session = _StubSession(MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="bad version", data=data))
await _negotiate(session)
assert session.initialized
assert session.adopted is None
assert session.probed_at == [LATEST_MODERN_VERSION]
async def test_a_second_unsupported_version_after_the_corrective_retry_does_not_loop() -> None:
"""The corrective -32022 retry happens at most once; a second -32022 naming a
modern-only `supported` list re-raises rather than re-probing forever (the loop
guard makes this the disjoint-modern case on attempt two)."""
session = _StubSession(_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS)))
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert not session.initialized
assert session.adopted is None
# --- non-MCP errors propagate ---
@pytest.mark.parametrize(
"exc",
[
pytest.param(httpx.ConnectError("connection refused"), id="httpx-connect-error"),
pytest.param(anyio.ClosedResourceError(), id="anyio-closed-resource"),
],
)
async def test_a_network_or_resource_error_from_the_probe_propagates_unchanged(exc: Exception) -> None:
"""Anything that is not an `MCPError` propagates as-is; an outage or in-process bug
is never an era verdict, and `initialize()` is not called."""
session = _StubSession(exc)
with pytest.raises(type(exc)):
await _negotiate(session)
assert not session.initialized
assert session.adopted is None
# --- helper ---
@pytest.mark.parametrize(
("data", "expected"),
[
({"supported": ["2026-07-28"], "requested": "x"}, ["2026-07-28"]),
({"supported": [], "requested": "x"}, []),
(None, None),
({"supported": 123, "requested": "x"}, None),
("not a dict", None),
],
)
def test_parse_supported_returns_none_for_anything_not_shaped_like_the_spec_error_data(
data: Any, expected: list[str] | None
) -> None:
"""`_parse_supported` returns the `supported` list when `error.data` validates as
`UnsupportedProtocolVersionErrorData`, and `None` otherwise — never raises."""
assert _parse_supported(data) == expected

View file

@ -42,7 +42,7 @@ async def test_sampling_callback():
return True
# Test with sampling callback
async with Client(server, sampling_callback=sampling_callback) as client:
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
# Make a request to trigger sampling callback
result = await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert result.is_error is False
@ -52,7 +52,7 @@ async def test_sampling_callback():
# Without a sampling callback the client responds with an MCPError, which the
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
# error rather than wrapping it as an isError result.
async with Client(server) as client:
async with Client(server, mode="legacy") as client:
with pytest.raises(MCPError) as exc_info:
await client.call_tool("test_sampling", {"message": "Test message for sampling"})
assert exc_info.value.error.code == INVALID_REQUEST
@ -93,7 +93,7 @@ async def test_create_message_backwards_compat_single_content():
assert not hasattr(result, "content_as_list") or not callable(getattr(result, "content_as_list", None))
return True
async with Client(server, sampling_callback=sampling_callback) as client:
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
result = await client.call_tool("test_backwards_compat", {"message": "Test"})
assert result.is_error is False
assert isinstance(result.content[0], TextContent)

View file

@ -18,14 +18,15 @@ from mcp.shared.dispatcher import CallOptions, DispatchContext, OnNotify, OnRequ
from mcp.shared.message import SessionMessage
from mcp.shared.session import RequestResponder
from mcp.shared.transport_context import TransportContext
from mcp.shared.version import SUPPORTED_PROTOCOL_VERSIONS
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION
from mcp.types import (
CONNECTION_CLOSED,
INTERNAL_ERROR,
INVALID_PARAMS,
LATEST_PROTOCOL_VERSION,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
REQUEST_TIMEOUT,
UNSUPPORTED_PROTOCOL_VERSION,
CallToolResult,
Implementation,
InitializedNotification,
@ -86,7 +87,7 @@ async def test_client_session_initialize():
assert isinstance(request, InitializeRequest)
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(
logging=None,
resources=None,
@ -139,7 +140,7 @@ async def test_client_session_initialize():
# Assert the result
assert isinstance(result, InitializeResult)
assert result.protocol_version == LATEST_PROTOCOL_VERSION
assert result.protocol_version == LATEST_HANDSHAKE_VERSION
assert isinstance(result.capabilities, ServerCapabilities)
assert result.server_info == Implementation(name="mock-server", version="0.1.0")
assert result.instructions == "The server instructions."
@ -170,7 +171,7 @@ async def test_client_session_custom_client_info():
received_client_info = request.params.client_info
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
@ -227,7 +228,7 @@ async def test_client_session_default_client_info():
received_client_info = request.params.client_info
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
@ -276,8 +277,8 @@ async def test_client_session_version_negotiation_success():
)
assert isinstance(request, InitializeRequest)
# Verify client sent the latest protocol version
assert request.params.protocol_version == LATEST_PROTOCOL_VERSION
# Verify client offers the newest handshake protocol version
assert request.params.protocol_version == LATEST_HANDSHAKE_VERSION
# Server responds with a supported older version
result = InitializeResult(
@ -313,7 +314,7 @@ async def test_client_session_version_negotiation_success():
# Assert the result with negotiated version
assert isinstance(result, InitializeResult)
assert result.protocol_version == "2024-11-05"
assert result.protocol_version in SUPPORTED_PROTOCOL_VERSIONS
assert result.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS
@pytest.mark.anyio
@ -385,7 +386,7 @@ async def test_client_capabilities_default():
received_capabilities = request.params.capabilities
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
@ -456,7 +457,7 @@ async def test_client_capabilities_with_custom_callbacks():
received_capabilities = request.params.capabilities
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
@ -535,7 +536,7 @@ async def test_client_capabilities_with_sampling_tools():
received_capabilities = request.params.capabilities
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
@ -603,7 +604,7 @@ async def test_initialize_result():
assert isinstance(request, InitializeRequest)
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=expected_capabilities,
server_info=expected_server_info,
instructions=expected_instructions,
@ -642,7 +643,12 @@ async def test_initialize_result():
assert result.server_info == expected_server_info
assert result.capabilities == expected_capabilities
assert result.instructions == expected_instructions
assert result.protocol_version == LATEST_PROTOCOL_VERSION
assert result.protocol_version == LATEST_HANDSHAKE_VERSION
# Era-neutral accessors are populated from the InitializeResult.
assert session.server_info == expected_server_info
assert session.server_capabilities == expected_capabilities
assert session.instructions == expected_instructions
assert session.protocol_version == LATEST_HANDSHAKE_VERSION
@pytest.mark.anyio
@ -665,7 +671,7 @@ async def test_client_tool_call_with_meta(meta: RequestParamsMeta | None):
assert isinstance(request, InitializeRequest)
result = InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
@ -784,10 +790,12 @@ async def test_receive_loop_drops_unknown_notification_method_without_response()
def _set_negotiated_version(session: ClientSession, version: str) -> None:
"""Force `session.protocol_version` without running the handshake."""
session._initialize_result = InitializeResult(
protocol_version=version,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
session.adopt(
InitializeResult(
protocol_version=version,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
)
@ -1300,6 +1308,25 @@ async def test_dispatcher_keyword_request_timeout_bounds_wait_for_never_run_peer
assert exc.value.error.code == REQUEST_TIMEOUT
def test_adopt_raises_when_no_mutual_modern_version_is_supported() -> None:
"""SDK-defined: ``adopt(DiscoverResult)`` picks the newest version both sides support; an
empty intersection is unrecoverable and raises rather than installing a stamp."""
client_d, _ = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_d)
with pytest.raises(RuntimeError, match="No mutually supported modern protocol version"):
session.adopt(
types.DiscoverResult(
supported_versions=["1999-01-01"],
capabilities=types.ServerCapabilities(),
server_info=types.Implementation(name="s", version="0"),
result_type="complete",
ttl_ms=0,
cache_scope="public",
)
)
assert session.protocol_version is None
@pytest.mark.anyio
async def test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_leave_it_unset():
"""`send_request` passes `cancel_on_abandon=False` for `initialize` — the spec forbids
@ -1327,13 +1354,13 @@ async def test_initialize_opts_out_of_cancel_on_abandon_while_other_requests_lea
self.calls.append((method, opts or {}))
if method == "initialize":
return InitializeResult(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
return {}
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
pass
dispatcher = RecordingDispatcher()
@ -1387,7 +1414,7 @@ async def test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly():
) -> dict[str, Any]:
raise NotImplementedError
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
raise NotImplementedError
session = ClientSession(dispatcher=NeverStartsDispatcher())
@ -1400,66 +1427,6 @@ async def test_aenter_cancelled_while_dispatcher_starts_unwinds_cleanly():
assert session._task_group is None
@pytest.mark.anyio
async def test_initialize_on_a_stateless_pinned_session_returns_the_synthesized_result_without_any_frame_sent():
"""A session pinned to the 2026-07-28 stateless protocol is born initialized.
The 2026-07-28 lifecycle replaces the initialize handshake with a per-request ``_meta``
envelope, so ``initialize()`` is idempotent and returns a locally-synthesized result
without ever touching the wire.
"""
async with raw_client_session(protocol_version="2026-07-28") as (session, _send, from_client):
result = await session.initialize()
assert result.protocol_version == "2026-07-28"
assert isinstance(result.capabilities, ServerCapabilities)
assert from_client.statistics().current_buffer_used == 0
assert (await session.initialize()) is result
@pytest.mark.anyio
async def test_initialize_on_a_stateful_pin_requests_the_pinned_version():
"""A session pinned to a pre-2026 stateful version still runs the handshake, but the
outgoing ``initialize`` frame requests the pinned version rather than ``LATEST``."""
async with raw_client_session(protocol_version="2025-06-18") as (session, to_client, from_client):
first: list[InitializeResult] = []
async def do_initialize() -> None:
first.append(await session.initialize())
async with anyio.create_task_group() as tg:
tg.start_soon(do_initialize)
out = await from_client.receive()
assert isinstance(out.message, JSONRPCRequest)
assert out.message.params is not None
assert out.message.params["protocolVersion"] == "2025-06-18"
assert session.protocol_version == "2025-06-18"
# Server negotiates a different (older) supported version than the pin requested.
result = InitializeResult(
protocol_version="2025-03-26",
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
)
await to_client.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=out.message.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# Drain the notifications/initialized frame so the buffer-used assertion below
# measures only what the second initialize() emits.
notif = await from_client.receive()
assert isinstance(notif.message, JSONRPCNotification)
# The property reports the negotiated version, not the pin, once the handshake is done.
assert session.protocol_version == "2025-03-26"
# A second call returns the cached result without a second handshake frame.
again = await session.initialize()
assert again is first[0]
assert from_client.statistics().current_buffer_used == 0
@pytest.mark.anyio
async def test_send_notification_after_close_is_dropped_silently():
"""Post-close `send_notification` is fire-and-forget: the notification is dropped,
@ -1476,3 +1443,216 @@ async def test_send_notification_after_close_is_dropped_silently():
finally:
for s in (s2c_send, s2c_recv, c2s_send, c2s_recv):
s.close()
# --- discover() ladder ---
class _ScriptedDispatcher:
"""Records every `send_raw_request` and plays back scripted answers in order.
A script entry that is an `Exception` is raised; a dict is returned."""
def __init__(self, *script: dict[str, Any] | Exception) -> None:
self.calls: list[tuple[str, Mapping[str, Any] | None]] = []
self.notifies: list[str] = []
self._script: list[dict[str, Any] | Exception] = list(script)
async def run(
self,
on_request: OnRequest,
on_notify: OnNotify,
*,
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
) -> None:
task_status.started()
await anyio.sleep_forever()
async def send_raw_request(
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
) -> dict[str, Any]:
self.calls.append((method, params))
item = self._script.pop(0)
if isinstance(item, Exception):
raise item
return item
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
self.notifies.append(method)
def _discover_result_dict() -> dict[str, Any]:
return types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(),
server_info=Implementation(name="stub", version="0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
@pytest.mark.anyio
async def test_initialize_is_idempotent_and_returns_the_cached_result() -> None:
"""A second `initialize()` returns the first call's result by identity and sends nothing
over the wire — the early-return guard short-circuits before the dispatcher is touched."""
init_result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="mock-server", version="0.1.0"),
).model_dump(by_alias=True, mode="json", exclude_none=True)
dispatcher = _ScriptedDispatcher(init_result)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
first = await session.initialize()
second = await session.initialize()
assert first is second
assert [method for method, _ in dispatcher.calls] == ["initialize"]
assert dispatcher.notifies == ["notifications/initialized"]
@pytest.mark.anyio
async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp() -> None:
"""SDK-defined: a successful `server/discover` is adopted and subsequent requests
carry the modern `_meta` envelope (protocol version + client info + capabilities)."""
dispatcher = _ScriptedDispatcher(_discover_result_dict(), {})
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
result = await session.discover()
assert isinstance(result, types.DiscoverResult)
assert session.protocol_version == "2026-07-28"
await session.send_ping()
ping_method, ping_params = dispatcher.calls[-1]
assert ping_method == "ping"
assert ping_params is not None
assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28"
@pytest.mark.anyio
async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None:
"""Spec SHOULD: a -32022 reply that names a mutually-supported version
triggers exactly one retry at that version, and the retry's result is adopted."""
dispatcher = _ScriptedDispatcher(
MCPError(
UNSUPPORTED_PROTOCOL_VERSION,
"unsupported",
data={"supported": ["2026-07-28"], "requested": "2026-07-28"},
),
_discover_result_dict(),
)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
assert session.protocol_version == "2026-07-28"
assert [m for m, _ in dispatcher.calls] == ["server/discover", "server/discover"]
@pytest.mark.anyio
async def test_discover_raises_when_retry_intersection_is_empty() -> None:
"""Spec SHOULD: a -32022 reply whose `supported` list shares nothing with the
client's modern versions is unrecoverable — the original error is re-raised
without a second probe."""
dispatcher = _ScriptedDispatcher(
MCPError(
UNSUPPORTED_PROTOCOL_VERSION,
"unsupported",
data={"supported": ["1999-01-01"], "requested": "2026-07-28"},
),
)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
with pytest.raises(MCPError) as exc:
await session.discover()
assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
@pytest.mark.anyio
@pytest.mark.parametrize("code", [METHOD_NOT_FOUND, REQUEST_TIMEOUT, INTERNAL_ERROR])
async def test_discover_reraises_non_retry_errors_without_falling_back(code: int) -> None:
"""SDK-defined: any error outside the -32022 retry rung propagates verbatim
— `discover()` does not fall back to `initialize()` itself; that is the
caller's policy (`Client.__aenter__`)."""
dispatcher = _ScriptedDispatcher(MCPError(code, "nope"))
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
with pytest.raises(MCPError) as exc:
await session.discover()
assert exc.value.error.code == code
assert session.protocol_version is None
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
assert dispatcher.notifies == []
@pytest.mark.anyio
async def test_discover_validates_the_response_shape_before_adopting() -> None:
"""SDK-defined: the raw response is run through `DiscoverResult` validation
before any state is installed, so a malformed reply leaves the session
un-adopted rather than half-configured."""
dispatcher = _ScriptedDispatcher({"supportedVersions": ["2026-07-28"]})
session = ClientSession(dispatcher=dispatcher)
with anyio.fail_after(5):
async with session:
with pytest.raises(ValidationError):
await session.discover()
assert session.protocol_version is None
@pytest.mark.anyio
async def test_discover_is_idempotent_and_returns_the_cached_result() -> None:
"""SDK-defined: a second `discover()` returns the already-adopted result without
re-probing — the script holds exactly one entry, so a second wire call would
`IndexError` on the empty script."""
dispatcher = _ScriptedDispatcher(_discover_result_dict())
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
first = await session.discover()
assert isinstance(first, types.DiscoverResult)
assert await session.discover() is first
assert session.discover_result is first
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
def test_era_neutral_properties_are_none_before_any_handshake() -> None:
"""SDK-defined: the era-neutral accessors all read as None on a fresh session."""
client_d, _ = create_direct_dispatcher_pair()
session = ClientSession(dispatcher=client_d)
assert session.protocol_version is None
assert session.server_info is None
assert session.server_capabilities is None
assert session.instructions is None
assert session.discover_result is None
assert session.initialize_result is None
@pytest.mark.anyio
async def test_era_neutral_properties_after_discover() -> None:
"""SDK-defined: after `discover()` the era-neutral accessors read from the
DiscoverResult; `initialize_result` stays None."""
raw = types.DiscoverResult(
supported_versions=["2026-07-28"],
capabilities=ServerCapabilities(tools=types.ToolsCapability(list_changed=True)),
server_info=Implementation(name="discovered", version="2.0"),
instructions="hello",
).model_dump(by_alias=True, mode="json", exclude_none=True)
dispatcher = _ScriptedDispatcher(raw)
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
await session.discover()
assert session.protocol_version == "2026-07-28"
assert session.server_info == Implementation(name="discovered", version="2.0")
assert session.server_capabilities == ServerCapabilities(tools=types.ToolsCapability(list_changed=True))
assert session.instructions == "hello"
assert session.initialize_result is None
assert isinstance(session.discover_result, types.DiscoverResult)
@pytest.mark.anyio
async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None:
"""SDK-defined: a -32022 reply whose `data` is not a valid
`UnsupportedProtocolVersionErrorData` payload is unrecoverable — the original
error is re-raised without a retry probe."""
dispatcher = _ScriptedDispatcher(MCPError(UNSUPPORTED_PROTOCOL_VERSION, "unsupported", data="not-an-object"))
with anyio.fail_after(5):
async with ClientSession(dispatcher=dispatcher) as session:
with pytest.raises(MCPError) as exc:
await session.discover()
assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
assert [m for m, _ in dispatcher.calls] == ["server/discover"]

View file

@ -121,7 +121,7 @@ async def test_overlapping_sampling_requests_are_serviced_concurrently_by_the_cl
stop_reason="endTurn",
)
async with Client(server, sampling_callback=sampling_callback) as client:
async with Client(server, sampling_callback=sampling_callback, mode="legacy") as client:
with anyio.fail_after(5):
async with anyio.create_task_group() as task_group: # pragma: no branch

View file

@ -1,9 +1,9 @@
"""Unit tests for the streamable-HTTP client transport.
The full client<->server round trip is pinned by the interaction suite under
tests/interaction/transports/; these tests cover the transport's per-message header
derivation directly because the headers are an HTTP-seam observation the public client
never exposes.
tests/interaction/transports/; these tests cover the transport's header encoding and the
per-message metadata-headers merge directly because the headers are an HTTP-seam observation
the public client never exposes.
"""
import base64
@ -14,56 +14,10 @@ import httpx
import pytest
from inline_snapshot import snapshot
from mcp.client import ClientSession
from mcp.client.streamable_http import (
MCP_PROTOCOL_VERSION,
StreamableHTTPTransport,
_encode_header_value,
streamable_http_client,
)
from mcp.types import JSONRPCMessage, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
@pytest.mark.parametrize(
("message", "expected"),
[
(
JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}}),
snapshot({"mcp-method": "tools/call", "mcp-name": "add"}),
),
(
JSONRPCRequest(jsonrpc="2.0", id=2, method="tools/list", params={}),
snapshot({"mcp-method": "tools/list"}),
),
(
JSONRPCNotification(jsonrpc="2.0", method="notifications/cancelled"),
snapshot({"mcp-method": "notifications/cancelled"}),
),
(
JSONRPCResponse(jsonrpc="2.0", id=3, result={}),
snapshot({}),
),
],
)
def test_per_message_headers_for_pinned_transport_carry_method_and_name(
message: JSONRPCMessage, expected: dict[str, str]
) -> None:
"""A 2026-07-28-pinned transport derives ``Mcp-Method`` (and ``Mcp-Name`` for tools/call) from the body.
``MCP-Protocol-Version`` is not in the per-message set: ``_prepare_headers()`` adds it from the
pin for every request, so only the method/name advisory headers vary per POST. Responses yield
nothing because the spec only defines the headers for requests and notifications.
"""
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")
assert transport._per_message_headers(message) == expected # pyright: ignore[reportPrivateUsage]
@pytest.mark.parametrize("protocol_version", [None, "2025-11-25"])
def test_per_message_headers_are_empty_for_legacy_or_unpinned_transport(protocol_version: str | None) -> None:
"""An unpinned or 2025-era transport emits no per-message headers, keeping the wire byte-identical to v1."""
transport = StreamableHTTPTransport("http://test/mcp", protocol_version=protocol_version)
message = JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/call", params={"name": "add", "arguments": {}})
assert transport._per_message_headers(message) == {} # pyright: ignore[reportPrivateUsage]
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.inbound import MCP_PROTOCOL_VERSION_HEADER, encode_header_value
from mcp.shared.message import ClientMessageMetadata, SessionMessage
from mcp.types import METHOD_NOT_FOUND, JSONRPCError, JSONRPCNotification, JSONRPCRequest, JSONRPCResponse
@pytest.mark.parametrize(
@ -89,7 +43,7 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field
or trailing space is wrapped because RFC 7230 forbids it in field-values (h11 rejects on real
transports); an empty value is allowed and passes verbatim.
"""
encoded = _encode_header_value(raw)
encoded = encode_header_value(raw)
assert encoded == expected
if wrapped:
assert encoded.startswith("=?base64?") and encoded.endswith("?=")
@ -99,77 +53,104 @@ def test_mcp_name_header_values_are_base64_wrapped_when_unsafe_for_an_http_field
@pytest.mark.anyio
async def test_pinned_transport_ignores_returned_session_id_and_never_opens_get_or_delete() -> None:
"""A server-issued ``Mcp-Session-Id`` never reaches a pinned client's wire: only POSTs are sent.
async def test_post_request_merges_per_message_metadata_headers() -> None:
"""`ClientMessageMetadata.headers` on a `SessionMessage` are merged into the outgoing POST headers
(SDK-defined: the headers sidecar is the path the session uses to reach the transport)."""
recorded: list[httpx.Request] = []
The session-id capture, the standalone GET listening stream, and the DELETE-on-close are all
gated implicitly: a pinned ``ClientSession`` never sends ``initialize`` (no InitializeResult to
capture an id from) and never sends ``notifications/initialized`` (which is what triggers the
standalone GET), so even when a misbehaving peer volunteers a session id on every response the
recorded log stays POST-only and no request echoes the id back. The successful ``tools/call``
triggers the client's implicit ``tools/list`` output-schema fetch so there is a second POST
after the id was offered.
def handler(request: httpx.Request) -> httpx.Response:
recorded.append(request)
body = json.loads(request.content)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="tools/list", params={}),
metadata=ClientMessageMetadata(headers={"x-test": "v"}),
)
)
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert [r.method for r in recorded] == ["POST"]
assert recorded[0].headers["x-test"] == "v"
@pytest.mark.anyio
async def test_pre_session_bare_404_maps_to_method_not_found() -> None:
"""A bare HTTP 404 (no JSON-RPC body) before any session-id is held maps to METHOD_NOT_FOUND.
Gateways and legacy servers 404 at the HTTP layer for unknown methods; with no session yet,
"Session terminated" is meaningless, and the discover→initialize fallback ladder keys on -32601.
"""
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(404)
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={})))
reply = await read.receive()
assert isinstance(reply, SessionMessage)
assert isinstance(reply.message, JSONRPCError)
assert reply.message.error.code == METHOD_NOT_FOUND
@pytest.mark.anyio
async def test_initialize_post_clears_cached_pv_header_and_unstamped_posts_read_it() -> None:
"""``initialize`` discards the cached protocol-version header; every other POST reads it.
Steps:
1. A stamped probe POST caches its ``MCP-Protocol-Version`` header.
2. An ``initialize`` POST clears that cache before building headers, so the fallback
handshake never carries a probe-stamped value.
3. A subsequent stamped POST re-seeds the cache with the negotiated version.
4. An unstamped POST (a JSON-RPC response written by the dispatcher, which never
passes through the session's stamp) then reads the cache and carries the
negotiated version — the spec MUST for all post-initialization HTTP requests.
"""
recorded: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
recorded.append(request)
body = json.loads(request.content)
if body["method"] == "tools/list":
result: dict[str, object] = {
"tools": [{"name": "add", "inputSchema": {"type": "object"}}],
"resultType": "complete",
"ttlMs": 0,
"cacheScope": "public",
}
else:
result = {"content": [{"type": "text", "text": "5"}], "isError": False, "resultType": "complete"}
return httpx.Response(
200, json={"jsonrpc": "2.0", "id": body["id"], "result": result}, headers={"mcp-session-id": "srv-123"}
)
if "id" not in body or "result" in body:
return httpx.Response(202)
return httpx.Response(200, json={"jsonrpc": "2.0", "id": body["id"], "result": {}})
with anyio.fail_after(5):
async with (
httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http,
streamable_http_client("http://test/mcp", http_client=http, protocol_version="2026-07-28") as (read, write),
ClientSession(read, write, protocol_version="2026-07-28") as session,
streamable_http_client("http://test/mcp", http_client=http) as (read, write),
):
await session.call_tool("add", {"a": 2, "b": 3})
await write.send(
SessionMessage(
message=JSONRPCRequest(jsonrpc="2.0", id=1, method="server/discover", params={}),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2026-07-28"}),
)
)
await read.receive()
await write.send(SessionMessage(JSONRPCRequest(jsonrpc="2.0", id=2, method="initialize", params={})))
await read.receive()
await write.send(
SessionMessage(
message=JSONRPCNotification(jsonrpc="2.0", method="notifications/initialized"),
metadata=ClientMessageMetadata(headers={MCP_PROTOCOL_VERSION_HEADER: "2025-11-25"}),
)
)
# An unstamped JSON-RPC response — what the dispatcher writes when answering
# a server-initiated request (sampling/elicitation/roots).
await write.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=99, result={})))
assert [r.method for r in recorded] == snapshot(["POST", "POST"])
assert all("mcp-session-id" not in r.headers for r in recorded)
def test_modern_constructor_pin_is_not_overwritten_by_an_initialize_result() -> None:
"""A 2026-07-28+ pin wins over the InitializeResult snoop (no initialize is ever sent)."""
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2026-07-28")
init = JSONRPCResponse(
jsonrpc="2.0",
id=1,
result={
"protocolVersion": "2025-11-25",
"capabilities": {},
"serverInfo": {"name": "s", "version": "0"},
},
)
transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage]
assert transport.protocol_version == "2026-07-28"
def test_stateful_constructor_pin_is_ignored_and_the_negotiated_version_wins() -> None:
"""A pre-2026 pin is a session-layer concern; the transport must not stamp it on the
initialize request and must adopt the server's negotiated version for later headers."""
transport = StreamableHTTPTransport("http://test/mcp", protocol_version="2025-06-18")
assert MCP_PROTOCOL_VERSION not in transport._prepare_headers() # pyright: ignore[reportPrivateUsage]
init = JSONRPCResponse(
jsonrpc="2.0",
id=1,
result={
"protocolVersion": "2025-03-26",
"capabilities": {},
"serverInfo": {"name": "s", "version": "0"},
},
)
transport._maybe_extract_protocol_version_from_message(init) # pyright: ignore[reportPrivateUsage]
assert transport.protocol_version == "2025-03-26"
assert transport._prepare_headers()[MCP_PROTOCOL_VERSION] == "2025-03-26" # pyright: ignore[reportPrivateUsage]
assert [r.method for r in recorded] == ["POST", "POST", "POST", "POST"]
assert recorded[0].headers[MCP_PROTOCOL_VERSION_HEADER] == "2026-07-28"
assert MCP_PROTOCOL_VERSION_HEADER not in recorded[1].headers
assert recorded[2].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"
assert recorded[3].headers[MCP_PROTOCOL_VERSION_HEADER] == "2025-11-25"

View file

@ -75,13 +75,13 @@ async def test_with_mcpserver(mcpserver_server: MCPServer):
async def test_server_is_running(mcpserver_server: MCPServer):
"""Test that the server is running and responding to requests."""
async with Client(mcpserver_server) as client:
assert client.initialize_result.capabilities.tools is not None
async with Client(mcpserver_server, mode="legacy") as client:
assert client.server_capabilities.tools is not None
async def test_list_tools(mcpserver_server: MCPServer):
"""Test listing tools through the transport."""
async with Client(mcpserver_server) as client:
async with Client(mcpserver_server, mode="legacy") as client:
tools_result = await client.list_tools()
assert len(tools_result.tools) > 0
tool_names = [t.name for t in tools_result.tools]
@ -90,7 +90,7 @@ async def test_list_tools(mcpserver_server: MCPServer):
async def test_call_tool(mcpserver_server: MCPServer):
"""Test calling a tool through the transport."""
async with Client(mcpserver_server) as client:
async with Client(mcpserver_server, mode="legacy") as client:
result = await client.call_tool("greet", {"name": "World"})
assert result is not None
assert len(result.content) > 0