Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
54 changed files with 2533 additions and 993 deletions
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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
242
tests/client/test_probe.py
Normal 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
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
369
tests/interaction/lowlevel/test_client_connect.py
Normal file
369
tests/interaction/lowlevel/test_client_connect.py
Normal 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
|
||||
|
|
@ -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": ""})
|
||||
|
|
|
|||
|
|
@ -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={}))
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in a new issue