Skip to content

Commit a7d1275

Browse files
committed
Add negotiate_auto: denylist probe classifier for mode='auto'
The mode='auto' connect path now goes through client/_probe.py's negotiate_auto, which inverts the previous allowlist into a denylist: every MCPError from the server/discover probe falls back to initialize(), the sole exception being -32022 with a disjoint modern-only supported list. An unparseable probe result also falls back. Any non-MCPError exception (network/connection errors, anyio resource errors) propagates — an outage is never an era verdict. ClientSession gains send_discover(version) (the raw probe, no retry, no adopt), and discover() is reimplemented on top of it. The __aenter__ mode='auto' arm collapses to a single negotiate_auto call. tests/client/test_probe.py covers the verdict table directly; the interaction-suite fallback test broadens to a parametrized rpc-error set, and the previous "INTERNAL_ERROR raises" assertion is replaced with a network-error case (under the denylist, INTERNAL_ERROR now falls back).
1 parent eab740b commit a7d1275

7 files changed

Lines changed: 385 additions & 67 deletions

File tree

src/mcp/client/_probe.py

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
"""Connect-time era negotiation for ``mode='auto'``.
2+
3+
The ``server/discover`` probe is sent at the newest modern version. Anything
4+
that is not positive evidence the peer is a modern MCP server falls back to
5+
the legacy ``initialize`` handshake — a *denylist* (only the disjoint-modern
6+
case raises) rather than an allowlist of fallback codes.
7+
8+
Every ``MCPError`` falls back except ``-32022`` with a disjoint modern-only
9+
``supported`` list. The streamable-HTTP transport already maps HTTP-layer
10+
4xx rejections (no JSON-RPC body) into ``MCPError`` codes, so those reach
11+
the same path. Any non-``MCPError`` exception (network/connection errors,
12+
anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual)
13+
propagates to the caller; an outage or in-process bug is never an era verdict.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
from typing import Any
19+
20+
from pydantic import ValidationError
21+
22+
from mcp import types
23+
from mcp.client.session import ClientSession
24+
from mcp.shared.exceptions import MCPError
25+
from mcp.shared.version import (
26+
HANDSHAKE_PROTOCOL_VERSIONS,
27+
LATEST_MODERN_VERSION,
28+
MODERN_PROTOCOL_VERSIONS,
29+
)
30+
from mcp.types import UNSUPPORTED_PROTOCOL_VERSION
31+
32+
33+
def _parse_supported(data: Any) -> list[str] | None:
34+
"""Pull ``data.supported`` off a -32022 error, or ``None`` if not actionable."""
35+
try:
36+
return types.UnsupportedProtocolVersionErrorData.model_validate(data).supported
37+
except ValidationError:
38+
return None
39+
40+
41+
async def negotiate_auto(session: ClientSession) -> None:
42+
"""Drive the ``mode='auto'`` connect-time policy on ``session``.
43+
44+
Probes ``server/discover`` once (twice if the server names a mutual
45+
modern version via -32022), then either ``adopt()``s the result or falls
46+
back to ``initialize()``. Idempotent only in the sense that one of
47+
``session.discover_result`` / ``session.initialize_result`` is set on
48+
return.
49+
50+
Raises:
51+
MCPError: The server is modern-only and shares no version with this
52+
client (-32022 with a disjoint ``supported`` list).
53+
Exception: Any transport/network error from the probe propagates as-is.
54+
"""
55+
version = LATEST_MODERN_VERSION
56+
for attempt in range(2):
57+
try:
58+
raw = await session.send_discover(version)
59+
except MCPError as e:
60+
if e.code == UNSUPPORTED_PROTOCOL_VERSION:
61+
supported = _parse_supported(e.error.data)
62+
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())]
63+
if mutual and attempt == 0:
64+
version = mutual[-1]
65+
continue
66+
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
67+
raise # server is modern-only and disjoint — real incompatibility
68+
await session.initialize() # every other rpc-error → legacy (the denylist)
69+
return
70+
# any other exception (httpx.TransportError, ConnectionError, anyio errors,
71+
# RuntimeError from adopt) → propagate
72+
try:
73+
result = types.DiscoverResult.model_validate(raw)
74+
except ValidationError:
75+
await session.initialize() # unparseable result → not modern evidence
76+
return
77+
session.adopt(result)
78+
return
79+
raise AssertionError("unreachable") # pragma: no cover — loop body always returns or raises

src/mcp/client/client.py

Lines changed: 4 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212

1313
from mcp import types
1414
from mcp.client._memory import InMemoryTransport
15+
from mcp.client._probe import negotiate_auto
1516
from mcp.client._transport import Transport
1617
from mcp.client.session import ClientSession, ElicitationFnT, ListRootsFnT, LoggingFnT, MessageHandlerFnT, SamplingFnT
1718
from mcp.client.streamable_http import streamable_http_client
@@ -20,13 +21,10 @@
2021
from mcp.server.runner import modern_on_request
2122
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
2223
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
23-
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
24+
from mcp.shared.exceptions import MCPDeprecationWarning
2425
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
2526
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
2627
from mcp.types import (
27-
INVALID_REQUEST,
28-
METHOD_NOT_FOUND,
29-
REQUEST_TIMEOUT,
3028
CallToolResult,
3129
CompleteResult,
3230
EmptyResult,
@@ -183,8 +181,7 @@ async def main():
183181
client_info: Implementation | None = None
184182
"""Client implementation info to send to server."""
185183

186-
# TODO(maxisbey): flip default to 'auto' once the in-proc test suite is era-decoupled
187-
# and the probe-timeout fallback is transport-aware (stdio→fallback / HTTP→reject).
184+
# TODO(maxisbey): flip default to 'auto' once the in-proc test suite is era-decoupled.
188185
mode: ConnectMode = "legacy"
189186
"""'legacy' performs the initialize handshake. 'auto' probes server/discover and falls back to initialize()
190187
on legacy servers. A modern protocol-version string (e.g. '2026-07-28') adopts that version directly without
@@ -250,15 +247,7 @@ async def __aenter__(self) -> Client:
250247
if self.mode == "legacy":
251248
await session.initialize()
252249
elif self.mode == "auto":
253-
try:
254-
await session.discover()
255-
except MCPError as e:
256-
# TODO(L73): invert this allowlist into a `classify_probe_outcome` denylist —
257-
# fall back on every rpc-error/4xx that isn't a recognized modern error.
258-
if e.code in (METHOD_NOT_FOUND, INVALID_REQUEST, REQUEST_TIMEOUT):
259-
await session.initialize()
260-
else:
261-
raise
250+
await negotiate_auto(session)
262251
else:
263252
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
264253

src/mcp/client/session.py

Lines changed: 31 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -393,6 +393,35 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
393393
self._discover_result = None
394394
self._negotiated_version = result.protocol_version
395395

396+
async def send_discover(self, version: str) -> dict[str, Any]:
397+
"""Send a single ``server/discover`` at ``version`` and return the raw result dict.
398+
399+
No retry, no ``adopt()``. The ``_meta`` envelope and the
400+
``Mcp-Protocol-Version`` header are stamped at ``version`` so the
401+
server-side era router sees a coherent probe. Used by ``discover()`` and
402+
the connect-time auto-negotiation policy.
403+
404+
Raises:
405+
MCPError: The server returned a JSON-RPC error.
406+
ProbeNotRecognized: The transport bounced the request at its own
407+
layer (HTTP 4xx without a JSON-RPC error body).
408+
"""
409+
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
410+
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
411+
params = {
412+
"_meta": {
413+
PROTOCOL_VERSION_META_KEY: version,
414+
CLIENT_INFO_META_KEY: client_info,
415+
CLIENT_CAPABILITIES_META_KEY: capabilities,
416+
}
417+
}
418+
opts: CallOptions = {
419+
"timeout": DISCOVER_TIMEOUT_SECONDS,
420+
"cancel_on_abandon": False,
421+
"headers": {MCP_PROTOCOL_VERSION_HEADER: version},
422+
}
423+
return await self._dispatcher.send_raw_request("server/discover", params, opts)
424+
396425
async def discover(self) -> types.DiscoverResult:
397426
"""Probe `server/discover` and adopt the result.
398427
@@ -412,26 +441,8 @@ async def discover(self) -> types.DiscoverResult:
412441
if self._discover_result is not None:
413442
return self._discover_result
414443

415-
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
416-
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
417-
418-
async def probe(version: str) -> dict[str, Any]:
419-
params = {
420-
"_meta": {
421-
PROTOCOL_VERSION_META_KEY: version,
422-
CLIENT_INFO_META_KEY: client_info,
423-
CLIENT_CAPABILITIES_META_KEY: capabilities,
424-
}
425-
}
426-
opts: CallOptions = {
427-
"timeout": DISCOVER_TIMEOUT_SECONDS,
428-
"cancel_on_abandon": False,
429-
"headers": {MCP_PROTOCOL_VERSION_HEADER: version},
430-
}
431-
return await self._dispatcher.send_raw_request("server/discover", params, opts)
432-
433444
try:
434-
raw = await probe(LATEST_MODERN_VERSION)
445+
raw = await self.send_discover(LATEST_MODERN_VERSION)
435446
except MCPError as e:
436447
if e.code != UNSUPPORTED_PROTOCOL_VERSION:
437448
raise
@@ -443,7 +454,7 @@ async def probe(version: str) -> dict[str, Any]:
443454
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported]
444455
if not mutual:
445456
raise
446-
raw = await probe(mutual[-1])
457+
raw = await self.send_discover(mutual[-1])
447458

448459
result = types.DiscoverResult.model_validate(raw)
449460
self.adopt(result)

tests/client/test_client.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -415,13 +415,14 @@ async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Serve
415415
assert (await client.list_resources()).resources[0].name == "Test Resource"
416416

417417

418-
@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT])
418+
@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR])
419419
async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None:
420-
"""`mode='auto'`: when `server/discover` is rejected with -32601 or -32001,
421-
`Client.__aenter__` runs the legacy `initialize()` handshake and lands at a
422-
handshake-era protocol version. The session itself does not fall back —
423-
that policy lives here. A real `Server` always implements `server/discover`,
424-
so the server side is hand-played."""
420+
"""`mode='auto'`: any JSON-RPC error from `server/discover` makes
421+
`Client.__aenter__` run the legacy `initialize()` handshake and land at a
422+
handshake-era protocol version. The denylist policy treats every server-sent
423+
rpc-error as "not modern" — including INTERNAL_ERROR, since a legacy server
424+
may crash on the unknown method before reaching its router. A real `Server`
425+
always implements `server/discover`, so the server side is hand-played."""
425426
methods_seen: list[str] = []
426427

427428
async def scripted_server(streams: MessageStream) -> None:

0 commit comments

Comments
 (0)