11"""`Connection` - per-client connection state and the standalone outbound channel.
22
33Always 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+
1622import logging
1723from collections .abc import Mapping
1824from contextlib import AsyncExitStack
2531from mcp .shared .exceptions import NoBackChannelError
2632from mcp .shared .peer import Meta , dump_params
2733from 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+
70102class 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