Files
modelcontextprotocol--pytho…/tests/client/test_probe.py
T
wehub-resource-sync 49b9bb6724
Deploy Docs / deploy-docs (push) Failing after 1s
Conformance Tests / client-conformance (push) Failing after 3s
Conformance Tests / server-conformance (push) Failing after 1s
GitHub Actions Security Analysis / zizmor (push) Failing after 1s
CI / checks (push) Failing after 59m20s
CI / all-green (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 12:10:27 +08:00

322 lines
13 KiB
Python

"""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 mcp_types as types
import pytest
from mcp_types import (
INTERNAL_ERROR,
INVALID_REQUEST,
METHOD_NOT_FOUND,
PARSE_ERROR,
REQUEST_TIMEOUT,
UNSUPPORTED_PROTOCOL_VERSION,
Implementation,
ServerCapabilities,
)
from mcp_types.version import (
HANDSHAKE_PROTOCOL_VERSIONS,
LATEST_MODERN_VERSION,
MODERN_PROTOCOL_VERSIONS,
)
from mcp.client._probe import _parse_supported, negotiate_auto
from mcp.client.session import ClientSession
from mcp.shared.exceptions import MCPError
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` raises the next entry of an optional `handshake` exception
script (succeeding once it is exhausted) and records its calls; `adopt`
just records.
"""
def __init__(self, *script: dict[str, Any] | Exception, handshake: list[Exception] | None = None) -> None:
self._script: list[dict[str, Any] | Exception] = list(script)
self._handshake: list[Exception] = list(handshake or [])
self.probed_at: list[str] = []
self.initialize_calls: int = 0
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.initialize_calls += 1
if self._handshake:
raise self._handshake.pop(0)
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
# --- -32022 from the fallback handshake: modern evidence, one re-probe ---
async def test_handshake_unsupported_after_a_timed_out_probe_reprobes_and_adopts() -> None:
"""A probe that times out client-side but succeeds on a slow-starting
server locks the connection modern, so the fallback handshake answers
-32022. That code is itself modern evidence: re-probe once at a version
the server names and adopt - the connect must not fail."""
session = _StubSession(
MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out"),
_discover_dict(),
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS))],
)
await _negotiate(session)
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
assert session.adopted is not None
assert session.initialize_calls == 1
assert not session.initialized
@pytest.mark.parametrize(
"data",
[
pytest.param({"supported": ["2099-01-01"], "requested": LATEST_MODERN_VERSION}, id="disjoint"),
pytest.param(None, id="no-data"),
],
)
async def test_handshake_unsupported_without_a_mutual_version_reraises(data: Any) -> None:
"""-32022 from the handshake naming no version we speak (or nothing
parseable) leaves nothing to retry with - the error propagates."""
session = _StubSession(
MCPError(code=METHOD_NOT_FOUND, message="nope"),
handshake=[MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="already modern", data=data)],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
assert session.adopted is None
assert not session.initialized
async def test_handshake_unsupported_reprobes_at_most_once() -> None:
"""The handshake-driven re-probe is bounded: if the second attempt also
ends in a timed-out probe and a -32022 handshake, the -32022 propagates
instead of looping."""
timeout = MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out")
session = _StubSession(
timeout,
timeout,
handshake=[_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 session.initialize_calls == 2
async def test_any_other_handshake_error_propagates_unchanged() -> None:
"""A non--32022 error from the fallback handshake is a real handshake
failure, not era evidence - it propagates without a re-probe."""
session = _StubSession(
MCPError(code=METHOD_NOT_FOUND, message="nope"),
handshake=[MCPError(code=INTERNAL_ERROR, message="handshake broke")],
)
with pytest.raises(MCPError) as exc_info:
await _negotiate(session)
assert exc_info.value.code == INTERNAL_ERROR
assert session.probed_at == [LATEST_MODERN_VERSION]
# --- 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