Skip to content

Commit ff73d6b

Browse files
committed
S2 waves 1-3: driver-split kernel + two-channel ServerSession + classifier tests
ServerRunner becomes the handler kernel only: - ServerRunner(server, connection, lifespan_state, *, dispatch_middleware) — no run(), no dispatcher field, no stateless flag, no __post_init__ Connection construction - on_request/on_notify as cached_property (middleware composed once) - _resolve_protocol_version deleted; kernel reads connection.protocol_version as a fact - serve_connection(server, dispatcher, *, connection, lifespan_state, init_options) and serve_one(server, request, *, connection, dctx, lifespan_state) free-function drivers; both aclose_shielded(connection) in finally; neither constructs Connection Connection factories replace post-construction mutation: - from_envelope(pv, client_info, caps, *, outbound=_NO_CHANNEL) — born-ready, initialized set - for_loop(outbound, *, session_id, protocol_version_hint) — handshake-driven, version seeded - protocol_version: str non-Optional; has_standalone_channel derived (outbound is not _NO_CHANNEL) - _NoChannelOutbound private singleton: send_raw_request raises NoBackChannelError, notify drops ServerSession two-channel selector (closes the related-request-id routing on stateful HTTP): - ServerSession(request_outbound, connection, *, standalone_outbound) per request in _make_context - send_request/send_notification select channel by related_request_id presence - StatelessModeNotSupported + four if-stateless guards deleted (subsumed by _NoChannelOutbound) - Both type:ignore[call-arg] removed; nothing transport-specific crosses the Outbound Protocol Also: - streamable_http.py: ServerMessageMetadata(protocol_version=...) writers deleted (no readers) - tests/shared/test_inbound.py: 32 table-driven classifier tests, 100% branch coverage - tests/server/test_runner.py: resolver tests deleted, fixture migrated to factories (partial; full migration in T2) Tree is intentionally mid-reshape: lowlevel.Server.run() and the modern HTTP entry still call the deleted ServerRunner.run() / SingleExchangeDispatcher; wave 4 (D5/H3) rewrites those. Part of #2891.
1 parent 0f6a02f commit ff73d6b

7 files changed

Lines changed: 509 additions & 283 deletions

File tree

src/mcp/server/connection.py

Lines changed: 128 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,18 +1,24 @@
11
"""`Connection` - per-client connection state and the standalone outbound channel.
22
33
Always present on `Context` (never `None`), even in stateless deployments.
4-
Holds peer info populated at `initialize` time, per-connection scratch
5-
`state` and an `exit_stack` for teardown, and an `Outbound` for the
6-
standalone stream (the SSE GET stream in streamable HTTP, or the single duplex
7-
stream in stdio).
4+
Holds peer info, per-connection scratch `state` and an `exit_stack` for
5+
teardown, and an `Outbound` for the standalone stream (the SSE GET stream in
6+
streamable HTTP, or the single duplex stream in stdio).
7+
8+
Construct via the factories: `Connection.from_envelope` for the 2026-era
9+
single-exchange path (born ready, no back-channel) and `Connection.for_loop`
10+
for the handshake-driven loop path. Both populate `protocol_version` so the
11+
kernel reads it as a fact.
812
913
`notify` is best-effort: it never raises. If there's no standalone channel
10-
(stateless HTTP) or the stream has been dropped, the notification is
11-
debug-logged and silently discarded - server-initiated notifications are
12-
inherently advisory. `send_raw_request` *does* raise `NoBackChannelError` when
13-
there's no channel; `ping` is the only spec-sanctioned standalone request.
14+
or the stream has been dropped, the notification is debug-logged and silently
15+
discarded - server-initiated notifications are inherently advisory.
16+
`send_raw_request` raises `NoBackChannelError` when there's no channel; `ping`
17+
is the only spec-sanctioned standalone request.
1418
"""
1519

20+
from __future__ import annotations
21+
1622
import logging
1723
from collections.abc import Mapping
1824
from contextlib import AsyncExitStack
@@ -26,12 +32,14 @@
2632
from mcp.shared.exceptions import MCPDeprecationWarning, NoBackChannelError
2733
from mcp.shared.peer import Meta, dump_params
2834
from mcp.types import (
35+
LATEST_PROTOCOL_VERSION,
2936
ClientCapabilities,
3037
CreateMessageRequest,
3138
CreateMessageResult,
3239
ElicitRequest,
3340
ElicitResult,
3441
EmptyResult,
42+
Implementation,
3543
InitializeRequestParams,
3644
ListRootsRequest,
3745
ListRootsResult,
@@ -68,32 +76,57 @@ def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> d
6876
return out
6977

7078

79+
class _NoChannelOutbound:
80+
"""Connection-scoped `Outbound` for the no-back-channel case.
81+
82+
The structural answer to "this connection cannot push to its peer":
83+
`send_raw_request` raises `NoBackChannelError`; `notify` drops with a
84+
debug log. `Connection.from_envelope` installs this so the modern
85+
single-exchange path never needs a mode flag - the channel itself says no.
86+
"""
87+
88+
async def send_raw_request(
89+
self,
90+
method: str,
91+
params: Mapping[str, Any] | None,
92+
opts: CallOptions | None = None,
93+
) -> dict[str, Any]:
94+
raise NoBackChannelError(method)
95+
96+
async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
97+
logger.debug("dropped %s: no standalone channel", method)
98+
99+
100+
_NO_CHANNEL = _NoChannelOutbound()
101+
102+
71103
class Connection:
72104
"""Per-client connection state and standalone-stream `Outbound`.
73105
74-
Constructed by `ServerRunner` once per connection. The peer-info fields
75-
are `None` until `initialize` completes; `initialized` is set later, when
76-
the client's `notifications/initialized` follow-up arrives. In stateless
77-
deployments the runner sets `initialized` immediately and peer-info
78-
remains `None` (no handshake reaches a stateless connection).
106+
Construct via `from_envelope` (modern single-exchange: born ready, no
107+
back-channel) or `for_loop` (handshake-driven: ready once the client's
108+
`notifications/initialized` arrives). Either way `protocol_version` is
109+
populated at construction.
79110
"""
80111

81-
has_standalone_channel: bool
112+
outbound: Outbound
113+
"""The connection-scoped channel for server-initiated messages."""
114+
82115
session_id: str | None
83116

84117
client_params: InitializeRequestParams | None
85-
"""The full `initialize` request params; `None` before initialization."""
118+
"""The full `initialize` request params, or the equivalent built from the
119+
2026-era envelope. `None` when no client info was supplied."""
86120

87-
protocol_version: str | None
88-
"""The protocol version negotiated during `initialize`; `None` before
89-
initialization. Stateless connections don't require the handshake, so this
90-
normally stays `None` there (a client that sends `initialize` anyway still
91-
commits it). For the per-request value, read `ctx.protocol_version`."""
121+
protocol_version: str
122+
"""The protocol version this connection speaks. Populated at construction
123+
by the factory and overwritten by `_handle_initialize` once the handshake
124+
commits on the loop path."""
92125

93126
initialized: anyio.Event
94127
"""Set when `notifications/initialized` arrives (matches TS `oninitialized`);
95128
the point from which the spec permits server-initiated requests beyond
96-
ping/logging. Pre-set on stateless connections."""
129+
ping/logging. Pre-set on connections built via `from_envelope`."""
97130

98131
state: dict[str, Any]
99132
"""Per-connection scratch state; persists across requests on this connection."""
@@ -103,24 +136,83 @@ class Connection:
103136
closes. Push cleanup from handlers or middleware; exceptions are logged
104137
and swallowed."""
105138

106-
def __init__(self, outbound: Outbound, *, has_standalone_channel: bool, session_id: str | None = None) -> None:
107-
self._outbound = outbound
108-
self.has_standalone_channel = has_standalone_channel
139+
def __init__(
140+
self,
141+
outbound: Outbound,
142+
*,
143+
protocol_version: str,
144+
session_id: str | None = None,
145+
client_params: InitializeRequestParams | None = None,
146+
) -> None:
147+
self.outbound = outbound
148+
self.protocol_version = protocol_version
109149
self.session_id = session_id
110-
111-
self.client_params = None
112-
self.protocol_version = None
150+
self.client_params = client_params
113151
self.initialized = anyio.Event()
114-
115152
self.state = {}
116-
117153
self.exit_stack = AsyncExitStack()
118154

155+
@classmethod
156+
def from_envelope(
157+
cls,
158+
protocol_version: str,
159+
client_info: Implementation | None,
160+
client_capabilities: ClientCapabilities | None,
161+
*,
162+
outbound: Outbound = _NO_CHANNEL,
163+
) -> Connection:
164+
"""A born-ready connection populated from a request's `_meta` envelope.
165+
166+
`initialized` is set and the envelope's client info/capabilities (when
167+
both supplied) are recorded as `client_params` so capability checks
168+
work. `outbound` defaults to the no-channel sentinel for the
169+
single-exchange HTTP path; duplex modern transports (e.g. stdio) pass
170+
the dispatcher so server-initiated messages have a back-channel.
171+
"""
172+
client_params = None
173+
if client_info is not None and client_capabilities is not None:
174+
client_params = InitializeRequestParams(
175+
protocol_version=protocol_version,
176+
capabilities=client_capabilities,
177+
client_info=client_info,
178+
)
179+
connection = cls(outbound, protocol_version=protocol_version, client_params=client_params)
180+
connection.initialized.set()
181+
return connection
182+
183+
@classmethod
184+
def for_loop(
185+
cls,
186+
outbound: Outbound,
187+
*,
188+
session_id: str | None = None,
189+
protocol_version_hint: str | None = None,
190+
) -> Connection:
191+
"""A connection for the handshake-driven loop path.
192+
193+
Not born-ready: `initialized` is set later by the kernel when
194+
`notifications/initialized` arrives. `protocol_version` is seeded from
195+
the transport hint (or `LATEST_PROTOCOL_VERSION`) so it's never `None`;
196+
the handshake overwrites it once negotiated.
197+
"""
198+
return cls(
199+
outbound,
200+
protocol_version=protocol_version_hint if protocol_version_hint is not None else LATEST_PROTOCOL_VERSION,
201+
session_id=session_id,
202+
)
203+
204+
@property
205+
def has_standalone_channel(self) -> bool:
206+
"""Whether this connection has a real back-channel for server-initiated
207+
messages. Derived from `outbound` - the no-channel sentinel is the only
208+
case that doesn't."""
209+
return self.outbound is not _NO_CHANNEL
210+
119211
@property
120212
def initialize_accepted(self) -> bool:
121213
"""True once the inbound request gate is open: `initialize` recorded the
122-
peer info, or the handshake completed outright (stateless birth, or a
123-
bare `notifications/initialized`). Derived, never stored."""
214+
peer info, or the handshake completed outright (born-ready, or a bare
215+
`notifications/initialized`). Derived, never stored."""
124216
return self.client_params is not None or self.initialized.is_set()
125217

126218
async def send_raw_request(
@@ -140,9 +232,7 @@ async def send_raw_request(
140232
MCPError: The peer responded with an error.
141233
NoBackChannelError: `has_standalone_channel` is `False`.
142234
"""
143-
if not self.has_standalone_channel:
144-
raise NoBackChannelError(method)
145-
return await self._outbound.send_raw_request(method, params, opts)
235+
return await self.outbound.send_raw_request(method, params, opts)
146236

147237
@overload
148238
async def send_request(
@@ -177,11 +267,9 @@ async def send_request(
177267
KeyError: `result_type` omitted for a non-spec request type.
178268
"""
179269
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
180-
# Literal fallback covers pre-handshake and stateless; matches runner.py.
181-
version = self.protocol_version or "2025-11-25"
182270
if req.method in _methods.MONOLITH_REQUESTS:
183271
try:
184-
_methods.validate_client_result(req.method, version, raw)
272+
_methods.validate_client_result(req.method, self.protocol_version, raw)
185273
except KeyError:
186274
pass
187275
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
@@ -193,11 +281,8 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
193281
Never raises. If there's no standalone channel or the stream is broken,
194282
the notification is dropped and debug-logged.
195283
"""
196-
if not self.has_standalone_channel:
197-
logger.debug("dropped %s: no standalone channel", method)
198-
return
199284
try:
200-
await self._outbound.notify(method, params)
285+
await self.outbound.notify(method, params)
201286
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
202287
logger.debug("dropped %s: standalone stream closed", method)
203288

@@ -233,9 +318,9 @@ async def send_resource_updated(self, uri: str, *, meta: Meta | None = None) ->
233318
def check_capability(self, capability: ClientCapabilities) -> bool:
234319
"""Return whether the connected client declared the given capability.
235320
236-
Returns `False` if `initialize` hasn't completed yet.
321+
Returns `False` when no client info has been recorded.
237322
"""
238-
# TODO: redesign - mirrors v1 ServerSession.check_client_capability
323+
# TODO(L29): redesign - mirrors v1 ServerSession.check_client_capability
239324
# verbatim for parity.
240325
if self.client_params is None:
241326
return False

0 commit comments

Comments
 (0)