Skip to content

Commit a82042a

Browse files
committed
discover() returns DiscoverResult; fallback ladder moves to Client; era-neutral accessors
- ClientSession.discover() -> DiscoverResult: no fallback (METHOD_NOT_FOUND/ REQUEST_TIMEOUT propagate; Client owns that policy), no InitializeResult synthesis. Separate _discover_result/_initialize_result/_negotiated_version slots. .adopt() sets the matching slot; no more synthesis. - Era-neutral properties on ClientSession and Client: .server_info, .server_capabilities, .instructions, .protocol_version read from whichever result is set. ClientSession.discover_result for prior_discover round-trip. - Client.__aenter__: mode='auto' wraps discover() with the fallback ladder (METHOD_NOT_FOUND | REQUEST_TIMEOUT -> initialize()). _build_session helper consolidates the dispatcher/transport branching to one ClientSession() site. - Client.initialize_result removed (use the era-neutral accessors). - mode= validated in __post_init__: ValueError on unknown values, with a redirect hint for handshake-era versions. - adopt()/discover() docstrings gain Raises: sections.
1 parent b6be755 commit a82042a

14 files changed

Lines changed: 332 additions & 192 deletions

File tree

src/mcp/client/client.py

Lines changed: 90 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -19,15 +19,17 @@
1919
from mcp.server.mcpserver import MCPServer
2020
from mcp.server.runner import modern_on_request
2121
from mcp.shared.direct_dispatcher import create_direct_dispatcher_pair
22-
from mcp.shared.dispatcher import ProgressFnT
23-
from mcp.shared.exceptions import MCPDeprecationWarning
22+
from mcp.shared.dispatcher import Dispatcher, ProgressFnT
23+
from mcp.shared.exceptions import MCPDeprecationWarning, MCPError
24+
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
2425
from mcp.types import (
26+
METHOD_NOT_FOUND,
27+
REQUEST_TIMEOUT,
2528
CallToolResult,
2629
CompleteResult,
2730
EmptyResult,
2831
GetPromptResult,
2932
Implementation,
30-
InitializeResult,
3133
ListPromptsResult,
3234
ListResourcesResult,
3335
ListResourceTemplatesResult,
@@ -38,8 +40,14 @@
3840
ReadResourceResult,
3941
RequestParamsMeta,
4042
ResourceTemplateReference,
43+
ServerCapabilities,
4144
)
4245

46+
ConnectMode = Literal["legacy", "auto"] | str
47+
"""``mode=`` value: ``"legacy"`` (initialize handshake), ``"auto"`` (discover, fall back to
48+
initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for
49+
forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction."""
50+
4351

4452
def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
4553
return types.DiscoverResult(
@@ -119,10 +127,10 @@ async def main():
119127
client_info: Implementation | None = None
120128
"""Client implementation info to send to server."""
121129

122-
mode: Literal["legacy", "auto"] | str = "legacy"
130+
mode: ConnectMode = "legacy"
123131
"""'legacy' performs the initialize handshake. 'auto' probes server/discover and falls back to initialize()
124-
on legacy servers. A protocol-version string (e.g. '2026-07-28') adopts that version directly without a
125-
handshake — supply prior_discover to reuse a known DiscoverResult, or omit it to synthesize a minimal one."""
132+
on legacy servers. A modern protocol-version string (e.g. '2026-07-28') adopts that version directly without
133+
a handshake — supply prior_discover to reuse a known DiscoverResult, or omit it to synthesize a minimal one."""
126134

127135
prior_discover: types.DiscoverResult | None = None
128136
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
@@ -146,60 +154,70 @@ def __post_init__(self) -> None:
146154
else:
147155
self._transport = self.server
148156

157+
if self.mode not in ("legacy", "auto") and self.mode not in MODERN_PROTOCOL_VERSIONS:
158+
hint = (
159+
f" ({self.mode!r} is a handshake-era version — use mode='legacy')"
160+
if self.mode in HANDSHAKE_PROTOCOL_VERSIONS
161+
else ""
162+
)
163+
raise ValueError(
164+
f"mode must be 'legacy', 'auto', or one of {list(MODERN_PROTOCOL_VERSIONS)}; got {self.mode!r}{hint}"
165+
)
166+
167+
async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
168+
"""Set up the dispatcher/transport and return an un-entered ClientSession."""
169+
dispatcher: Dispatcher[Any] | None
170+
if self._inproc_server is not None and self.mode != "legacy":
171+
# Modern in-process path: drive the server through a DirectDispatcher peer-pair
172+
# with one `serve_one` per request — no streams, no initialize handshake.
173+
lifespan_state = await exit_stack.enter_async_context(self._inproc_server.lifespan(self._inproc_server))
174+
client_disp, server_disp = create_direct_dispatcher_pair()
175+
tg = await exit_stack.enter_async_context(anyio.create_task_group())
176+
exit_stack.callback(server_disp.close)
177+
on_request = modern_on_request(self._inproc_server, lifespan_state, raise_exceptions=self.raise_exceptions)
178+
await tg.start(server_disp.run, on_request, _drop_notify)
179+
dispatcher = client_disp
180+
read_stream = write_stream = None
181+
else:
182+
if self._inproc_server is not None:
183+
transport: Transport = InMemoryTransport(self._inproc_server, raise_exceptions=self.raise_exceptions)
184+
else:
185+
assert self._transport is not None
186+
transport = self._transport
187+
read_stream, write_stream = await exit_stack.enter_async_context(transport)
188+
dispatcher = None
189+
return ClientSession(
190+
read_stream=read_stream,
191+
write_stream=write_stream,
192+
dispatcher=dispatcher,
193+
read_timeout_seconds=self.read_timeout_seconds,
194+
sampling_callback=self.sampling_callback,
195+
list_roots_callback=self.list_roots_callback,
196+
logging_callback=self.logging_callback,
197+
message_handler=self.message_handler,
198+
client_info=self.client_info,
199+
elicitation_callback=self.elicitation_callback,
200+
)
201+
149202
async def __aenter__(self) -> Client:
150203
"""Enter the async context manager."""
151204
if self._session is not None:
152205
raise RuntimeError("Client is already entered; cannot reenter")
153206

154207
async with AsyncExitStack() as exit_stack:
155-
if self._inproc_server is not None and self.mode != "legacy":
156-
# Modern in-process path: drive the server through a DirectDispatcher peer-pair
157-
# with one `serve_one` per request — no streams, no initialize handshake.
158-
lifespan_state = await exit_stack.enter_async_context(self._inproc_server.lifespan(self._inproc_server))
159-
client_disp, server_disp = create_direct_dispatcher_pair()
160-
tg = await exit_stack.enter_async_context(anyio.create_task_group())
161-
exit_stack.callback(server_disp.close)
162-
on_request = modern_on_request(
163-
self._inproc_server, lifespan_state, raise_exceptions=self.raise_exceptions
164-
)
165-
await tg.start(server_disp.run, on_request, _drop_notify)
166-
session = ClientSession(
167-
dispatcher=client_disp,
168-
read_timeout_seconds=self.read_timeout_seconds,
169-
sampling_callback=self.sampling_callback,
170-
list_roots_callback=self.list_roots_callback,
171-
logging_callback=self.logging_callback,
172-
message_handler=self.message_handler,
173-
client_info=self.client_info,
174-
elicitation_callback=self.elicitation_callback,
175-
)
176-
else:
177-
if self._inproc_server is not None:
178-
transport: Transport = InMemoryTransport(
179-
self._inproc_server, raise_exceptions=self.raise_exceptions
180-
)
181-
else:
182-
assert self._transport is not None
183-
transport = self._transport
184-
read_stream, write_stream = await exit_stack.enter_async_context(transport)
185-
session = ClientSession(
186-
read_stream=read_stream,
187-
write_stream=write_stream,
188-
read_timeout_seconds=self.read_timeout_seconds,
189-
sampling_callback=self.sampling_callback,
190-
list_roots_callback=self.list_roots_callback,
191-
logging_callback=self.logging_callback,
192-
message_handler=self.message_handler,
193-
client_info=self.client_info,
194-
elicitation_callback=self.elicitation_callback,
195-
)
196-
208+
session = await self._build_session(exit_stack)
197209
self._session = await exit_stack.enter_async_context(session)
198210

199211
if self.mode == "legacy":
200212
await self._session.initialize()
201213
elif self.mode == "auto":
202-
await self._session.discover()
214+
try:
215+
await self._session.discover()
216+
except MCPError as e:
217+
if e.code in (METHOD_NOT_FOUND, REQUEST_TIMEOUT):
218+
await self._session.initialize()
219+
else:
220+
raise
203221
else:
204222
self._session.adopt(self.prior_discover or _synthesize_discover(self.mode))
205223

@@ -227,16 +245,30 @@ def session(self) -> ClientSession:
227245
return self._session
228246

229247
@property
230-
def initialize_result(self) -> InitializeResult:
231-
"""The server's InitializeResult.
248+
def protocol_version(self) -> str:
249+
"""Negotiated protocol version (set by initialize/discover/adopt during ``__aenter__``)."""
250+
version = self.session.protocol_version
251+
assert version is not None
252+
return version
232253

233-
Contains server_info, capabilities, instructions, and the negotiated protocol_version.
234-
Raises RuntimeError if accessed outside the context manager.
235-
"""
236-
result = self.session.initialize_result
237-
if result is None: # pragma: no cover
238-
raise RuntimeError("Client must be used within an async context manager")
239-
return result
254+
@property
255+
def server_info(self) -> Implementation:
256+
"""Server name/version (set by initialize/discover/adopt during ``__aenter__``)."""
257+
info = self.session.server_info
258+
assert info is not None
259+
return info
260+
261+
@property
262+
def server_capabilities(self) -> ServerCapabilities:
263+
"""Server capabilities (set by initialize/discover/adopt during ``__aenter__``)."""
264+
caps = self.session.server_capabilities
265+
assert caps is not None
266+
return caps
267+
268+
@property
269+
def instructions(self) -> str | None:
270+
"""Server-provided instructions text, if any."""
271+
return self.session.instructions
240272

241273
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
242274
"""Send a ping request to the server."""

0 commit comments

Comments
 (0)