Skip to content

Commit 1fbd075

Browse files
committed
ClientSession.discover() with the error ladder; Client mode='auto'
- discover() probes server/discover via the dispatcher (bypassing the stamp), validates the response as DiscoverResult before reading any field, then .adopt()s it - Error ladder: -32022 retries once with the intersection of MODERN and data.supported (re-raises if empty or on second failure); -32601 and REQUEST_TIMEOUT fall back to .initialize(); anything else propagates - Idempotent (mirrors .initialize()) - Client.mode gains 'auto' which calls .discover() in __aenter__ - 9 unit tests cover each ladder rung, idempotency, malformed -32022 data, and the response-validation gate; 1 end-to-end test drives mode='auto' over the in-process ASGI bridge
1 parent 1d33743 commit 1fbd075

4 files changed

Lines changed: 280 additions & 4 deletions

File tree

src/mcp/client/client.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -107,10 +107,10 @@ async def main():
107107
client_info: Implementation | None = None
108108
"""Client implementation info to send to server."""
109109

110-
mode: Literal["legacy"] | str = "legacy"
111-
"""'legacy' performs the initialize handshake. A protocol-version string (e.g. '2026-07-28') adopts that
112-
version directly without a handshake — supply prior_discover to reuse a known DiscoverResult, or omit it
113-
to synthesize a minimal one."""
110+
mode: Literal["legacy", "auto"] | str = "legacy"
111+
"""'legacy' performs the initialize handshake. 'auto' probes server/discover and falls back to initialize()
112+
on legacy servers. A protocol-version string (e.g. '2026-07-28') adopts that version directly without a
113+
handshake — supply prior_discover to reuse a known DiscoverResult, or omit it to synthesize a minimal one."""
114114

115115
prior_discover: types.DiscoverResult | None = None
116116
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
@@ -155,6 +155,8 @@ async def __aenter__(self) -> Client:
155155

156156
if self.mode == "legacy":
157157
await self._session.initialize()
158+
elif self.mode == "auto":
159+
await self._session.discover()
158160
else:
159161
self._session.adopt(self.prior_discover or _synthesize_discover(self.mode))
160162

src/mcp/client/session.py

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,12 +34,15 @@
3434
INTERNAL_ERROR,
3535
METHOD_NOT_FOUND,
3636
PROTOCOL_VERSION_META_KEY,
37+
REQUEST_TIMEOUT,
38+
UNSUPPORTED_PROTOCOL_VERSION,
3739
RequestId,
3840
RequestParamsMeta,
3941
)
4042
from mcp.types import methods as _methods
4143

4244
DEFAULT_CLIENT_INFO = types.Implementation(name="mcp", version="0.1.0")
45+
DISCOVER_TIMEOUT_SECONDS = 10.0
4346

4447
logger = logging.getLogger("client")
4548

@@ -368,6 +371,67 @@ def adopt(self, result: types.InitializeResult | types.DiscoverResult) -> None:
368371
self._stamp = _make_handshake_stamp(result.protocol_version)
369372
self._initialize_result = result
370373

374+
async def discover(self) -> types.InitializeResult:
375+
"""Probe `server/discover` and adopt the result, falling back to `initialize()`.
376+
377+
Sends a single `server/discover` proposing the newest modern protocol
378+
version. The error ladder, in order:
379+
380+
- `UNSUPPORTED_PROTOCOL_VERSION` (-32022): the server's `supported`
381+
list is intersected with `MODERN_PROTOCOL_VERSIONS` and the probe is
382+
retried once at the highest mutual version. No mutual version, or a
383+
second failure, raises the server's `MCPError`.
384+
- `METHOD_NOT_FOUND` (-32601) or `REQUEST_TIMEOUT` (-32001): the server
385+
is treated as legacy and `initialize()` runs instead — exactly as
386+
``mode='legacy'`` would.
387+
- Any other error: re-raised.
388+
389+
Returns the synthesized `InitializeResult` (also available afterwards
390+
via `initialize_result`).
391+
"""
392+
if self._initialize_result is not None:
393+
return self._initialize_result
394+
395+
client_info = self._client_info.model_dump(by_alias=True, mode="json", exclude_none=True)
396+
capabilities = self._build_capabilities().model_dump(by_alias=True, mode="json", exclude_none=True)
397+
398+
async def probe(version: str) -> dict[str, Any]:
399+
params = {
400+
"_meta": {
401+
PROTOCOL_VERSION_META_KEY: version,
402+
CLIENT_INFO_META_KEY: client_info,
403+
CLIENT_CAPABILITIES_META_KEY: capabilities,
404+
}
405+
}
406+
opts: CallOptions = {
407+
"timeout": DISCOVER_TIMEOUT_SECONDS,
408+
"cancel_on_abandon": False,
409+
"headers": {MCP_PROTOCOL_VERSION_HEADER: version},
410+
}
411+
return await self._dispatcher.send_raw_request("server/discover", params, opts)
412+
413+
try:
414+
raw = await probe(MODERN_PROTOCOL_VERSIONS[-1])
415+
except MCPError as e:
416+
if e.code == UNSUPPORTED_PROTOCOL_VERSION:
417+
try:
418+
data = types.UnsupportedProtocolVersionErrorData.model_validate(e.error.data)
419+
except ValidationError:
420+
raise e from None
421+
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in data.supported]
422+
if not mutual:
423+
raise
424+
raw = await probe(mutual[-1])
425+
elif e.code in (METHOD_NOT_FOUND, REQUEST_TIMEOUT):
426+
return await self.initialize()
427+
else:
428+
raise
429+
430+
result = types.DiscoverResult.model_validate(raw)
431+
self.adopt(result)
432+
assert self._initialize_result is not None
433+
return self._initialize_result
434+
371435
@property
372436
def initialize_result(self) -> types.InitializeResult | None:
373437
"""The server's InitializeResult. None until `initialize()` or `adopt()`.

tests/client/test_client.py

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
from mcp import MCPError, types
1515
from mcp.client._memory import InMemoryTransport
1616
from mcp.client.client import Client
17+
from mcp.client.streamable_http import streamable_http_client
1718
from mcp.server import Server, ServerRequestContext
1819
from mcp.server.mcpserver import MCPServer
1920
from mcp.types import (
@@ -37,6 +38,7 @@
3738
Tool,
3839
ToolsCapability,
3940
)
41+
from tests.interaction._connect import BASE_URL, mounted_app
4042

4143
pytestmark = pytest.mark.anyio
4244

@@ -359,3 +361,17 @@ async def check_context() -> str:
359361
assert result.content[0].text == "client_value", ( # type: ignore[union-attr]
360362
"Server handler did not see the sender's contextvars.Context"
361363
)
364+
365+
366+
async def test_client_auto_mode_probes_discover_then_adopts(simple_server: Server) -> None:
367+
"""`mode='auto'` over an in-process HTTP transport: the `server/discover` probe
368+
reaches the modern entry and the negotiated protocol version is adopted without
369+
an `initialize` handshake. Runs over HTTP because the in-memory runner gates
370+
`server/discover` behind the init handshake."""
371+
with anyio.fail_after(5):
372+
async with (
373+
mounted_app(simple_server) as (http, _),
374+
Client(streamable_http_client(f"{BASE_URL}/mcp", http_client=http), mode="auto") as client,
375+
):
376+
assert client.initialize_result.protocol_version == "2026-07-28"
377+
assert (await client.list_resources()).resources[0].name == "Test Resource"

tests/client/test_session.py

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,9 @@
2525
INVALID_PARAMS,
2626
LATEST_PROTOCOL_VERSION,
2727
METHOD_NOT_FOUND,
28+
PROTOCOL_VERSION_META_KEY,
2829
REQUEST_TIMEOUT,
30+
UNSUPPORTED_PROTOCOL_VERSION,
2931
CallToolResult,
3032
Implementation,
3133
InitializedNotification,
@@ -1435,3 +1437,195 @@ async def test_send_notification_after_close_is_dropped_silently():
14351437
finally:
14361438
for s in (s2c_send, s2c_recv, c2s_send, c2s_recv):
14371439
s.close()
1440+
1441+
1442+
# --- discover() ladder ---
1443+
1444+
1445+
class _ScriptedDispatcher:
1446+
"""Records every `send_raw_request` and plays back scripted answers in order.
1447+
1448+
A script entry that is an `Exception` is raised; a dict is returned."""
1449+
1450+
def __init__(self, *script: dict[str, Any] | Exception) -> None:
1451+
self.calls: list[tuple[str, Mapping[str, Any] | None]] = []
1452+
self.notifies: list[str] = []
1453+
self._script: list[dict[str, Any] | Exception] = list(script)
1454+
1455+
async def run(
1456+
self,
1457+
on_request: OnRequest,
1458+
on_notify: OnNotify,
1459+
*,
1460+
task_status: anyio.abc.TaskStatus[None] = anyio.TASK_STATUS_IGNORED,
1461+
) -> None:
1462+
task_status.started()
1463+
await anyio.sleep_forever()
1464+
1465+
async def send_raw_request(
1466+
self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None
1467+
) -> dict[str, Any]:
1468+
self.calls.append((method, params))
1469+
item = self._script.pop(0)
1470+
if isinstance(item, Exception):
1471+
raise item
1472+
return item
1473+
1474+
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
1475+
self.notifies.append(method)
1476+
1477+
1478+
def _discover_result_dict() -> dict[str, Any]:
1479+
return types.DiscoverResult(
1480+
supported_versions=["2026-07-28"],
1481+
capabilities=ServerCapabilities(),
1482+
server_info=Implementation(name="stub", version="0"),
1483+
).model_dump(by_alias=True, mode="json", exclude_none=True)
1484+
1485+
1486+
def _initialize_result_dict() -> dict[str, Any]:
1487+
return InitializeResult(
1488+
protocol_version=HANDSHAKE_PROTOCOL_VERSIONS[-1],
1489+
capabilities=ServerCapabilities(),
1490+
server_info=Implementation(name="stub", version="0"),
1491+
).model_dump(by_alias=True, mode="json", exclude_none=True)
1492+
1493+
1494+
@pytest.mark.anyio
1495+
async def test_discover_adopts_the_returned_result_and_installs_the_modern_stamp() -> None:
1496+
"""SDK-defined: a successful `server/discover` is adopted and subsequent requests
1497+
carry the modern `_meta` envelope (protocol version + client info + capabilities)."""
1498+
dispatcher = _ScriptedDispatcher(_discover_result_dict(), {})
1499+
with anyio.fail_after(5):
1500+
async with ClientSession(dispatcher=dispatcher) as session:
1501+
await session.discover()
1502+
assert session.protocol_version == "2026-07-28"
1503+
await session.send_ping()
1504+
ping_method, ping_params = dispatcher.calls[-1]
1505+
assert ping_method == "ping"
1506+
assert ping_params is not None
1507+
assert ping_params["_meta"][PROTOCOL_VERSION_META_KEY] == "2026-07-28"
1508+
1509+
1510+
@pytest.mark.anyio
1511+
async def test_discover_retries_once_on_unsupported_version_then_adopts() -> None:
1512+
"""Spec SHOULD: a -32022 reply that names a mutually-supported version
1513+
triggers exactly one retry at that version, and the retry's result is adopted."""
1514+
dispatcher = _ScriptedDispatcher(
1515+
MCPError(
1516+
UNSUPPORTED_PROTOCOL_VERSION,
1517+
"unsupported",
1518+
data={"supported": ["2026-07-28"], "requested": "2026-07-28"},
1519+
),
1520+
_discover_result_dict(),
1521+
)
1522+
with anyio.fail_after(5):
1523+
async with ClientSession(dispatcher=dispatcher) as session:
1524+
await session.discover()
1525+
assert session.protocol_version == "2026-07-28"
1526+
assert [m for m, _ in dispatcher.calls] == ["server/discover", "server/discover"]
1527+
1528+
1529+
@pytest.mark.anyio
1530+
async def test_discover_raises_when_retry_intersection_is_empty() -> None:
1531+
"""Spec SHOULD: a -32022 reply whose `supported` list shares nothing with the
1532+
client's modern versions is unrecoverable — the original error is re-raised
1533+
without a second probe."""
1534+
dispatcher = _ScriptedDispatcher(
1535+
MCPError(
1536+
UNSUPPORTED_PROTOCOL_VERSION,
1537+
"unsupported",
1538+
data={"supported": ["1999-01-01"], "requested": "2026-07-28"},
1539+
),
1540+
)
1541+
with anyio.fail_after(5):
1542+
async with ClientSession(dispatcher=dispatcher) as session:
1543+
with pytest.raises(MCPError) as exc:
1544+
await session.discover()
1545+
assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
1546+
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
1547+
1548+
1549+
@pytest.mark.anyio
1550+
async def test_discover_falls_back_to_initialize_on_method_not_found() -> None:
1551+
"""Spec SHOULD: a legacy server that answers -32601 to `server/discover` is
1552+
transparently driven through the handshake instead."""
1553+
dispatcher = _ScriptedDispatcher(
1554+
MCPError(METHOD_NOT_FOUND, "Method not found"),
1555+
_initialize_result_dict(),
1556+
)
1557+
with anyio.fail_after(5):
1558+
async with ClientSession(dispatcher=dispatcher) as session:
1559+
await session.discover()
1560+
assert session.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS
1561+
assert [m for m, _ in dispatcher.calls] == ["server/discover", "initialize"]
1562+
assert dispatcher.notifies == ["notifications/initialized"]
1563+
1564+
1565+
@pytest.mark.anyio
1566+
async def test_discover_falls_back_to_initialize_on_timeout() -> None:
1567+
"""Spec SHOULD: a `REQUEST_TIMEOUT` from the dispatcher is treated the same as
1568+
method-not-found — the server is presumed legacy and `initialize()` runs."""
1569+
dispatcher = _ScriptedDispatcher(
1570+
MCPError(REQUEST_TIMEOUT, "timed out"),
1571+
_initialize_result_dict(),
1572+
)
1573+
with anyio.fail_after(5):
1574+
async with ClientSession(dispatcher=dispatcher) as session:
1575+
await session.discover()
1576+
assert session.protocol_version in HANDSHAKE_PROTOCOL_VERSIONS
1577+
assert [m for m, _ in dispatcher.calls] == ["server/discover", "initialize"]
1578+
1579+
1580+
@pytest.mark.anyio
1581+
async def test_discover_reraises_on_other_errors() -> None:
1582+
"""SDK-defined: any error outside the retry/fallback ladder propagates verbatim
1583+
— `discover()` does not mask server failures by falling back to `initialize()`."""
1584+
dispatcher = _ScriptedDispatcher(MCPError(INTERNAL_ERROR, "boom"))
1585+
with anyio.fail_after(5):
1586+
async with ClientSession(dispatcher=dispatcher) as session:
1587+
with pytest.raises(MCPError) as exc:
1588+
await session.discover()
1589+
assert exc.value.error.code == INTERNAL_ERROR
1590+
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
1591+
1592+
1593+
@pytest.mark.anyio
1594+
async def test_discover_validates_the_response_shape_before_adopting() -> None:
1595+
"""SDK-defined: the raw response is run through `DiscoverResult` validation
1596+
before any state is installed, so a malformed reply leaves the session
1597+
un-adopted rather than half-configured."""
1598+
dispatcher = _ScriptedDispatcher({"supportedVersions": ["2026-07-28"]})
1599+
session = ClientSession(dispatcher=dispatcher)
1600+
with anyio.fail_after(5):
1601+
async with session:
1602+
with pytest.raises(ValidationError):
1603+
await session.discover()
1604+
assert session.protocol_version is None
1605+
1606+
1607+
@pytest.mark.anyio
1608+
async def test_discover_is_idempotent_and_returns_the_cached_result() -> None:
1609+
"""SDK-defined: a second `discover()` returns the already-adopted result without
1610+
re-probing — the script holds exactly one entry, so a second wire call would
1611+
`IndexError` on the empty script."""
1612+
dispatcher = _ScriptedDispatcher(_discover_result_dict())
1613+
with anyio.fail_after(5):
1614+
async with ClientSession(dispatcher=dispatcher) as session:
1615+
first = await session.discover()
1616+
assert await session.discover() is first
1617+
assert [m for m, _ in dispatcher.calls] == ["server/discover"]
1618+
1619+
1620+
@pytest.mark.anyio
1621+
async def test_discover_reraises_unsupported_version_with_malformed_error_data() -> None:
1622+
"""SDK-defined: a -32022 reply whose `data` is not a valid
1623+
`UnsupportedProtocolVersionErrorData` payload is unrecoverable — the original
1624+
error is re-raised without a retry probe."""
1625+
dispatcher = _ScriptedDispatcher(MCPError(UNSUPPORTED_PROTOCOL_VERSION, "unsupported", data="not-an-object"))
1626+
with anyio.fail_after(5):
1627+
async with ClientSession(dispatcher=dispatcher) as session:
1628+
with pytest.raises(MCPError) as exc:
1629+
await session.discover()
1630+
assert exc.value.error.code == UNSUPPORTED_PROTOCOL_VERSION
1631+
assert [m for m, _ in dispatcher.calls] == ["server/discover"]

0 commit comments

Comments
 (0)