Skip to content

Commit 290e068

Browse files
committed
Recover mode='auto' from a timed-out probe and close the in-process back-channel
A server/discover probe that outlives the client's 10s timeout can still succeed on a slow-starting server, locking the connection modern before the pipelined fallback initialize arrives; that initialize then fails with -32022, which negotiate_auto treated as terminal - the default mode='auto' could not connect at all. The -32022 is itself positive modern evidence (it names the server's versions), so the policy now re-probes once at a mutual version, bounded by the existing two-attempt loop. modern_on_request now wraps its dispatch context in the server-requests denial like the other modern entries: a handler's request-scoped elicitation no longer delivers the protocol-forbidden frame to the in-process client (which answered a misleading "Method not found") and instead refuses server-side with the no-back-channel contract.
1 parent 482d7cd commit 290e068

5 files changed

Lines changed: 181 additions & 8 deletions

File tree

src/mcp/client/_probe.py

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,12 @@
1111
the same path. Any non-``MCPError`` exception (network/connection errors,
1212
anyio cancellation, the ``RuntimeError`` from ``adopt()`` on no-mutual)
1313
propagates to the caller; an outage or in-process bug is never an era verdict.
14+
15+
The fallback handshake itself can be answered with ``-32022`` — e.g. a probe
16+
that timed out client-side but succeeded on a slow-starting server locked the
17+
connection modern before the pipelined ``initialize`` arrived. That code is
18+
itself positive modern evidence (it names the server's versions), so it
19+
triggers one re-probe at a mutual version instead of failing the connect.
1420
"""
1521

1622
from __future__ import annotations
@@ -49,7 +55,8 @@ async def negotiate_auto(session: ClientSession) -> None:
4955
5056
Raises:
5157
MCPError: The server is modern-only and shares no version with this
52-
client (-32022 with a disjoint ``supported`` list).
58+
client (-32022 with a disjoint ``supported`` list), or the
59+
fallback handshake failed and one corrective re-probe did too.
5360
Exception: Any transport/network error from the probe propagates as-is.
5461
"""
5562
version = LATEST_MODERN_VERSION
@@ -65,7 +72,22 @@ async def negotiate_auto(session: ClientSession) -> None:
6572
continue
6673
if supported is not None and not any(v in HANDSHAKE_PROTOCOL_VERSIONS for v in supported):
6774
raise # server is modern-only and disjoint — real incompatibility
68-
await session.initialize() # every other rpc-error → legacy (the denylist)
75+
try:
76+
await session.initialize() # every other rpc-error → legacy (the denylist)
77+
except MCPError as handshake_exc:
78+
if handshake_exc.code != UNSUPPORTED_PROTOCOL_VERSION or attempt != 0:
79+
raise
80+
# -32022 from the handshake is itself modern evidence: a probe
81+
# that timed out client-side but succeeded on the server locked
82+
# the connection modern before this initialize arrived. Re-probe
83+
# once at a version the server names; the era is already
84+
# settled, so the second probe answers without the slow start.
85+
supported = _parse_supported(handshake_exc.error.data)
86+
mutual = [v for v in MODERN_PROTOCOL_VERSIONS if v in (supported or ())]
87+
if not mutual:
88+
raise
89+
version = mutual[-1]
90+
continue
6991
return
7092
# any other exception (httpx.TransportError, ConnectionError, anyio errors,
7193
# RuntimeError from adopt) → propagate

src/mcp/server/runner.py

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -735,8 +735,11 @@ def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> O
735735
Wire this into the server side of a `DirectDispatcher` peer-pair to drive an
736736
in-process server on the modern per-request-envelope path (each request
737737
carries protocol version, client info, and capabilities in `params._meta`;
738-
no `initialize` handshake). Like `serve_one`, this raises whatever the
739-
handler chain raises - the dispatcher owns the exception-to-error mapping.
738+
no `initialize` handshake). The dispatch context is wrapped in the
739+
server-requests denial, so the modern prohibition on server-initiated
740+
JSON-RPC requests holds on this entry like on the others. Like `serve_one`,
741+
this raises whatever the handler chain raises - the dispatcher owns the
742+
exception-to-error mapping.
740743
"""
741744

742745
async def handle(
@@ -748,6 +751,13 @@ async def handle(
748751
meta.get(CLIENT_INFO_META_KEY),
749752
meta.get(CLIENT_CAPABILITIES_META_KEY),
750753
)
751-
return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state)
754+
return await serve_one(
755+
server,
756+
_NoServerRequestsDispatchContext(dctx),
757+
method,
758+
params,
759+
connection=connection,
760+
lifespan_state=lifespan_state,
761+
)
752762

753763
return handle

tests/client/test_client.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,26 @@ async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequ
243243
assert exc_info.value.__cause__ is None
244244

245245

246+
async def test_modern_inproc_path_refuses_server_initiated_requests():
247+
"""The in-process modern entry enforces the same prohibition as the other
248+
modern entries: a handler's request-scoped server-initiated request is
249+
refused server-side with the no-back-channel contract, instead of the
250+
protocol-forbidden frame being delivered to the client."""
251+
252+
async def handle_call_tool(ctx: ServerRequestContext, params: types.CallToolRequestParams) -> CallToolResult:
253+
schema = types.ElicitRequestedSchema(type="object", properties={"x": {"type": "string"}})
254+
await ctx.session.elicit_form("question", schema, related_request_id=ctx.request_id)
255+
raise AssertionError("unreachable: elicit_form must refuse") # pragma: no cover
256+
257+
server = Server("test", on_call_tool=handle_call_tool)
258+
async with Client(server, mode="2026-07-28") as client:
259+
with pytest.raises(MCPError) as exc_info:
260+
await client.call_tool("asker", {})
261+
assert exc_info.value.error.code == types.INVALID_REQUEST
262+
assert "no back-channel" in exc_info.value.error.message
263+
assert "elicitation/create" in exc_info.value.error.message
264+
265+
246266
async def test_get_prompt(app: MCPServer):
247267
"""Test getting a prompt."""
248268
async with Client(app) as client:
@@ -458,6 +478,48 @@ async def test_client_legacy_mode_still_handshakes_over_a_stream_loop(simple_ser
458478
assert (await client.list_resources()).resources[0].name == "Test Resource"
459479

460480

481+
async def test_client_auto_mode_recovers_from_a_timed_out_probe_over_a_stream_loop(
482+
simple_server: Server, monkeypatch: pytest.MonkeyPatch
483+
) -> None:
484+
"""A probe that outlives the client's discover timeout still succeeds on the
485+
(slow-starting) server and locks the connection modern; the fallback
486+
handshake's -32022 is modern evidence, so one corrective re-probe completes
487+
the connect instead of stranding `mode='auto'`."""
488+
monkeypatch.setattr("mcp.client.session.DISCOVER_TIMEOUT_SECONDS", 0.05)
489+
c2relay_send, c2relay_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
490+
relay2s_send, relay2s_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
491+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](32)
492+
493+
async def relay() -> None:
494+
# Hold the client's first frame (the probe) until its second frame (the
495+
# post-timeout initialize) arrives - the deterministic stand-in for a
496+
# server too slow to answer before the client's discover timeout.
497+
held: SessionMessage | Exception | None = None
498+
first = True
499+
async for item in c2relay_recv:
500+
if first:
501+
held, first = item, False
502+
continue
503+
if held is not None:
504+
await relay2s_send.send(held)
505+
held = None
506+
await relay2s_send.send(item)
507+
508+
@asynccontextmanager
509+
async def transport() -> AsyncIterator[TransportStreams]:
510+
async with c2relay_send, c2relay_recv, relay2s_send, relay2s_recv, s2c_send, s2c_recv:
511+
async with anyio.create_task_group() as tg:
512+
tg.start_soon(simple_server.run, relay2s_recv, s2c_send, simple_server.create_initialization_options())
513+
tg.start_soon(relay)
514+
yield s2c_recv, c2relay_send
515+
tg.cancel_scope.cancel()
516+
517+
with anyio.fail_after(10):
518+
async with Client(transport(), mode="auto") as client:
519+
assert client.protocol_version == "2026-07-28"
520+
assert (await client.list_resources()).resources[0].name == "Test Resource"
521+
522+
461523
@pytest.mark.parametrize("code", [types.METHOD_NOT_FOUND, types.REQUEST_TIMEOUT, types.INTERNAL_ERROR])
462524
async def test_client_auto_mode_falls_back_to_initialize_on_legacy_signal(code: int) -> None:
463525
"""`mode='auto'`: any JSON-RPC error from `server/discover` makes

tests/client/test_probe.py

Lines changed: 81 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
INVALID_REQUEST,
2525
METHOD_NOT_FOUND,
2626
PARSE_ERROR,
27+
REQUEST_TIMEOUT,
2728
UNSUPPORTED_PROTOCOL_VERSION,
2829
Implementation,
2930
ServerCapabilities,
@@ -45,12 +46,16 @@ class _StubSession:
4546
"""Minimal stand-in for `ClientSession` exposing only what `negotiate_auto` touches.
4647
4748
`send_discover` plays back a script (raise an exception, or return a dict);
48-
`initialize` and `adopt` just record that they were called.
49+
`initialize` raises the next entry of an optional `handshake` exception
50+
script (succeeding once it is exhausted) and records its calls; `adopt`
51+
just records.
4952
"""
5053

51-
def __init__(self, *script: dict[str, Any] | Exception) -> None:
54+
def __init__(self, *script: dict[str, Any] | Exception, handshake: list[Exception] | None = None) -> None:
5255
self._script: list[dict[str, Any] | Exception] = list(script)
56+
self._handshake: list[Exception] = list(handshake or [])
5357
self.probed_at: list[str] = []
58+
self.initialize_calls: int = 0
5459
self.initialized: bool = False
5560
self.adopted: types.DiscoverResult | None = None
5661

@@ -62,6 +67,9 @@ async def send_discover(self, version: str) -> dict[str, Any]:
6267
return step
6368

6469
async def initialize(self) -> None:
70+
self.initialize_calls += 1
71+
if self._handshake:
72+
raise self._handshake.pop(0)
6573
self.initialized = True
6674

6775
def adopt(self, result: types.DiscoverResult) -> None:
@@ -201,6 +209,77 @@ async def test_a_second_unsupported_version_after_the_corrective_retry_does_not_
201209
assert session.adopted is None
202210

203211

212+
# --- -32022 from the fallback handshake: modern evidence, one re-probe ---
213+
214+
215+
async def test_handshake_unsupported_after_a_timed_out_probe_reprobes_and_adopts() -> None:
216+
"""A probe that times out client-side but succeeds on a slow-starting
217+
server locks the connection modern, so the fallback handshake answers
218+
-32022. That code is itself modern evidence: re-probe once at a version
219+
the server names and adopt - the connect must not fail."""
220+
session = _StubSession(
221+
MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out"),
222+
_discover_dict(),
223+
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS))],
224+
)
225+
await _negotiate(session)
226+
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
227+
assert session.adopted is not None
228+
assert session.initialize_calls == 1
229+
assert not session.initialized
230+
231+
232+
@pytest.mark.parametrize(
233+
"data",
234+
[
235+
pytest.param({"supported": ["2099-01-01"], "requested": LATEST_MODERN_VERSION}, id="disjoint"),
236+
pytest.param(None, id="no-data"),
237+
],
238+
)
239+
async def test_handshake_unsupported_without_a_mutual_version_reraises(data: Any) -> None:
240+
"""-32022 from the handshake naming no version we speak (or nothing
241+
parseable) leaves nothing to retry with - the error propagates."""
242+
session = _StubSession(
243+
MCPError(code=METHOD_NOT_FOUND, message="nope"),
244+
handshake=[MCPError(code=UNSUPPORTED_PROTOCOL_VERSION, message="already modern", data=data)],
245+
)
246+
with pytest.raises(MCPError) as exc_info:
247+
await _negotiate(session)
248+
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
249+
assert session.adopted is None
250+
assert not session.initialized
251+
252+
253+
async def test_handshake_unsupported_reprobes_at_most_once() -> None:
254+
"""The handshake-driven re-probe is bounded: if the second attempt also
255+
ends in a timed-out probe and a -32022 handshake, the -32022 propagates
256+
instead of looping."""
257+
timeout = MCPError(code=REQUEST_TIMEOUT, message="Request 'server/discover' timed out")
258+
session = _StubSession(
259+
timeout,
260+
timeout,
261+
handshake=[_err_32022(list(MODERN_PROTOCOL_VERSIONS)), _err_32022(list(MODERN_PROTOCOL_VERSIONS))],
262+
)
263+
with pytest.raises(MCPError) as exc_info:
264+
await _negotiate(session)
265+
assert exc_info.value.code == UNSUPPORTED_PROTOCOL_VERSION
266+
assert session.probed_at == [LATEST_MODERN_VERSION, MODERN_PROTOCOL_VERSIONS[-1]]
267+
assert session.initialize_calls == 2
268+
269+
270+
async def test_any_other_handshake_error_propagates_unchanged() -> None:
271+
"""A non--32022 error from the fallback handshake is a real handshake
272+
failure, not era evidence - it propagates without a re-probe."""
273+
session = _StubSession(
274+
MCPError(code=METHOD_NOT_FOUND, message="nope"),
275+
handshake=[MCPError(code=INTERNAL_ERROR, message="handshake broke")],
276+
)
277+
with pytest.raises(MCPError) as exc_info:
278+
await _negotiate(session)
279+
assert exc_info.value.code == INTERNAL_ERROR
280+
assert session.probed_at == [LATEST_MODERN_VERSION]
281+
282+
204283
# --- non-MCP errors propagate ---
205284

206285

tests/docs_src/test_client_callbacks.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ async def test_each_callback_declares_its_own_capability() -> None:
107107
async def test_the_modern_in_memory_path_has_no_back_channel() -> None:
108108
"""The `!!! info`: under the default mode the negotiated path has no back-channel for `elicitation/create`."""
109109
async with Client(tutorial001.mcp, elicitation_callback=tutorial002.handle_elicitation) as client:
110-
with pytest.raises(MCPError, match="Method not found"):
110+
with pytest.raises(MCPError, match="no back-channel"):
111111
await client.call_tool("issue_card")
112112

113113

0 commit comments

Comments
 (0)