115 lines
5.3 KiB
Python
115 lines
5.3 KiB
Python
"""Connect-time era negotiation for ``mode='auto'``.
|
|
|
|
The ``server/discover`` probe is sent at the newest modern version. Anything
|
|
that is not positive evidence the peer is a modern MCP server falls back to
|
|
the legacy ``initialize`` handshake — a *denylist* (only the disjoint-modern
|
|
case raises) rather than an allowlist of fallback codes.
|
|
|
|
Every ``MCPError`` falls back except ``-32022`` with a disjoint modern-only
|
|
``supported`` list. The streamable-HTTP transport already maps HTTP-layer
|
|
4xx rejections (no JSON-RPC body) into ``MCPError`` codes, so those reach
|
|
the same path. Any non-``MCPError`` exception (network/connection errors,
|
|
anyio cancellation) propagates to the caller; an outage or in-process bug
|
|
is never an era verdict.
|
|
|
|
A successful ``DiscoverResult`` whose ``supportedVersions`` shares no modern
|
|
version with this client is treated the same way: the server speaks discover
|
|
but advertises only handshake-era versions, which is a legacy advertisement,
|
|
not an incompatibility.
|
|
|
|
The fallback handshake itself can be answered with ``-32022`` — e.g. a probe
|
|
that timed out client-side but succeeded on a slow-starting server locked the
|
|
connection modern before the pipelined ``initialize`` arrived. That code is
|
|
itself positive modern evidence (it names the server's versions), so it
|
|
triggers one re-probe at a mutual version instead of failing the connect.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import mcp_types as types
|
|
from mcp_types import UNSUPPORTED_PROTOCOL_VERSION
|
|
from mcp_types.version import (
|
|
HANDSHAKE_PROTOCOL_VERSIONS,
|
|
LATEST_MODERN_VERSION,
|
|
MODERN_PROTOCOL_VERSIONS,
|
|
)
|
|
from pydantic import ValidationError
|
|
|
|
from mcp.client.session import ClientSession
|
|
from mcp.shared.exceptions import MCPError
|
|
|
|
|
|
def _parse_supported(data: Any) -> list[str] | None:
|
|
"""Pull ``data.supported`` off a -32022 error, or ``None`` if not actionable."""
|
|
try:
|
|
return types.UnsupportedProtocolVersionErrorData.model_validate(data).supported
|
|
except ValidationError:
|
|
return None
|
|
|
|
|
|
async def negotiate_auto(session: ClientSession) -> None:
|
|
"""Drive the ``mode='auto'`` connect-time policy on ``session``.
|
|
|
|
Probes ``server/discover`` once (twice if the server names a mutual
|
|
modern version via -32022), then either ``adopt()``s the result or falls
|
|
back to ``initialize()``. Idempotent only in the sense that one of
|
|
``session.discover_result`` / ``session.initialize_result`` is set on
|
|
return.
|
|
|
|
Raises:
|
|
MCPError: The server is modern-only and shares no version with this
|
|
client (-32022 with a disjoint ``supported`` list), or the
|
|
fallback handshake failed and one corrective re-probe did too.
|
|
Exception: Any transport/network error from the probe propagates as-is.
|
|
"""
|
|
version = LATEST_MODERN_VERSION
|
|
for attempt in range(2):
|
|
try:
|
|
raw = await session.send_discover(version)
|
|
except MCPError as e:
|
|
if e.code == UNSUPPORTED_PROTOCOL_VERSION:
|
|
supported = _parse_supported(e.error.data)
|
|
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())]
|
|
if mutual and attempt == 0:
|
|
version = mutual[-1]
|
|
continue
|
|
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
|
|
raise # server is modern-only and disjoint — real incompatibility
|
|
try:
|
|
await session.initialize() # every other rpc-error → legacy (the denylist)
|
|
except MCPError as handshake_exc:
|
|
if handshake_exc.code != UNSUPPORTED_PROTOCOL_VERSION or attempt != 0:
|
|
raise
|
|
# -32022 from the handshake is itself modern evidence: a probe
|
|
# that timed out client-side but succeeded on the server locked
|
|
# the connection modern before this initialize arrived. Re-probe
|
|
# once at a version the server names; the era is already
|
|
# settled, so the second probe answers without the slow start.
|
|
supported = _parse_supported(handshake_exc.error.data)
|
|
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())]
|
|
if not mutual:
|
|
raise
|
|
version = mutual[-1]
|
|
continue
|
|
return
|
|
# any other exception (httpx2.TransportError, ConnectionError,
|
|
# anyio errors) → propagate
|
|
try:
|
|
result = types.DiscoverResult.model_validate(raw)
|
|
except ValidationError:
|
|
await session.initialize() # unparseable result → not modern evidence
|
|
return
|
|
if not any(v in result.supported_versions for v in MODERN_PROTOCOL_VERSIONS):
|
|
# A discover-answering server that advertises no modern version
|
|
# (go-sdk's stateful streamable default does this) is an explicit
|
|
# legacy advertisement: fall back like the -32022 branch above
|
|
# instead of letting `adopt()` raise. The ts and go clients fall
|
|
# back here too.
|
|
await session.initialize()
|
|
return
|
|
session.adopt(result)
|
|
return
|
|
raise AssertionError("unreachable") # pragma: no cover — loop body always returns or raises
|