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
54 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

View file

@ -31,8 +31,8 @@ from mcp.server.sse import SseServerTransport
from mcp.server.streamable_http import EventStore
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from mcp.server.transport_security import TransportSecuritySettings
from mcp.shared.version import LATEST_HANDSHAKE_VERSION, MODERN_PROTOCOL_VERSIONS
from mcp.types import (
LATEST_PROTOCOL_VERSION,
ClientCapabilities,
Implementation,
InitializeRequestParams,
@ -70,7 +70,7 @@ class Connect(Protocol):
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
protocol_version: str = LATEST_PROTOCOL_VERSION,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AbstractAsyncContextManager[Client]: ...
@ -85,11 +85,17 @@ async def connect_in_memory(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
protocol_version: str = LATEST_PROTOCOL_VERSION,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server over the in-memory transport."""
"""Yield a Client connected to the server over the in-memory transport.
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which drives the server through the DirectDispatcher peer-pair
(per-request `serve_one`, no initialize handshake) instead of the legacy stream pair.
"""
async with Client(
server,
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
@ -97,7 +103,6 @@ async def connect_in_memory(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
protocol_version=protocol_version,
) as client:
yield client
@ -117,7 +122,7 @@ async def connect_over_streamable_http(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
protocol_version: str = LATEST_PROTOCOL_VERSION,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's streamable HTTP app, entirely in process.
@ -126,6 +131,10 @@ async def connect_over_streamable_http(
transport-specific tests pass `json_response` to select the other server mode, and the
resumability tests pass an `event_store` (with `retry_interval=0` so the client's
reconnection wait is a no-op).
When `spec_version` is a modern (2026-07-28+) revision the Client is opened with
`mode=<version>`, which adopts a synthesized DiscoverResult instead of running the legacy
initialize handshake.
"""
app = server.streamable_http_app(
stateless_http=stateless_http,
@ -138,7 +147,8 @@ async def connect_over_streamable_http(
server.session_manager.run(),
httpx.AsyncClient(transport=StreamingASGITransport(app), base_url=BASE_URL) as http_client,
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client, protocol_version=protocol_version),
streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client),
mode=spec_version if spec_version in MODERN_PROTOCOL_VERSIONS else "legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,
@ -146,7 +156,6 @@ async def connect_over_streamable_http(
message_handler=message_handler,
client_info=client_info,
elicitation_callback=elicitation_callback,
protocol_version=protocol_version,
) as client,
):
yield client
@ -229,6 +238,9 @@ async def client_via_http(
transport = streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client)
async with Client(
transport,
# Callers assert the legacy HTTP wire shape (session-id header, standalone GET stream,
# closing DELETE); the modern flow is sessionless and would silently change the subject.
mode="legacy",
logging_callback=logging_callback,
message_handler=message_handler,
elicitation_callback=elicitation_callback,
@ -260,14 +272,14 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]:
"""Standard request headers for raw-httpx streamable-HTTP tests.
Every well-formed request carries these (Accept covering both response representations,
Content-Type for POST bodies, MCP-Protocol-Version at the latest revision, and the session
Content-Type for POST bodies, MCP-Protocol-Version at the newest handshake revision, and the session
ID once one exists), so a test that wants to assert a specific rejection only varies the one
header under test.
"""
headers = {
"accept": "application/json, text/event-stream",
"content-type": "application/json",
"mcp-protocol-version": LATEST_PROTOCOL_VERSION,
"mcp-protocol-version": LATEST_HANDSHAKE_VERSION,
}
if session_id is not None:
headers["mcp-session-id"] = session_id
@ -277,7 +289,7 @@ def base_headers(*, session_id: str | None = None) -> dict[str, str]:
def initialize_body(request_id: int = 1) -> dict[str, object]:
"""A wire-level initialize JSON-RPC request body, exactly as an SDK client would send it."""
params = InitializeRequestParams(
protocol_version=LATEST_PROTOCOL_VERSION,
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ClientCapabilities(),
client_info=Implementation(name="raw", version="0.0.0"),
)
@ -345,7 +357,7 @@ async def connect_over_sse(
message_handler: MessageHandlerFnT | None = None,
client_info: Implementation | None = None,
elicitation_callback: ElicitationFnT | None = None,
protocol_version: str = LATEST_PROTOCOL_VERSION,
spec_version: str = LATEST_HANDSHAKE_VERSION,
) -> AsyncIterator[Client]:
"""Yield a Client connected to the server's legacy SSE transport, entirely in process."""
app, _ = build_sse_app(server)
@ -369,6 +381,8 @@ async def connect_over_sse(
transport = sse_client(f"{BASE_URL}/sse", httpx_client_factory=httpx_client_factory)
async with Client(
transport,
# SSE is a legacy-only transport; the modern path has no SSE story.
mode="legacy",
read_timeout_seconds=read_timeout_seconds,
sampling_callback=sampling_callback,
list_roots_callback=list_roots_callback,

View file

@ -63,9 +63,7 @@ CONNECTABLE_TRANSPORTS: tuple[Transport, ...] = ("in-memory", "sse", "streamable
TRANSPORT_SPEC_VERSIONS: dict[Transport, tuple[SpecVersion, ...]] = {
"sse": ("2025-11-25",),
# Temporary lock: the in-memory transport has no modern entry point yet, so it cannot
# negotiate the newer revision. Remove once an in-memory factory for the modern path lands.
"in-memory": ("2025-11-25",),
"in-memory": ("2025-11-25", "2026-07-28"),
# At the newer revision the protocol-version header check runs before the stateless branch is
# taken, so a stateless connection at that revision behaves identically to the stateful one.
# Locked to avoid a redundant matrix column; revisit if the header/stateless ordering changes.
@ -252,6 +250,7 @@ REQUIREMENTS: dict[str, Requirement] = {
source=f"{SPEC_BASE_URL}/basic/lifecycle#initialization",
behavior="The client's name, version, and title are visible to server handlers after initialization.",
removed_in="2026-07-28",
superseded_by="lifecycle:envelope:stamped-on-every-request",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
@ -262,6 +261,7 @@ REQUIREMENTS: dict[str, Requirement] = {
"(sampling, elicitation, roots)."
),
removed_in="2026-07-28",
superseded_by="lifecycle:envelope:stamped-on-every-request",
note="initialize handshake removed at 2026-07-28; per-request _meta envelope replaces it.",
arm_exclusions=(ArmExclusion(reason="requires-session", transport="streamable-http-stateless"),),
),
@ -395,6 +395,82 @@ REQUIREMENTS: dict[str, Requirement] = {
"hosting:http:legacy-no-modern-vocabulary covers the same vocabulary set"
),
),
"lifecycle:envelope:stamped-on-every-request": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic#_meta",
behavior=(
"Every client→server request on a modern-negotiated session carries "
"_meta.{protocolVersion,clientInfo,clientCapabilities}; notifications do not."
),
added_in="2026-07-28",
supersedes=("lifecycle:initialize:client-info", "lifecycle:initialize:client-capabilities"),
),
"lifecycle:envelope:header-matches-meta": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/streamable-http#headers",
behavior="On HTTP, the MCP-Protocol-Version header on every POST matches _meta.protocolVersion in the body.",
transports=("streamable-http", "streamable-http-stateless"),
added_in="2026-07-28",
note="HTTP-only: the header is a streamable-http transport concern; stdio and in-memory carry no headers.",
),
"lifecycle:discover:basic": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#discover",
behavior=(
"Calling discover() sends server/discover with no params and returns a typed DiscoverResult "
"carrying protocolVersion, capabilities, serverInfo and the cache hint fields."
),
added_in="2026-07-28",
),
"lifecycle:discover:retry-on-32022": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/lifecycle#version-errors",
behavior=(
"When server/discover returns -32022 UnsupportedProtocolVersion, the client retries once with "
"the intersection of error.data.supported and its own modern versions; an empty intersection raises."
),
added_in="2026-07-28",
),
"lifecycle:discover:fallback-method-not-found": Requirement(
source=f"{SPEC_2026_BASE_URL}/basic/transports/stdio#backward-compatibility",
behavior=(
"When server/discover returns any JSON-RPC error or a bare HTTP 4xx, an auto-negotiating "
"client falls back to the legacy initialize handshake and the connection succeeds at a "
"handshake-era version (legacy servers reject the probe with various codes)."
),
added_in="2026-07-28",
),
"lifecycle:discover:network-error-raises": Requirement(
source="sdk",
behavior=(
"A network/connection error during server/discover propagates to the caller without "
"falling back to initialize; any rpc-error or 4xx falls back (legacy servers reject the "
"probe with various codes). An outage is never an era verdict."
),
transports=("streamable-http", "streamable-http-stateless"),
added_in="2026-07-28",
note="HTTP-only: distinguishes transport-level failures from server-side rejection.",
),
"lifecycle:mode:legacy-never-probes": Requirement(
source="sdk",
behavior=(
"A Client constructed with mode='legacy' sends initialize as its first request "
"and never sends server/discover."
),
added_in="2026-07-28",
),
"lifecycle:mode:pin-never-handshakes": Requirement(
source="sdk",
behavior=(
"A Client constructed with mode='2026-07-28' sends no initialize and no server/discover; its "
"first wire request is the caller's first call, carrying the full _meta envelope."
),
added_in="2026-07-28",
),
"lifecycle:mode:prior-discover-zero-rtt": Requirement(
source="sdk",
behavior=(
"A Client constructed with prior_discover=<DiscoverResult> sends no negotiation traffic; "
"server_info and capabilities are populated from the prior result."
),
added_in="2026-07-28",
),
# ═══════════════════════════════════════════════════════════════════════════
# Protocol primitives: cancellation, timeout, progress, errors, _meta
# ═══════════════════════════════════════════════════════════════════════════
@ -581,7 +657,9 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a handler during a request are delivered to the caller's "
"progress callback, in order, with their progress, total, and message."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"protocol:progress:token-injected": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -594,7 +672,14 @@ REQUIREMENTS: dict[str, Requirement] = {
"protocol:progress:token-unique": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
behavior=("Concurrent in-flight requests that each supply a progress callback carry distinct progress tokens."),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
note=(
"Tested as the consequence: each callback receives only its own request's progress under "
"interleaved emission. Token distinctness is the JSON-RPC mechanism for that; the in-process "
"direct dispatcher carries the callback per-request without a wire-level token."
),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"protocol:progress:monotonic": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -607,7 +692,9 @@ REQUIREMENTS: dict[str, Requirement] = {
"handler that emits non-increasing values has them forwarded to the callback unchanged."
),
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"protocol:progress:stops-after-completion": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#behavior-requirements",
@ -745,7 +832,9 @@ REQUIREMENTS: dict[str, Requirement] = {
"Log notifications emitted by a tool handler during execution reach the client's logging "
"callback before the tool result returns."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"tools:call:progress": Requirement(
source=f"{SPEC_BASE_URL}/basic/utilities/progress#progress-flow",
@ -753,7 +842,9 @@ REQUIREMENTS: dict[str, Requirement] = {
"Progress notifications emitted by a tool handler reach the caller's progress callback before "
"the tool result returns."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"tools:call:sampling-roundtrip": Requirement(
source=f"{SPEC_BASE_URL}/client/sampling#creating-messages",
@ -974,14 +1065,18 @@ REQUIREMENTS: dict[str, Requirement] = {
"The Context logging helpers (debug/info/warning/error) send log message notifications at the "
"corresponding severity."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"mcpserver:context:progress": Requirement(
source="sdk",
behavior=(
"Context.report_progress sends a progress notification against the requesting client's progress token."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"mcpserver:context:elicit": Requirement(
source="sdk",
@ -1339,7 +1434,9 @@ REQUIREMENTS: dict[str, Requirement] = {
"logging:message:all-levels": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-levels",
behavior="All eight RFC 5424 severity levels are deliverable as log message notifications.",
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"logging:message:fields": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#log-message-notifications",
@ -1347,7 +1444,9 @@ REQUIREMENTS: dict[str, Requirement] = {
"A log message sent by a server handler is delivered to the client's logging callback with its "
"severity level, logger name, and data."
),
known_failures=(KnownFailure(spec_version="2026-07-28", note=_MODERN_NOTIFY_DROP, issue=None),),
known_failures=(
KnownFailure(spec_version="2026-07-28", transport="streamable-http", note=_MODERN_NOTIFY_DROP, issue=None),
),
),
"logging:message:filtered": Requirement(
source=f"{SPEC_BASE_URL}/server/utilities/logging#setting-log-level",
@ -1970,6 +2069,7 @@ REQUIREMENTS: dict[str, Requirement] = {
known_failures=(
KnownFailure(
spec_version="2026-07-28",
transport="streamable-http",
note=(
"List-mutation assertions hold; only the sentinel ctx.info() never reaches the client. "
+ _MODERN_NOTIFY_DROP

View file

@ -470,6 +470,7 @@ async def connect_with_oauth(
)
headless.bind(http_client)
client = await stack.enter_async_context(
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client))
# The auth flow tests snapshot the legacy initialize-handshake HTTP shape.
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http_client), mode="legacy")
)
yield client, headless

View file

@ -104,7 +104,7 @@ async def test_an_unauthenticated_request_is_challenged_then_the_full_oauth_flow
# The first PRM discovery GET carries the protocol-version header (an SDK behaviour, not a
# spec requirement on discovery requests).
prm_get = next(r for r in requests if r.url.path == "/.well-known/oauth-protected-resource/mcp")
assert prm_get.headers.get("mcp-protocol-version") == snapshot("2025-11-25")
assert prm_get.headers.get("mcp-protocol-version") == snapshot("2026-07-28")
authorize = parse_qs(urlsplit(headless.authorize_url).query)
assert authorize["response_type"] == ["code"]

View file

@ -45,4 +45,4 @@ def connect(request: pytest.FixtureRequest) -> Connect:
transport, spec_version = request.param
assert isinstance(transport, str)
assert isinstance(spec_version, str)
return partial(_FACTORIES[transport], protocol_version=spec_version)
return partial(_FACTORIES[transport], spec_version=spec_version)

View file

@ -0,0 +1,369 @@
"""Client connect-time negotiation: mode selection, server/discover, and the per-request envelope.
These tests pin what `Client(..., mode=...)` puts on the wire BEFORE the caller's first call --
the legacy initialize handshake, the modern `server/discover` probe, or nothing at all -- and
that a modern-negotiated session stamps the three-key `io.modelcontextprotocol/*` `_meta`
envelope on every subsequent request. Each test drives the highest public surface (`Client`)
and observes traffic at a recording seam: `RecordingTransport` for the legacy stream pair, and
`mounted_app`'s httpx event hook for the in-process streamable-HTTP transport.
The fallback test alone hand-plays the server's side of the wire, because no real `Server`
answers `server/discover` with -32601.
"""
import json
from collections.abc import AsyncIterator, Awaitable, Callable
from contextlib import asynccontextmanager
import anyio
import httpx
import pytest
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.shared.memory import MessageStream, create_client_server_memory_streams
from mcp.shared.message import SessionMessage
from mcp.shared.version import LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS
from mcp.types import (
CLIENT_CAPABILITIES_META_KEY,
CLIENT_INFO_META_KEY,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PROTOCOL_VERSION_META_KEY,
UNSUPPORTED_PROTOCOL_VERSION,
DiscoverResult,
Implementation,
InitializeResult,
JSONRPCError,
JSONRPCNotification,
JSONRPCRequest,
JSONRPCResponse,
ServerCapabilities,
ToolsCapability,
)
from tests.interaction._connect import BASE_URL, Connect, mounted_app
from tests.interaction._helpers import RecordingTransport
from tests.interaction._requirements import requirement
pytestmark = pytest.mark.anyio
def _tools_server(name: str = "negotiator") -> Server:
"""A low-level server with one list-tools handler, so a feature request has something to reach."""
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
return types.ListToolsResult(tools=[types.Tool(name="noop", input_schema={"type": "object"})])
return Server(name, on_list_tools=list_tools)
def _request_recorder() -> tuple[list[httpx.Request], Callable[[httpx.Request], Awaitable[None]]]:
"""Return a list and an `on_request` hook that appends each outgoing httpx request to it."""
captured: list[httpx.Request] = []
async def on_request(request: httpx.Request) -> None:
captured.append(request)
return captured, on_request
@requirement("lifecycle:mode:legacy-never-probes")
async def test_legacy_mode_sends_initialize_and_never_probes_discover() -> None:
"""`Client(server, mode='legacy')` opens with `initialize` and never sends `server/discover`.
Requirement `lifecycle:mode:legacy-never-probes` (sdk-defined): ``mode='legacy'`` must remain
byte-identical to the pre-2026 client so a 2025-era server never observes modern vocabulary.
"""
recording = RecordingTransport(InMemoryTransport(_tools_server()))
with anyio.fail_after(5):
async with Client(recording, mode="legacy") as client:
await client.list_tools()
sent = [m.message for m in recording.sent]
methods = [m.method for m in sent if isinstance(m, JSONRPCRequest | JSONRPCNotification)]
assert methods[0] == "initialize"
assert "server/discover" not in methods
assert "notifications/initialized" in methods
@requirement("lifecycle:mode:pin-never-handshakes")
async def test_pinned_mode_sends_no_connect_time_traffic() -> None:
"""`Client(..., mode='2026-07-28')` sends nothing on entry; the caller's first call is the first wire request.
Requirement `lifecycle:mode:pin-never-handshakes` (sdk-defined): a version pin adopts a
synthesized DiscoverResult locally, so no `initialize` and no `server/discover` ever cross
the wire. Asserted at the in-process streamable-HTTP seam via the httpx event hook.
"""
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
):
assert requests == [] # entering the Client produced zero HTTP traffic
result = await client.list_tools()
bodies = [json.loads(r.content) for r in requests]
assert [b["method"] for b in bodies] == ["tools/list"]
assert PROTOCOL_VERSION_META_KEY in bodies[0]["params"]["_meta"]
assert [t.name for t in result.tools] == ["noop"]
@requirement("lifecycle:mode:prior-discover-zero-rtt")
async def test_prior_discover_populates_state_with_zero_connect_time_traffic() -> None:
"""`Client(..., mode=<pin>, prior_discover=...)` sends nothing on entry and exposes the prior server_info.
Requirement `lifecycle:mode:prior-discover-zero-rtt` (sdk-defined): a previously-obtained
DiscoverResult is installed via `adopt()` so server_info and capabilities are available
immediately with zero round trips.
"""
prior = DiscoverResult(
supported_versions=[LATEST_MODERN_VERSION],
capabilities=ServerCapabilities(tools=ToolsCapability(list_changed=False)),
server_info=Implementation(name="cached-server", version="9.9.9"),
)
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(
streamable_http_client(f"{BASE_URL}/mcp", http_client=http),
mode=LATEST_MODERN_VERSION,
prior_discover=prior,
) as client,
):
assert requests == []
assert client.server_info == Implementation(name="cached-server", version="9.9.9")
assert client.server_capabilities.tools == ToolsCapability(list_changed=False)
await client.list_tools()
assert [json.loads(r.content)["method"] for r in requests] == ["tools/list"]
@requirement("lifecycle:discover:basic")
async def test_auto_mode_probes_server_discover_and_adopts_the_result() -> None:
"""`Client(..., mode='auto')` sends `server/discover` first and adopts the returned version and server_info.
Requirement `lifecycle:discover:basic` (spec basic/lifecycle#discover): the probe is a
single `server/discover` request whose result carries supported versions, capabilities,
server_info and the cache-hint fields, after which the session is modern-negotiated.
"""
requests, on_request = _request_recorder()
server = _tools_server("discoverable")
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="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
assert client.server_info.name == "discoverable"
await client.list_tools()
bodies = [json.loads(r.content) for r in requests]
assert bodies[0]["method"] == "server/discover"
assert "initialize" not in [b["method"] for b in bodies]
@requirement("lifecycle:discover:retry-on-32022")
async def test_auto_mode_retries_discover_once_on_unsupported_protocol_version() -> None:
"""A -32022 from `server/discover` triggers exactly one retry at the highest mutual modern version.
Requirement `lifecycle:discover:retry-on-32022` (spec basic/lifecycle#version-errors): the
client intersects `error.data.supported` with its own modern versions and re-probes once;
the second success is adopted. The server's `server/discover` handler is overridden to fail
the first call and succeed on the second.
"""
calls: list[str | None] = []
async def discover(ctx: ServerRequestContext, params: types.RequestParams | None) -> DiscoverResult:
proposed = ctx.meta.get(PROTOCOL_VERSION_META_KEY) if ctx.meta else None
calls.append(proposed)
if len(calls) == 1:
raise MCPError(
code=UNSUPPORTED_PROTOCOL_VERSION,
message="unsupported protocol version",
data={"supported": list(MODERN_PROTOCOL_VERSIONS), "requested": proposed},
)
return DiscoverResult(
supported_versions=list(MODERN_PROTOCOL_VERSIONS),
capabilities=ServerCapabilities(),
server_info=Implementation(name="picky", version="1.0.0"),
)
server = _tools_server("picky")
server.add_request_handler("server/discover", types.RequestParams, discover)
requests, on_request = _request_recorder()
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="auto") as client,
):
assert client.protocol_version == LATEST_MODERN_VERSION
assert calls == [LATEST_MODERN_VERSION, LATEST_MODERN_VERSION]
assert [json.loads(r.content)["method"] for r in requests][:2] == ["server/discover", "server/discover"]
@requirement("lifecycle:discover:network-error-raises")
async def test_auto_mode_propagates_a_network_error_from_discover_without_initializing() -> None:
"""A network/connection error during `server/discover` propagates to the caller without falling back.
Requirement `lifecycle:discover:network-error-raises` (sdk-defined): under the denylist policy
every server-sent rpc-error and every transport-layer 4xx falls back to `initialize()`; the
only probe failures that reach the caller are real outages — network errors, anyio resource
errors, and the disjoint-modern -32022 case. Exercised here as an `httpx.ConnectError` from
the underlying transport, which the policy must not classify as an era verdict. The error
reaches the test wrapped in the streamable-http transport's task-group teardown, so
`pytest.RaisesGroup` flattens before matching. The probe POST is recorded before the
transport raises, so the `initialize` fallback observably did not happen.
"""
requests: list[httpx.Request] = []
def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
raise httpx.ConnectError("connection refused")
with anyio.fail_after(5):
async with httpx.AsyncClient(transport=httpx.MockTransport(handler)) as http:
with pytest.RaisesGroup(httpx.ConnectError, flatten_subgroups=True): # pragma: no branch
async with Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto"):
raise NotImplementedError("entering the Client should have raised") # pragma: no cover
assert [json.loads(r.content)["method"] for r in requests] == ["server/discover"]
@requirement("lifecycle:discover:fallback-method-not-found")
@pytest.mark.parametrize(
("probe_code", "probe_message"),
[
(METHOD_NOT_FOUND, "Method not found"),
(INVALID_REQUEST, "Bad Request: Missing session ID"),
],
ids=["method-not-found", "invalid-request"],
)
async def test_auto_mode_falls_back_to_initialize_on_a_legacy_probe_rejection(
probe_code: int, probe_message: str
) -> None:
"""A legacy server's rejection of `server/discover` makes an auto-negotiating client fall back to `initialize`.
Requirement `lifecycle:discover:fallback-method-not-found` (spec stdio#backward-compatibility):
a legacy-era server that does not implement `server/discover` is connected to via the
handshake, and the session lands at a handshake-era protocol version. The probe rejection
arrives as METHOD_NOT_FOUND from a server that routes the unknown method, or as
INVALID_REQUEST from a deployed v1.x stateful streamable-HTTP server that rejects the
session-id-less probe before dispatch. A real `Server` always implements `server/discover`,
so this test plays the server's side of the wire by hand. Reserve this pattern for behaviour
no real server can be made to produce.
"""
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, JSONRPCRequest | JSONRPCNotification)
methods_seen.append(frame.method)
if isinstance(frame, JSONRPCRequest) and frame.method == "server/discover":
error = types.ErrorData(code=probe_code, message=probe_message)
await server_write.send(SessionMessage(JSONRPCError(jsonrpc="2.0", id=frame.id, error=error)))
elif isinstance(frame, JSONRPCRequest) and frame.method == "initialize":
result = InitializeResult(
protocol_version=LATEST_HANDSHAKE_VERSION,
capabilities=ServerCapabilities(),
server_info=Implementation(name="legacy-only", version="0.0.1"),
)
await server_write.send(
SessionMessage(
JSONRPCResponse(
jsonrpc="2.0",
id=frame.id,
result=result.model_dump(by_alias=True, mode="json", exclude_none=True),
)
)
)
# notifications/initialized (and anything else) is observed and ignored.
@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"]
@requirement("lifecycle:envelope:stamped-on-every-request")
async def test_every_request_on_a_modern_session_carries_the_three_key_meta_envelope(connect: Connect) -> None:
"""Each modern-session request's `params._meta` carries protocolVersion, clientInfo and clientCapabilities.
Requirement `lifecycle:envelope:stamped-on-every-request` (spec basic#_meta): the per-request
envelope replaces the initialize handshake's once-per-session exchange. Asserted server-side
by capturing `ctx.meta` inside the handler.
"""
observed: list[dict[str, object]] = []
async def list_tools(
ctx: ServerRequestContext, params: types.PaginatedRequestParams | None
) -> types.ListToolsResult:
assert ctx.meta is not None
observed.append(dict(ctx.meta))
return types.ListToolsResult(tools=[])
server = Server("stamped", on_list_tools=list_tools)
with anyio.fail_after(5):
async with connect(server, client_info=Implementation(name="enveloper", version="1.2.3")) as client:
await client.list_tools()
await client.list_tools()
assert len(observed) == 2
for meta in observed:
assert meta[PROTOCOL_VERSION_META_KEY] == LATEST_MODERN_VERSION
assert meta[CLIENT_INFO_META_KEY] == {"name": "enveloper", "version": "1.2.3"}
assert CLIENT_CAPABILITIES_META_KEY in meta
@requirement("lifecycle:envelope:header-matches-meta")
async def test_http_protocol_version_header_matches_meta_protocol_version_on_every_post() -> None:
"""On streamable-HTTP, the `MCP-Protocol-Version` header on each POST equals `_meta.protocolVersion` in its body.
Requirement `lifecycle:envelope:header-matches-meta` (spec streamable-http#headers): the
body-derived header and the envelope's protocol version are kept in lockstep so the server's
header-based routing and body-based validation never disagree.
"""
requests, on_request = _request_recorder()
with anyio.fail_after(5):
async with (
mounted_app(_tools_server(), on_request=on_request) as (http, _),
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode=LATEST_MODERN_VERSION) as client,
):
await client.list_tools()
await client.list_tools()
assert requests, "no HTTP traffic recorded"
for request in requests:
body = json.loads(request.content)
assert request.headers["mcp-protocol-version"] == body["params"]["_meta"][PROTOCOL_VERSION_META_KEY]
assert request.headers["mcp-protocol-version"] == LATEST_MODERN_VERSION

View file

@ -123,7 +123,7 @@ async def test_complete_without_handler_is_method_not_found(connect: Connect) ->
server = Server("incomplete")
async with connect(server) as client:
assert client.initialize_result.capabilities.completions is None
assert client.server_capabilities.completions is None
with pytest.raises(MCPError) as exc_info:
await client.complete(PromptReference(name="anything"), argument={"name": "topic", "value": ""})

View file

@ -60,7 +60,7 @@ async def test_initialize_returns_server_info(connect: Connect) -> None:
)
async with connect(server) as client:
server_info = client.initialize_result.server_info
server_info = client.server_info
assert server_info == snapshot(
Implementation(
@ -78,10 +78,10 @@ async def test_initialize_returns_server_info(connect: Connect) -> None:
async def test_initialize_returns_instructions(connect: Connect) -> None:
"""Instructions are returned when the server declares them and omitted when it does not."""
async with connect(Server("guided", instructions="Call the add tool.")) as client:
assert client.initialize_result.instructions == snapshot("Call the add tool.")
assert client.instructions == snapshot("Call the add tool.")
async with connect(Server("unguided")) as client:
assert client.initialize_result.instructions is None
assert client.instructions is None
@requirement("lifecycle:initialize:capabilities:from-handlers")
@ -137,7 +137,7 @@ async def test_initialize_capabilities_reflect_registered_handlers(connect: Conn
)
async with connect(server) as client:
capabilities = client.initialize_result.capabilities
capabilities = client.server_capabilities
assert capabilities == snapshot(
ServerCapabilities(
@ -155,7 +155,7 @@ async def test_initialize_capabilities_reflect_registered_handlers(connect: Conn
async def test_initialize_minimal_server_advertises_no_capabilities(connect: Connect) -> None:
"""A server with no feature handlers advertises no feature capabilities."""
async with connect(Server("bare")) as client:
capabilities = client.initialize_result.capabilities
capabilities = client.server_capabilities
assert capabilities == snapshot(ServerCapabilities(experimental={}))

View file

@ -19,7 +19,7 @@ async def test_client_ping_returns_empty_result(connect: Connect) -> None:
server = Server("silent")
async with connect(server) as client:
result = await client.send_ping()
result = await client.send_ping() # pyright: ignore[reportDeprecated]
assert result == snapshot(EmptyResult())

Some files were not shown because too many files have changed in this diff Show more