Skip to content

Commit 2693424

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 9f40e02 commit 2693424

7 files changed

Lines changed: 508 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
@@ -25,12 +31,14 @@
2531
from mcp.shared.exceptions import NoBackChannelError
2632
from mcp.shared.peer import Meta, dump_params
2733
from mcp.types import (
34+
LATEST_PROTOCOL_VERSION,
2835
ClientCapabilities,
2936
CreateMessageRequest,
3037
CreateMessageResult,
3138
ElicitRequest,
3239
ElicitResult,
3340
EmptyResult,
41+
Implementation,
3442
InitializeRequestParams,
3543
ListRootsRequest,
3644
ListRootsResult,
@@ -67,32 +75,57 @@ def _notification_params(payload: dict[str, Any] | None, meta: Meta | None) -> d
6775
return out
6876

6977

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

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

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

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

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

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

105-
def __init__(self, outbound: Outbound, *, has_standalone_channel: bool, session_id: str | None = None) -> None:
106-
self._outbound = outbound
107-
self.has_standalone_channel = has_standalone_channel
138+
def __init__(
139+
self,
140+
outbound: Outbound,
141+
*,
142+
protocol_version: str,
143+
session_id: str | None = None,
144+
client_params: InitializeRequestParams | None = None,
145+
) -> None:
146+
self.outbound = outbound
147+
self.protocol_version = protocol_version
108148
self.session_id = session_id
109-
110-
self.client_params = None
111-
self.protocol_version = None
149+
self.client_params = client_params
112150
self.initialized = anyio.Event()
113-
114151
self.state = {}
115-
116152
self.exit_stack = AsyncExitStack()
117153

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

125217
async def send_raw_request(
@@ -139,9 +231,7 @@ async def send_raw_request(
139231
MCPError: The peer responded with an error.
140232
NoBackChannelError: `has_standalone_channel` is `False`.
141233
"""
142-
if not self.has_standalone_channel:
143-
raise NoBackChannelError(method)
144-
return await self._outbound.send_raw_request(method, params, opts)
234+
return await self.outbound.send_raw_request(method, params, opts)
145235

146236
@overload
147237
async def send_request(
@@ -176,11 +266,9 @@ async def send_request(
176266
KeyError: `result_type` omitted for a non-spec request type.
177267
"""
178268
raw = await self.send_raw_request(req.method, dump_params(req.params), opts)
179-
# Literal fallback covers pre-handshake and stateless; matches runner.py.
180-
version = self.protocol_version or "2025-11-25"
181269
if req.method in _methods.MONOLITH_REQUESTS:
182270
try:
183-
_methods.validate_client_result(req.method, version, raw)
271+
_methods.validate_client_result(req.method, self.protocol_version, raw)
184272
except KeyError:
185273
pass
186274
cls = result_type if result_type is not None else _RESULT_FOR[type(req)]
@@ -192,11 +280,8 @@ async def notify(self, method: str, params: Mapping[str, Any] | None) -> None:
192280
Never raises. If there's no standalone channel or the stream is broken,
193281
the notification is dropped and debug-logged.
194282
"""
195-
if not self.has_standalone_channel:
196-
logger.debug("dropped %s: no standalone channel", method)
197-
return
198283
try:
199-
await self._outbound.notify(method, params)
284+
await self.outbound.notify(method, params)
200285
except (anyio.BrokenResourceError, anyio.ClosedResourceError):
201286
logger.debug("dropped %s: standalone stream closed", method)
202287

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

0 commit comments

Comments
 (0)