Skip to content

Commit 2d12b96

Browse files
committed
Flip Client default mode to 'auto'
The default Client(...) now probes server/discover and falls back to initialize() (via negotiate_auto's denylist). For an in-process Server, the default path is now DirectDispatcher per-request rather than the legacy InMemoryTransport stream loop. DirectDispatcher gains raise_handler_exceptions (matching JSONRPCDispatcher's knob): True chains the original exception via __cause__; False sanitizes to MCPError(INTERNAL_ERROR). modern_on_request collapses to a pure envelope-builder (no exception ladder of its own), and Client threads raise_exceptions into create_direct_dispatcher_pair. Tests that exercise legacy-specific semantics — server-initiated sampling/elicitation push, message_handler delivery, ping, InMemoryTransport mechanics, JSON-RPC wire-shape recording — are pinned to mode='legacy' explicitly (~64 sites across 26 test files plus the client_via_http / connect_over_sse / auth-harness helpers). These are census-driven, not failure-driven: ~23 sites would have passed under 'auto' but silently stopped testing their subject. Client.send_ping() is deprecated (ping is removed from 2026-07-28); it only works under mode='legacy'. docs/migration.md gains a section explaining the default change and when to pin mode='legacy'; docs/testing.md notes the same for test authors.
1 parent a7d1275 commit 2d12b96

33 files changed

Lines changed: 155 additions & 107 deletions

docs/migration.md

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,15 @@ version = session.protocol_version
352352

353353
The raw handshake result is also retained: `session.initialize_result` is set after `initialize()` (≤2025-11-25 servers — including `stateless_http=True` servers, which still answer `initialize`); `session.discover_result` is set after `discover()` (2026-07-28+ servers). At most one is non-`None`.
354354

355-
On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always handshakes on enter.)
355+
On the high-level `Client`, `client.server_capabilities`, `client.server_info`, and `client.protocol_version` are non-nullable inside the context manager. `client.instructions` remains `str | None` since the server may omit it. (The lowlevel `ClientSession` still lets you call methods before any handshake, as in v1; `Client` always connects on enter — by default it probes `server/discover` and falls back to the initialize handshake.)
356+
357+
### `Client` defaults to `mode='auto'`
358+
359+
In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.
360+
361+
For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used.
362+
363+
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.
356364

357365
### `McpError` renamed to `MCPError`
358366

@@ -832,7 +840,7 @@ async with Client(server) as client:
832840
result = await client.call_tool("my_tool", {"x": 1})
833841
```
834842

835-
`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors.
843+
`Client` accepts the same callback parameters the old helper did (`sampling_callback`, `list_roots_callback`, `logging_callback`, `message_handler`, `elicitation_callback`, `client_info`) plus `raise_exceptions` to surface server-side errors and `mode` to control version negotiation (`'auto'` by default; `'legacy'` reproduces v1's initialize-only handshake).
836844

837845
If you need direct access to the underlying `ClientSession` and memory streams (e.g., for low-level transport testing), `create_client_server_memory_streams` is still available in `mcp.shared.memory`:
838846

docs/testing.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -74,4 +74,9 @@ async def test_call_add_tool(client: Client):
7474
1. If you are using `trio`, you should set `"trio"` as the `anyio_backend`. Check more information in the [anyio documentation](https://anyio.readthedocs.io/en/stable/testing.html#specifying-the-backends-to-run-on).
7575
2. The `client` fixture creates a connected client that can be reused across multiple tests.
7676

77+
!!! note
78+
`Client(app)` connects in-process and is era-neutral by default — it probes the server and picks the
79+
appropriate protocol path. Pin `mode='legacy'` if your test exercises legacy-specific semantics
80+
(sampling/elicitation push, `message_handler`).
81+
7782
There you go! You can now extend your tests to cover more scenarios.

pyproject.toml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,9 @@ filterwarnings = [
220220
# 2026-07-28 restricts progress to server->client; the client send path is
221221
# advisory-deprecated and a handful of tests still exercise it.
222222
"ignore:Client-to-server progress is deprecated as of 2026-07-28.*:mcp.MCPDeprecationWarning",
223+
# 2026-07-28 drops ping; Client.send_ping() is advisory-deprecated and the
224+
# legacy interaction/transport tests still drive it.
225+
"ignore:ping is removed as of 2026-07-28.*:mcp.MCPDeprecationWarning",
223226
]
224227

225228
[tool.markdown.lint]

src/mcp/client/client.py

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -78,10 +78,10 @@ async def connect(exit_stack: AsyncExitStack, mode: ConnectMode, raise_exception
7878
read_stream, write_stream = await exit_stack.enter_async_context(transport)
7979
return JSONRPCDispatcher(read_stream, write_stream)
8080
lifespan_state = await exit_stack.enter_async_context(server.lifespan(server))
81-
client_disp, server_disp = create_direct_dispatcher_pair()
81+
client_disp, server_disp = create_direct_dispatcher_pair(raise_handler_exceptions=raise_exceptions)
8282
tg = await exit_stack.enter_async_context(anyio.create_task_group())
8383
exit_stack.callback(server_disp.close)
84-
on_request = modern_on_request(server, lifespan_state, raise_exceptions=raise_exceptions)
84+
on_request = modern_on_request(server, lifespan_state)
8585
await tg.start(server_disp.run, on_request, _no_inbound_client_notifications)
8686
return client_disp
8787

@@ -151,7 +151,7 @@ async def main():
151151
server: Server[Any] | MCPServer | Transport | str
152152
"""The MCP server to connect to.
153153
154-
If the server is a `Server` or `MCPServer` instance, it will be wrapped in an `InMemoryTransport`.
154+
If the server is a `Server` or `MCPServer` instance, it will be connected in-process.
155155
If the server is a URL string, it will be used as the URL for a `streamable_http_client` transport.
156156
If the server is a `Transport` instance, it will be used directly.
157157
"""
@@ -181,11 +181,14 @@ async def main():
181181
client_info: Implementation | None = None
182182
"""Client implementation info to send to server."""
183183

184-
# TODO(maxisbey): flip default to 'auto' once the in-proc test suite is era-decoupled.
185-
mode: ConnectMode = "legacy"
186-
"""'legacy' performs the initialize handshake. 'auto' probes server/discover and falls back to initialize()
187-
on legacy servers. A modern protocol-version string (e.g. '2026-07-28') adopts that version directly without
188-
a handshake — supply prior_discover to reuse a known DiscoverResult, or omit it to synthesize a minimal one."""
184+
mode: ConnectMode = "auto"
185+
"""How to negotiate the protocol version.
186+
187+
'auto' (the default) probes `server/discover` and falls back to the initialize handshake on legacy servers;
188+
for an in-process `Server`/`MCPServer` it dispatches directly without JSON-RPC framing. 'legacy' forces the
189+
initialize handshake (byte-identical pre-2026 behavior). A modern protocol-version string (e.g. '2026-07-28')
190+
adopts that version directly without a probe — supply `prior_discover` to reuse a known DiscoverResult, or
191+
omit it to synthesize a minimal one."""
189192

190193
prior_discover: types.DiscoverResult | None = None
191194
"""A previously-obtained DiscoverResult to install via .adopt() when mode is a version pin.
@@ -301,6 +304,10 @@ def instructions(self) -> str | None:
301304
"""Server-provided instructions text, if any."""
302305
return self.session.instructions
303306

307+
@deprecated(
308+
"ping is removed as of 2026-07-28; the method only works under mode='legacy'.",
309+
category=MCPDeprecationWarning,
310+
)
304311
async def send_ping(self, *, meta: RequestParamsMeta | None = None) -> EmptyResult:
305312
"""Send a ping request to the server."""
306313
return await self.session.send_ping(meta=meta)

src/mcp/server/runner.py

Lines changed: 4 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -496,18 +496,14 @@ async def serve_one(
496496
await aclose_shielded(connection)
497497

498498

499-
def modern_on_request(
500-
server: Server[LifespanT], lifespan_state: LifespanT, *, raise_exceptions: bool = False
501-
) -> OnRequest:
499+
def modern_on_request(server: Server[LifespanT], lifespan_state: LifespanT) -> OnRequest:
502500
"""Return an `OnRequest` callback that serves each call via `serve_one` with a fresh per-request `Connection`.
503501
504502
Wire this into the server side of a `DirectDispatcher` peer-pair to drive an
505503
in-process server on the modern per-request-envelope path (each request
506504
carries protocol version, client info, and capabilities in `params._meta`;
507-
no `initialize` handshake). ``raise_exceptions`` lets unmapped handler
508-
exceptions propagate to the caller for debuggable in-process testing;
509-
otherwise they are sanitized to `MCPError(INTERNAL_ERROR)` so the in-process
510-
path matches the wire path's leak guard.
505+
no `initialize` handshake). Like `serve_one`, this raises whatever the
506+
handler chain raises - the dispatcher owns the exception-to-error mapping.
511507
"""
512508

513509
async def handle(
@@ -519,16 +515,6 @@ async def handle(
519515
meta.get(CLIENT_INFO_META_KEY),
520516
meta.get(CLIENT_CAPABILITIES_META_KEY),
521517
)
522-
try:
523-
return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state)
524-
except (MCPError, ValidationError):
525-
# DirectDispatcher's ladder maps these onward; this layer only owns the raise_exceptions
526-
# decision for unmapped exceptions, which DirectDispatcher would otherwise leak via str(exc).
527-
raise
528-
except Exception:
529-
if raise_exceptions:
530-
raise
531-
logger.exception("request handler raised")
532-
raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None
518+
return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state)
533519

534520
return handle

src/mcp/shared/direct_dispatcher.py

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@
99
(`ServerRunner`, `Context`, `Connection`) without wire-level moving parts
1010
* embed a server in-process when the JSON-RPC overhead is unnecessary
1111
12-
Unlike `JSONRPCDispatcher`, exceptions raised in a handler propagate directly
13-
to the caller - there is no exception-to-`ErrorData` boundary here.
12+
Like `JSONRPCDispatcher`, this is an exception-to-error boundary: a handler
13+
exception surfaces to the caller as `MCPError`. The `raise_handler_exceptions`
14+
knob controls whether unmapped exceptions are sanitized (matching the wire
15+
path) or chained as ``__cause__`` for in-process debugging.
1416
"""
1517

1618
from __future__ import annotations
@@ -96,8 +98,9 @@ class DirectDispatcher:
9698
they are silently dropped.
9799
"""
98100

99-
def __init__(self, transport_ctx: TransportContext):
101+
def __init__(self, transport_ctx: TransportContext, *, raise_handler_exceptions: bool = True):
100102
self._transport_ctx = transport_ctx
103+
self._raise_handler_exceptions = raise_handler_exceptions
101104
self._peer: DirectDispatcher | None = None
102105
self._on_request: OnRequest | None = None
103106
self._on_notify: OnNotify | None = None
@@ -235,7 +238,14 @@ async def _dispatch_request(
235238
# tests see what runner-over-JSONRPC would.
236239
raise MCPError(code=INVALID_PARAMS, message="Invalid request parameters", data="") from e
237240
except Exception as e:
238-
raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e
241+
# Single owner of the in-proc exception-to-error policy (mirrors
242+
# JSONRPCDispatcher / `_streamable_http_modern._to_jsonrpc_response`
243+
# for the wire paths). True chains the original for in-process
244+
# debugging; False sanitizes to match the wire path's leak guard.
245+
if self._raise_handler_exceptions:
246+
raise MCPError(code=INTERNAL_ERROR, message=str(e)) from e
247+
logger.exception("request handler raised")
248+
raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None
239249
except TimeoutError:
240250
raise MCPError(
241251
code=REQUEST_TIMEOUT,
@@ -259,21 +269,27 @@ def create_direct_dispatcher_pair(
259269
*,
260270
can_send_request: bool = True,
261271
headers: Mapping[str, str] | None = None,
272+
raise_handler_exceptions: bool = True,
262273
) -> tuple[DirectDispatcher, DirectDispatcher]:
263274
"""Create two `DirectDispatcher` instances wired to each other.
264275
265276
Args:
266277
can_send_request: Sets `TransportContext.can_send_request` on both
267278
sides. Pass `False` to simulate a transport with no back-channel.
268279
headers: Sets `TransportContext.headers` on both sides.
280+
raise_handler_exceptions: When `True` (the default - this is an
281+
in-process debugging substrate), an unmapped handler exception
282+
reaches the caller as `MCPError` with the original chained as
283+
``__cause__``. When `False` it is sanitized to an opaque
284+
`INTERNAL_ERROR` so the in-process path matches the wire.
269285
270286
Returns:
271287
A `(client, server)` pair. The wiring is symmetric, so the roles
272288
are conventional only.
273289
"""
274290
ctx = TransportContext(kind=DIRECT_TRANSPORT_KIND, can_send_request=can_send_request, headers=headers)
275-
client = DirectDispatcher(ctx)
276-
server = DirectDispatcher(ctx)
291+
client = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions)
292+
server = DirectDispatcher(ctx, raise_handler_exceptions=raise_handler_exceptions)
277293
client.connect_to(server)
278294
server.connect_to(client)
279295
return client, server

tests/client/test_client.py

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ def greeting_prompt(name: str) -> str:
107107

108108
async def test_client_is_initialized(app: MCPServer):
109109
"""Test that the client is initialized after entering context."""
110-
async with Client(app) as client:
110+
async with Client(app, mode="legacy") as client:
111111
assert client.server_capabilities == snapshot(
112112
ServerCapabilities(
113113
experimental={},
@@ -121,7 +121,7 @@ async def test_client_is_initialized(app: MCPServer):
121121

122122
async def test_client_exposes_negotiated_protocol_version(app: MCPServer):
123123
"""The negotiated protocol version is readable after initialization."""
124-
async with Client(app) as client:
124+
async with Client(app, mode="legacy") as client:
125125
assert client.protocol_version == LATEST_HANDSHAKE_VERSION
126126

127127

@@ -137,8 +137,8 @@ async def test_client_with_simple_server(simple_server: Server):
137137

138138

139139
async def test_client_send_ping(app: MCPServer):
140-
async with Client(app) as client:
141-
result = await client.send_ping()
140+
async with Client(app, mode="legacy") as client:
141+
result = await client.send_ping() # pyright: ignore[reportDeprecated]
142142
assert result == snapshot(EmptyResult())
143143

144144

@@ -278,27 +278,28 @@ async def handle_progress(ctx: ServerRequestContext, params: types.ProgressNotif
278278

279279
server = Server(name="test_server", on_progress=handle_progress)
280280

281-
async with Client(server) as client:
282-
await client.send_progress_notification(progress_token="token123", progress=50.0) # pyright: ignore[reportDeprecated]
283-
await event.wait()
284-
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
281+
with anyio.fail_after(5):
282+
async with Client(server, mode="legacy") as client:
283+
await client.send_progress_notification(progress_token="token123", progress=50.0) # pyright: ignore[reportDeprecated]
284+
await event.wait()
285+
assert received_from_client == snapshot({"progress_token": "token123", "progress": 50.0})
285286

286287

287288
async def test_client_subscribe_resource(simple_server: Server):
288-
async with Client(simple_server) as client:
289+
async with Client(simple_server, mode="legacy") as client:
289290
result = await client.subscribe_resource("memory://test")
290291
assert result == snapshot(EmptyResult())
291292

292293

293294
async def test_client_unsubscribe_resource(simple_server: Server):
294-
async with Client(simple_server) as client:
295+
async with Client(simple_server, mode="legacy") as client:
295296
result = await client.unsubscribe_resource("memory://test")
296297
assert result == snapshot(EmptyResult())
297298

298299

299300
async def test_client_set_logging_level(simple_server: Server):
300301
"""Test setting logging level."""
301-
async with Client(simple_server) as client:
302+
async with Client(simple_server, mode="legacy") as client:
302303
result = await client.set_logging_level("debug") # pyright: ignore[reportDeprecated]
303304
assert result == snapshot(EmptyResult())
304305

@@ -361,7 +362,7 @@ def test_client_with_url_initializes_streamable_http_transport():
361362

362363
async def test_client_uses_transport_directly(app: MCPServer):
363364
transport = InMemoryTransport(app)
364-
async with Client(transport) as client:
365+
async with Client(transport, mode="legacy") as client:
365366
result = await client.call_tool("greet", {"name": "Transport"})
366367
assert result == snapshot(
367368
CallToolResult(

tests/client/test_list_methods_cursor.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ async def test_list_methods_params_parameter(
6464
6565
See: https://modelcontextprotocol.io/specification/2025-03-26/server/utilities/pagination#request-format
6666
"""
67-
async with Client(full_featured_server) as client:
67+
async with Client(full_featured_server, mode="legacy") as client:
6868
spies = stream_spy()
6969

7070
# Test without params (omitted)

tests/client/test_list_roots_callback.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ async def test_list_roots(context: Context, message: str):
3131
return True
3232

3333
# Test with list_roots callback
34-
async with Client(server, list_roots_callback=list_roots_callback) as client:
34+
async with Client(server, list_roots_callback=list_roots_callback, mode="legacy") as client:
3535
# Make a request to trigger sampling callback
3636
result = await client.call_tool("test_list_roots", {"message": "test message"})
3737
assert result.is_error is False
@@ -41,7 +41,7 @@ async def test_list_roots(context: Context, message: str):
4141
# Without a list_roots callback the client responds with an MCPError, which the
4242
# tool body doesn't catch — the wrapper re-raises it as a top-level JSON-RPC
4343
# error rather than wrapping it as an isError result.
44-
async with Client(server) as client:
44+
async with Client(server, mode="legacy") as client:
4545
with pytest.raises(MCPError) as exc_info:
4646
await client.call_tool("test_list_roots", {"message": "test message"})
4747
assert exc_info.value.error.code == INVALID_REQUEST

tests/client/test_logging_callback.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@ async def message_handler(
6464
server,
6565
logging_callback=logging_collector,
6666
message_handler=message_handler,
67+
mode="legacy",
6768
) as client:
6869
# First verify our test tool works
6970
result = await client.call_tool("test_tool", {})

0 commit comments

Comments
 (0)