Skip to content

Commit bdcdeb0

Browse files
committed
serve_one returns dict; Client accessor and re-entry guard fixes
serve_one now returns the kernel's dict result and lets exceptions propagate; the modern HTTP entry composes to_jsonrpc_response around it directly, and modern_on_request no longer round-trips through JSONRPCError on the in-process path. The dctx.request_id assert drops. Client: the protocol_version/server_info/server_capabilities accessors now raise the same RuntimeError as .session instead of bare assert. __aenter__ publishes self._session only after the handshake succeeds, and a separate _entered flag makes the one-shot re-entry guard explicit. _drop_notify is renamed to say which direction it sinks. Adds a TODO at mode='legacy' for the eventual default flip and a TODO above the accessors for the connected-view shape. migration.md: point at the era-neutral session/client accessors instead of initialize_result, and stop listing client.instructions as non-nullable. The legacy-mode connect test now passes mode='legacy' explicitly so it asserts what that mode does, not what the default is.
1 parent b326347 commit bdcdeb0

8 files changed

Lines changed: 158 additions & 123 deletions

File tree

docs/migration.md

Lines changed: 12 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -161,7 +161,7 @@ Note: `sse_client` retains its `headers`, `timeout`, `sse_read_timeout`, and `au
161161

162162
### `StreamableHTTPTransport.protocol_version` attribute removed
163163

164-
The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are now supplied per-message by the session. If you were reading `transport.protocol_version` to learn the negotiated version, read it from `session.initialize_result.protocol_version` instead.
164+
The transport no longer holds per-connection protocol state; era-dependent headers (e.g. `MCP-Protocol-Version`) are now supplied per-message by the session. If you were reading `transport.protocol_version` to learn the negotiated version, read `session.protocol_version` (or `client.protocol_version` on the high-level `Client`) instead.
165165

166166
### `terminate_windows_process` removed
167167

@@ -330,9 +330,9 @@ result = await session.list_resources(params=PaginatedRequestParams(cursor="next
330330
result = await session.list_tools(params=PaginatedRequestParams(cursor="next_page_token"))
331331
```
332332

333-
### `ClientSession.get_server_capabilities()` replaced by `initialize_result` property
333+
### `ClientSession.get_server_capabilities()` replaced by era-neutral accessors
334334

335-
`ClientSession` now stores the full `InitializeResult` via an `initialize_result` property. This provides access to `server_info`, `capabilities`, `instructions`, and the negotiated `protocol_version` through a single property. The `get_server_capabilities()` method has been removed.
335+
`ClientSession` now exposes the negotiated server metadata as properties: `server_capabilities`, `server_info`, `instructions`, and `protocol_version`. These are populated by whichever connection step ran (`initialize()` for ≤2025-11-25 servers, `discover()` for 2026-07-28+). The `get_server_capabilities()` method has been removed.
336336

337337
**Before (v1):**
338338

@@ -344,15 +344,15 @@ capabilities = session.get_server_capabilities()
344344
**After (v2):**
345345

346346
```python
347-
result = session.initialize_result
348-
if result is not None:
349-
capabilities = result.capabilities
350-
server_info = result.server_info
351-
instructions = result.instructions
352-
version = result.protocol_version
347+
capabilities = session.server_capabilities
348+
server_info = session.server_info
349+
instructions = session.instructions
350+
version = session.protocol_version
353351
```
354352

355-
The high-level `Client.initialize_result` returns the same `InitializeResult` but is non-nullable — initialization is guaranteed inside the context manager, so no `None` check is needed. Like `session.initialize_result`, this replaces v1's `ClientSession.get_server_capabilities()`; use `client.initialize_result.capabilities` instead.
353+
The raw handshake result is also retained as `session.initialize_result` (legacy path) or `session.discover_result` (modern path) — exactly one is non-`None`.
354+
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.
356356

357357
### `McpError` renamed to `MCPError`
358358

@@ -770,9 +770,9 @@ async def my_tool(ctx: Context[MyLifespanState]) -> str: ...
770770

771771
### Version constants
772772

773-
`SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`.
773+
`SUPPORTED_PROTOCOL_VERSIONS` is deprecated — it's now the union of `HANDSHAKE_PROTOCOL_VERSIONS` (initialize-handshake versions) and `MODERN_PROTOCOL_VERSIONS` (per-request-envelope versions). If you were using it to mean "versions the initialize handshake accepts", switch to `HANDSHAKE_PROTOCOL_VERSIONS`. Named scalars derived from these tuples are now exported alongside them — `LATEST_HANDSHAKE_VERSION`, `LATEST_MODERN_VERSION`, `OLDEST_SUPPORTED_VERSION` — so prefer those over indexing the tuples directly.
774774

775-
`LATEST_PROTOCOL_VERSION` now reflects the newest protocol revision the SDK supports (`2026-07-28`). Code that used it to mean "the version `.initialize()` offers" should switch to `HANDSHAKE_PROTOCOL_VERSIONS[-1]`.
775+
`LATEST_PROTOCOL_VERSION` now reflects the newest protocol revision the SDK supports (`2026-07-28`). Code that used it to mean "the version `.initialize()` offers" should switch to `LATEST_HANDSHAKE_VERSION`.
776776

777777
### `ProgressContext` and `progress()` context manager removed
778778

src/mcp/client/client.py

Lines changed: 44 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@
55
from collections.abc import Mapping
66
from contextlib import AsyncExitStack
77
from dataclasses import KW_ONLY, dataclass, field
8-
from typing import Any, Literal
8+
from typing import Any, Literal, TypeVar
99

1010
import anyio
1111
from typing_extensions import deprecated
@@ -48,6 +48,20 @@
4848
initialize), or a modern protocol-version string (adopt directly). The ``str`` arm is for
4949
forward-compat; ``Client.__post_init__`` rejects anything outside that set at construction."""
5050

51+
_T = TypeVar("_T")
52+
53+
54+
def _connected(value: _T | None) -> _T:
55+
"""Narrow a post-handshake session attribute from ``T | None`` to ``T``.
56+
57+
``Client.__aenter__`` only assigns ``_session`` after the handshake succeeds, so inside
58+
``async with Client(...)`` these attributes are always populated; the ``.session`` gate
59+
raises before this is reached otherwise. The guard exists for pyright, not runtime.
60+
"""
61+
if value is None: # pragma: no cover
62+
raise RuntimeError("Client must be used within an async context manager")
63+
return value
64+
5165

5266
def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
5367
return types.DiscoverResult(
@@ -60,11 +74,14 @@ def _synthesize_discover(protocol_version: str) -> types.DiscoverResult:
6074
)
6175

6276

63-
async def _drop_notify(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None:
64-
"""Server-side ``OnNotify`` for the modern in-process path: client→server notifications are dropped.
77+
async def _no_inbound_client_notifications(_dctx: Any, _method: str, _params: Mapping[str, Any] | None) -> None:
78+
"""Server-side inbound ``OnNotify`` for the modern in-process path — receives nothing.
6579
66-
The per-request driver (`serve_one`) has no notification dispatch table; progress and
67-
cancellation travel via `CallOptions` on the `DirectDispatcher`, not as JSON-RPC notifies.
80+
At 2026-07-28 the spec defines no client→server notifications: ``initialized`` and
81+
``roots/list_changed`` are removed, and cancellation is structural (anyio scope cancel
82+
through the direct await, not a notify). Server→client notifications (progress, log
83+
messages) flow the other way via the per-request ``DispatchContext`` into the client's
84+
callbacks, and are not seen here.
6885
"""
6986

7087

@@ -127,6 +144,8 @@ async def main():
127144
client_info: Implementation | None = None
128145
"""Client implementation info to send to server."""
129146

147+
# TODO(maxisbey): flip default to 'auto' once the in-proc test suite is era-decoupled
148+
# and the probe-timeout fallback is transport-aware (stdio→fallback / HTTP→reject).
130149
mode: ConnectMode = "legacy"
131150
"""'legacy' performs the initialize handshake. 'auto' probes server/discover and falls back to initialize()
132151
on legacy servers. A modern protocol-version string (e.g. '2026-07-28') adopts that version directly without
@@ -139,6 +158,7 @@ async def main():
139158
elicitation_callback: ElicitationFnT | None = None
140159
"""Callback for handling elicitation requests."""
141160

161+
_entered: bool = field(init=False, default=False)
142162
_session: ClientSession | None = field(init=False, default=None)
143163
_exit_stack: AsyncExitStack | None = field(init=False, default=None)
144164
_transport: Transport | None = field(init=False, default=None)
@@ -175,7 +195,7 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
175195
tg = await exit_stack.enter_async_context(anyio.create_task_group())
176196
exit_stack.callback(server_disp.close)
177197
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)
198+
await tg.start(server_disp.run, on_request, _no_inbound_client_notifications)
179199
dispatcher = client_disp
180200
read_stream = write_stream = None
181201
else:
@@ -201,27 +221,31 @@ async def _build_session(self, exit_stack: AsyncExitStack) -> ClientSession:
201221

202222
async def __aenter__(self) -> Client:
203223
"""Enter the async context manager."""
204-
if self._session is not None:
224+
if self._entered:
205225
raise RuntimeError("Client is already entered; cannot reenter")
226+
self._entered = True
206227

207228
async with AsyncExitStack() as exit_stack:
208229
session = await self._build_session(exit_stack)
209-
self._session = await exit_stack.enter_async_context(session)
230+
session = await exit_stack.enter_async_context(session)
210231

211232
if self.mode == "legacy":
212-
await self._session.initialize()
233+
await session.initialize()
213234
elif self.mode == "auto":
214235
try:
215-
await self._session.discover()
236+
await session.discover()
216237
except MCPError as e:
217238
if e.code in (METHOD_NOT_FOUND, REQUEST_TIMEOUT):
218-
await self._session.initialize()
239+
await session.initialize()
219240
else:
220241
raise
221242
else:
222-
self._session.adopt(self.prior_discover or _synthesize_discover(self.mode))
243+
session.adopt(self.prior_discover or _synthesize_discover(self.mode))
223244

224-
# Transfer ownership to self for __aexit__ to handle
245+
# Only publish the session after the handshake succeeds, so `_session is not None`
246+
# implies the protocol_version/server_info/server_capabilities are populated. If the
247+
# handshake raised above, the local exit_stack unwinds the transport for us.
248+
self._session = session
225249
self._exit_stack = exit_stack.pop_all()
226250
return self
227251

@@ -244,26 +268,24 @@ def session(self) -> ClientSession:
244268
raise RuntimeError("Client must be used within an async context manager")
245269
return self._session
246270

271+
# TODO(maxisbey): the by-construction shape is for __aenter__ to return a connected-view
272+
# type whose protocol_version/server_info/server_capabilities are non-Optional fields,
273+
# eliminating these guards (and the one in .session). Same family as resolving the
274+
# transport/connector at __post_init__ so the Optional internal fields disappear.
247275
@property
248276
def protocol_version(self) -> str:
249277
"""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
278+
return _connected(self.session.protocol_version)
253279

254280
@property
255281
def server_info(self) -> Implementation:
256282
"""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
283+
return _connected(self.session.server_info)
260284

261285
@property
262286
def server_capabilities(self) -> ServerCapabilities:
263287
"""Server capabilities (set by initialize/discover/adopt during ``__aenter__``)."""
264-
caps = self.session.server_capabilities
265-
assert caps is not None
266-
return caps
288+
return _connected(self.session.server_capabilities)
267289

268290
@property
269291
def instructions(self) -> str | None:

src/mcp/server/_streamable_http_modern.py

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@
2525
from starlette.types import Receive, Scope, Send
2626

2727
from mcp.server.connection import Connection
28-
from mcp.server.runner import serve_one
28+
from mcp.server.runner import serve_one, to_jsonrpc_response
2929
from mcp.server.transport_security import TransportSecurityMiddleware, TransportSecuritySettings
3030
from mcp.shared.dispatcher import CallOptions
3131
from mcp.shared.exceptions import NoBackChannelError
@@ -193,5 +193,7 @@ async def handle_modern_request(
193193
request_id=req.id,
194194
message_metadata=ServerMessageMetadata(request_context=request),
195195
)
196-
msg = await serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
196+
msg = await to_jsonrpc_response(
197+
req.id, serve_one(app, dctx, req.method, req.params, connection=connection, lifespan_state=lifespan_state)
198+
)
197199
await _write(msg, scope, receive, send)

src/mcp/server/runner.py

Lines changed: 30 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher, handler_exception_to_error_data
3737
from mcp.shared.message import ServerMessageMetadata, SessionMessage
3838
from mcp.shared.transport_context import TransportContext
39-
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, MODERN_PROTOCOL_VERSIONS
39+
from mcp.shared.version import HANDSHAKE_PROTOCOL_VERSIONS, LATEST_HANDSHAKE_VERSION, LATEST_MODERN_VERSION
4040
from mcp.types import (
4141
CLIENT_CAPABILITIES_META_KEY,
4242
CLIENT_INFO_META_KEY,
@@ -187,13 +187,12 @@ async def to_jsonrpc_response(
187187
) -> JSONRPCResponse | JSONRPCError:
188188
"""Await ``coro`` and wrap its outcome as the JSON-RPC reply for ``request_id``.
189189
190-
The exception-to-wire boundary for the request-per-call drivers
191-
(`serve_one`, the modern HTTP entry). `MCPError` and `ValidationError`
190+
The exception-to-wire boundary for the modern HTTP entry, which composes
191+
this around `serve_one` directly. `MCPError` and `ValidationError`
192192
map via the shared `handler_exception_to_error_data` ladder; any other
193193
exception is logged and surfaced as `INTERNAL_ERROR` so handler internals
194194
never reach the wire. Set ``raise_unhandled`` to let unmapped exceptions
195-
propagate instead of being sanitized — used by the in-process test path so
196-
handler tracebacks reach the caller.
195+
propagate instead of being sanitized.
197196
"""
198197
try:
199198
result = await coro
@@ -422,7 +421,7 @@ def _negotiate_initialize(params: Mapping[str, Any] | None) -> tuple[InitializeR
422421
"""Validate `initialize` params and pick the protocol version."""
423422
init = InitializeRequestParams.model_validate(params or {}, by_name=False)
424423
requested = init.protocol_version
425-
negotiated = requested if requested in HANDSHAKE_PROTOCOL_VERSIONS else HANDSHAKE_PROTOCOL_VERSIONS[-1]
424+
negotiated = requested if requested in HANDSHAKE_PROTOCOL_VERSIONS else LATEST_HANDSHAKE_VERSION
426425
return init, negotiated
427426

428427
def _handle_initialize(self, params: Mapping[str, Any] | None) -> InitializeResult:
@@ -508,25 +507,21 @@ async def serve_one(
508507
*,
509508
connection: Connection,
510509
lifespan_state: LifespanT,
511-
raise_exceptions: bool = False,
512-
) -> JSONRPCResponse | JSONRPCError:
513-
"""Handle a single request ``(method, params)`` and return its JSON-RPC reply.
510+
) -> dict[str, Any]:
511+
"""Handle a single request ``(method, params)`` and return its result dict.
514512
515513
The single-exchange driver: builds the kernel, runs `on_request` once under
516-
`dctx`, maps the outcome to a `JSONRPCResponse` / `JSONRPCError` via
517-
`to_jsonrpc_response`, and tears down `connection.exit_stack` (shielded) on
518-
the way out. The entry constructs the (born-ready) `Connection` and the
519-
`dctx`; this only consumes them. ``raise_exceptions`` lets unmapped handler
520-
exceptions propagate instead of being sanitized to `INTERNAL_ERROR`.
514+
`dctx`, and tears down `connection.exit_stack` (shielded) on the way out.
515+
The entry constructs the (born-ready) `Connection` and the `dctx`; this
516+
only consumes them.
517+
518+
Raises whatever the handler chain raises (`MCPError` / `ValidationError` /
519+
unmapped); callers own the exception-to-wire mapping. The HTTP entry
520+
composes this with `to_jsonrpc_response`.
521521
"""
522522
runner = ServerRunner(server, connection, lifespan_state)
523523
try:
524-
# Single-exchange driver only handles requests; both entries populate `request_id`.
525-
# TODO(L54): drop once `DispatchContext` is split so `OnRequest` carries a non-Optional id.
526-
assert dctx.request_id is not None
527-
return await to_jsonrpc_response(
528-
dctx.request_id, runner.on_request(dctx, method, params), raise_unhandled=raise_exceptions
529-
)
524+
return await runner.on_request(dctx, method, params)
530525
finally:
531526
await aclose_shielded(connection)
532527

@@ -540,29 +535,30 @@ def modern_on_request(
540535
in-process server on the modern per-request-envelope path (each request
541536
carries protocol version, client info, and capabilities in `params._meta`;
542537
no `initialize` handshake). ``raise_exceptions`` lets unmapped handler
543-
exceptions propagate to the caller for debuggable in-process testing.
538+
exceptions propagate to the caller for debuggable in-process testing;
539+
otherwise they are sanitized to `MCPError(INTERNAL_ERROR)` so the in-process
540+
path matches the wire path's leak guard.
544541
"""
545542

546543
async def handle(
547544
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
548545
) -> dict[str, Any]:
549546
meta = (params or {}).get("_meta", {})
550547
connection = Connection.from_envelope(
551-
meta.get(PROTOCOL_VERSION_META_KEY, MODERN_PROTOCOL_VERSIONS[-1]),
548+
meta.get(PROTOCOL_VERSION_META_KEY, LATEST_MODERN_VERSION),
552549
meta.get(CLIENT_INFO_META_KEY),
553550
meta.get(CLIENT_CAPABILITIES_META_KEY),
554551
)
555-
msg = await serve_one(
556-
server,
557-
dctx,
558-
method,
559-
params,
560-
connection=connection,
561-
lifespan_state=lifespan_state,
562-
raise_exceptions=raise_exceptions,
563-
)
564-
if isinstance(msg, JSONRPCError):
565-
raise MCPError(code=msg.error.code, message=msg.error.message, data=msg.error.data)
566-
return msg.result
552+
try:
553+
return await serve_one(server, dctx, method, params, connection=connection, lifespan_state=lifespan_state)
554+
except (MCPError, ValidationError):
555+
# DirectDispatcher's ladder maps these onward; this layer only owns the raise_exceptions
556+
# decision for unmapped exceptions, which DirectDispatcher would otherwise leak via str(exc).
557+
raise
558+
except Exception:
559+
if raise_exceptions:
560+
raise
561+
logger.exception("request handler raised")
562+
raise MCPError(code=INTERNAL_ERROR, message="Internal server error") from None
567563

568564
return handle

0 commit comments

Comments
 (0)