Client-side 2026-07-28 support: .discover()/.adopt() + Client(mode=); request-metadata green (#2950)
This commit is contained in:
parent
ae13ede143
commit
f226d00d0a
11 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue