Skip to content

Commit 6a2ee51

Browse files
committed
Harden the dual-era stream loop's era-lock and rejection semantics
Post-merge review follow-up to #3038, fixing the cases where a failed request could still lock the connection's era: - The modern era now locks only when a request SUCCEEDS, mirroring the legacy side's lock-on-success rule. Previously the lock committed at classification time, so a first request with a well-formed envelope but malformed content - or subscriptions/listen, or an unknown method - locked the connection modern after failing; the client's initialize fallback then got -32022, the one code auto-negotiating clients do not fall back from, stranding the connection for its lifetime (a whole subprocess on stdio). - initialize is legacy-distinctive by definition (the method does not exist at modern versions), so it always takes the handshake path now, even when a confused client stamps the envelope triple on it. Previously it routed modern, failed METHOD_NOT_FOUND, and locked the era modern - bricking the handshake the same way. - A bare server/discover on a legacy-locked connection falls through to the loop runner's per-version surface validation again, restoring the byte-identical METHOD_NOT_FOUND a handshake-only server produced; only envelope-bearing frames get the INVALID_REQUEST rejection (a conforming legacy client can never send the reserved triple). - classify_inbound_request rejects a present-but-non-string protocol version as a descriptive INVALID_PARAMS instead of raising ValidationError out of its own -32022 payload construction (`requested` is a str field); the dispatcher's exception ladder happened to mask that as a generic -32602. - _NoServerRequestsDispatchContext masks transport.can_send_request so the per-message transport metadata agrees with the wrapper's denial, matching the modern HTTP entry's context. - NotifyOnlyOutbound inherits _NoChannelOutbound's refusal instead of duplicating it, and the has_standalone_channel / Raises contracts now document that a modern connection refuses requests on a real channel. - Docs: the migration guide and protocol-versions page note the discover probe is transport-independent; the subscriptions page documents the streamable-HTTP-only limitation of subscriptions/listen; serve_loop's stale shared-recipe claim is corrected. Also replaces two pyright suppressions in the stdio test doubles with typeshed-compatible Buffer signatures.
1 parent 0da9092 commit 6a2ee51

9 files changed

Lines changed: 185 additions & 58 deletions

File tree

docs/advanced/subscriptions.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,12 @@ Two more things the stream is *not*:
4646
* **It is not a replay log.** A dropped stream is gone; events published while nobody was connected are not queued. The client's contract is to re-listen and re-fetch what it cares about.
4747
* **It is not the 2025 path.** Clients on earlier protocol versions that called `resources/subscribe` are served by `ctx.session.send_resource_updated(uri)` — the `notify_*` methods reach `subscriptions/listen` streams only.
4848

49+
!!! warning "Streamable HTTP only, for now"
50+
`subscriptions/listen` is served on the streamable-HTTP transport. Over stdio (and other
51+
stream-pair transports) a 2026-07-28 connection rejects it with METHOD_NOT_FOUND — the
52+
open-stream semantics haven't been built for that transport yet, even though
53+
`server/discover` still advertises the subscription capabilities there.
54+
4955
## One process is the default. More takes a bus
5056

5157
Publishes travel from your handler to the open streams over a `SubscriptionBus`. The default is in-memory: one process, every stream in it. That is the right answer until you run replicas behind a load balancer — then a client's stream is pinned to one replica, and a publish on another replica has to reach it.

docs/client/protocol-versions.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ Either way you come out connected, and `client.protocol_version` tells you which
2626
That is the whole feature. One `Client`, any era of server, no branching in your code.
2727

2828
!!! info
29-
`MCPServer` answers `server/discover`, so against your own in-memory server `auto` always lands
30-
on `2026-07-28`. The fallback only ever fires against a real pre-2026 server, which is exactly
31-
when you want it to.
29+
`MCPServer` answers `server/discover` on every transport — in-memory, stdio, streamable
30+
HTTP — so against your own server `auto` always lands on `2026-07-28`. The fallback only
31+
ever fires against a real pre-2026 server, which is exactly when you want it to.
3232

3333
## `mode="legacy"`
3434

docs/migration.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -411,6 +411,8 @@ On the high-level `Client`, `client.server_capabilities`, `client.server_info`,
411411

412412
In v1, connecting to a server always performed the `initialize` handshake. In v2, `Client` defaults to `mode='auto'`: on enter it probes `server/discover` and, if the server doesn't support it, falls back to the `initialize` handshake. Pass `mode='legacy'` to force the initialize handshake and reproduce v1's byte-identical pre-2026 behavior, or pass a modern protocol-version string (e.g. `mode='2026-07-28'`) to pin a version without probing.
413413

414+
The probe is transport-independent: v2 servers answer it over stdio (and any other stream-pair transport) as well as streamable HTTP, so `mode='auto'` lands on `2026-07-28` against a v2 server on every transport. If your stdio workflow relies on server-initiated requests (sampling, push elicitation), pass `mode='legacy'` — a 2026-07-28 connection refuses them on every transport.
415+
414416
For an in-process `Client(server)` (where `server` is a `Server` or `MCPServer` instance), `mode='auto'` dispatches calls directly through `DirectDispatcher` with no JSON-RPC framing. Pass `mode='legacy'` if you need the in-memory JSON-RPC transport that v1 used.
415417

416418
`Client.send_ping()` is deprecated (ping is removed in 2026-07-28); pin `mode='legacy'` if you need it.

src/mcp/server/connection.py

Lines changed: 14 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -100,26 +100,18 @@ async def notify(self, method: str, params: Mapping[str, Any] | None, opts: Call
100100
_NO_CHANNEL = _NoChannelOutbound()
101101

102102

103-
class NotifyOnlyOutbound:
103+
class NotifyOnlyOutbound(_NoChannelOutbound):
104104
"""Connection-scoped `Outbound` that forwards notifications and refuses requests.
105105
106106
Installed by `serve_dual_era_loop` for modern (2026-07-28+) connections
107107
over duplex stream transports: the pipe is real, so server notifications
108108
ride it, but the modern protocol forbids server-initiated JSON-RPC
109-
requests, so `send_raw_request` refuses by construction.
109+
requests, so `send_raw_request` (inherited) refuses by construction.
110110
"""
111111

112112
def __init__(self, outbound: Outbound) -> None:
113113
self._outbound = outbound
114114

115-
async def send_raw_request(
116-
self,
117-
method: str,
118-
params: Mapping[str, Any] | None,
119-
opts: CallOptions | None = None,
120-
) -> dict[str, Any]:
121-
raise NoBackChannelError(method)
122-
123115
async def notify(self, method: str, params: Mapping[str, Any] | None, opts: CallOptions | None = None) -> None:
124116
await self._outbound.notify(method, params, opts)
125117

@@ -230,7 +222,12 @@ def for_loop(
230222
def has_standalone_channel(self) -> bool:
231223
"""Whether this connection has a real back-channel for server-initiated
232224
messages. Derived from `outbound` - the no-channel sentinel is the only
233-
case that doesn't."""
225+
case that doesn't.
226+
227+
Channel presence, not request permission: a modern (2026-07-28+)
228+
duplex connection has a channel that carries notifications while
229+
`send_raw_request` still refuses, because the protocol forbids
230+
server-initiated requests."""
234231
return self.outbound is not _NO_CHANNEL
235232

236233
@property
@@ -255,7 +252,9 @@ async def send_raw_request(
255252
256253
Raises:
257254
MCPError: The peer responded with an error.
258-
NoBackChannelError: `has_standalone_channel` is `False`.
255+
NoBackChannelError: no back-channel for server-initiated requests -
256+
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
257+
connection, where the protocol forbids them.
259258
"""
260259
return await self.outbound.send_raw_request(method, params, opts)
261260

@@ -316,7 +315,9 @@ async def ping(self, *, meta: Meta | None = None, opts: CallOptions | None = Non
316315
317316
Raises:
318317
MCPError: The peer responded with an error.
319-
NoBackChannelError: `has_standalone_channel` is `False`.
318+
NoBackChannelError: no back-channel for server-initiated requests -
319+
`has_standalone_channel` is `False`, or a modern (2026-07-28+)
320+
connection, where the protocol forbids them.
320321
"""
321322
await self.send_raw_request("ping", dump_params(None, meta), opts)
322323

src/mcp/server/runner.py

Lines changed: 59 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@
1515

1616
import logging
1717
from collections.abc import Awaitable, Mapping
18-
from dataclasses import KW_ONLY, dataclass
18+
from dataclasses import KW_ONLY, dataclass, replace
1919
from functools import cached_property, partial
2020
from typing import TYPE_CHECKING, Any, Generic, Literal, cast
2121

@@ -415,13 +415,14 @@ async def serve_loop(
415415
init_options: InitializationOptions | None = None,
416416
raise_exceptions: bool = False,
417417
) -> None:
418-
"""Drive ``server`` in loop mode over a stream pair until the channel closes.
418+
"""Drive ``server`` in handshake-only loop mode over a stream pair until the channel closes.
419419
420420
Builds the loop-mode `JSONRPCDispatcher` + `Connection` and hands them to
421-
`serve_connection`, so loop-mode callers share one dispatcher-construction
422-
recipe (notably the `inline_methods={"initialize"}` rule). Callers that own
423-
a lifespan (the streamable-HTTP manager) pass it in; callers that don't
424-
(`Server.run` for stdio/memory) enter the lifespan and then call this.
421+
`serve_connection`. The streamable-HTTP manager (which owns its lifespan
422+
and serves the modern era on the single-exchange entry instead) calls
423+
this; `Server.run` drives `serve_dual_era_loop`, which extends the same
424+
dispatcher recipe (notably the `inline_methods={"initialize"}` rule) with
425+
era routing.
425426
"""
426427
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
427428
read_stream,
@@ -481,7 +482,11 @@ class _NoServerRequestsDispatchContext:
481482

482483
@property
483484
def transport(self) -> TransportContext:
484-
return self._inner.transport
485+
# Mask the per-message flag so the transport metadata agrees with this
486+
# wrapper's denial: the modern HTTP entry builds its context with
487+
# can_send_request=False, while the loop's default builder says True.
488+
transport = self._inner.transport
489+
return replace(transport, can_send_request=False) if transport.can_send_request else transport
485490

486491
@property
487492
def can_send_request(self) -> bool:
@@ -529,24 +534,35 @@ async def serve_dual_era_loop(
529534
The stream-pair counterpart of the modern HTTP entry's era router. Era is
530535
a property of the connection, decided by how the client opens it, and
531536
mid-stream switching is undefined - so the first era-distinctive message
532-
locks the connection (matching the typescript-sdk):
537+
to SUCCEED locks the connection (matching the typescript-sdk):
533538
534-
- `initialize` locks legacy: the connection behaves exactly like
535-
`serve_loop` for its lifetime, and modern envelope traffic is rejected
536-
with INVALID_REQUEST.
539+
- A successful `initialize` locks legacy: the connection behaves exactly
540+
like `serve_loop` for its lifetime, and modern envelope traffic is then
541+
rejected with INVALID_REQUEST. `initialize` never routes modern - the
542+
method is legacy-distinctive by definition - even when a confused
543+
client stamps the envelope triple on it.
537544
- A request carrying the modern `_meta` envelope triple - or
538-
`server/discover`, a modern-only method - locks modern: every request is
539-
classified (`classify_inbound_request`) and served single-exchange via
540-
`serve_one` with a born-ready per-request `Connection`, the same
541-
dispatch model as the modern HTTP entry. A later `initialize` is
542-
rejected with UNSUPPORTED_PROTOCOL_VERSION naming the modern versions.
545+
`server/discover`, a modern-only method - is classified
546+
(`classify_inbound_request`) and served single-exchange via `serve_one`
547+
with a born-ready per-request `Connection`, the same dispatch model as
548+
the modern HTTP entry. The first such request to succeed locks the
549+
connection modern; a later `initialize` is then rejected with
550+
UNSUPPORTED_PROTOCOL_VERSION naming the modern versions.
543551
544552
Modern connections push notifications over the duplex pipe but refuse
545553
server-initiated requests on both channels (the modern protocol forbids
546-
them). A rejected classification (malformed envelope, unsupported version)
547-
never locks the era, so a failed probe leaves the legacy handshake
548-
available - released auto-negotiating clients fall back on any error code
549-
except -32022.
554+
them). A request that fails - rejected classification, malformed envelope
555+
content, unknown method - never locks either era, so a failed probe
556+
leaves the legacy handshake available: released auto-negotiating clients
557+
fall back on any error code except -32022, and that code is only emitted
558+
for genuine version negotiation or for `initialize` on an
559+
already-modern connection.
560+
561+
The era lock rides the request's own dispatch. For the inline methods
562+
(`initialize`, `server/discover`) that completes before the next frame is
563+
read, so the canonical probe-then-go flow is race-free; a pinned-modern
564+
client that pipelines frames ahead of its first response should expect
565+
envelope-less notifications sent in that window to be dropped.
550566
"""
551567
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
552568
read_stream,
@@ -570,8 +586,6 @@ async def serve_modern(
570586
route = classify_inbound_request({"method": method, "params": params})
571587
if isinstance(route, InboundLadderRejection):
572588
raise MCPError(code=route.code, message=route.message, data=route.data)
573-
if era != "modern":
574-
era, modern_version = "modern", route.protocol_version
575589
if method == "subscriptions/listen":
576590
# The registered listen handler assumes the HTTP entry's stream
577591
# semantics; served over a stream pair it would wedge. Reject until
@@ -585,34 +599,48 @@ async def serve_modern(
585599
route.client_capabilities,
586600
outbound=standalone_outbound,
587601
)
588-
return await serve_one(
602+
result = await serve_one(
589603
server,
590604
_NoServerRequestsDispatchContext(dctx),
591605
method,
592606
params,
593607
connection=connection,
594608
lifespan_state=lifespan_state,
595609
)
610+
if era != "modern":
611+
# Lock only on success, mirroring the legacy side: a request that
612+
# failed (malformed envelope content, unknown method) must not
613+
# strand the client, whose initialize fallback stays available.
614+
era, modern_version = "modern", route.protocol_version
615+
return result
596616

597617
async def on_request(
598618
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
599619
) -> dict[str, Any]:
600620
nonlocal era
601621
if era == "legacy":
602-
if method == "server/discover" or _has_modern_envelope(params):
622+
if _has_modern_envelope(params):
603623
raise MCPError(
604624
code=INVALID_REQUEST,
605625
message="connection is locked to the legacy handshake era; "
606626
"modern envelope requests are not accepted",
607627
)
628+
# Bare modern-only methods (e.g. `server/discover`) fall through to
629+
# the loop runner's per-version surface validation - the same
630+
# METHOD_NOT_FOUND a handshake-only server produced, byte for byte.
608631
return await loop_runner.on_request(dctx, method, params)
609-
if era == "modern" and method == "initialize":
610-
raise MCPError(
611-
code=UNSUPPORTED_PROTOCOL_VERSION,
612-
message="connection already negotiated a modern protocol version",
613-
data=_initialize_after_modern_data(params),
614-
)
615-
if era == "modern" or method == "server/discover" or _has_modern_envelope(params):
632+
if era == "modern":
633+
if method == "initialize":
634+
raise MCPError(
635+
code=UNSUPPORTED_PROTOCOL_VERSION,
636+
message="connection already negotiated a modern protocol version",
637+
data=_initialize_after_modern_data(params),
638+
)
639+
return await serve_modern(dctx, method, params)
640+
# Unlocked. `initialize` is legacy-distinctive by definition (the
641+
# method does not exist at modern versions), so it takes the handshake
642+
# path even when the envelope triple is stamped on it.
643+
if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)):
616644
return await serve_modern(dctx, method, params)
617645
result = await loop_runner.on_request(dctx, method, params)
618646
if method == "initialize":

src/mcp/shared/inbound.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -377,8 +377,8 @@ def classify_inbound_request(
377377
Rungs, in order — first failure wins:
378378
379379
1. `params._meta` is a mapping carrying every reserved envelope key
380-
(protocol version, client info, client capabilities) → else
381-
:data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
380+
(protocol version, client info, client capabilities), and the protocol
381+
version is a string → else :data:`~mcp_types.jsonrpc.INVALID_PARAMS`.
382382
2. When `headers` is given, `MCP-Protocol-Version` equals the envelope's
383383
protocol version, `Mcp-Method` equals `body.method`, and — for the
384384
methods in :data:`NAME_BEARING_METHODS` — `Mcp-Name` equals the named
@@ -411,6 +411,14 @@ def classify_inbound_request(
411411
message="params._meta must carry the reserved protocol-version, client-info and "
412412
"client-capabilities envelope keys",
413413
)
414+
if not isinstance(protocol_version, str):
415+
# A shape defect, not a version-negotiation outcome: -32022 is the one
416+
# code auto-negotiating clients do NOT fall back from, and the typed
417+
# rung-3 payload itself requires a string `requested`.
418+
return InboundLadderRejection(
419+
code=INVALID_PARAMS,
420+
message="the protocol-version envelope value must be a string",
421+
)
414422

415423
if headers is not None:
416424
if headers.get(MCP_PROTOCOL_VERSION_HEADER) != protocol_version:

0 commit comments

Comments
 (0)