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
2632from mcp .shared .exceptions import MCPDeprecationWarning , NoBackChannelError
2733from mcp .shared .peer import Meta , dump_params
2834from 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+
71103class 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