Skip to content

Commit fee8c52

Browse files
committed
Serve the 2026-07-28 protocol over stdio by deciding the era from the opening request
The stdio driver derived a connection's protocol era from whichever request finished first, so it could never host a request that does not return: subscriptions/listen was hard-refused with -32601 even though the same connection advertised list-changed capabilities, and a legacy handshake arriving during a slow 2026 request was accepted and locked the connection out from under it. Decide the era from the client's first request instead, once, before any handler runs: a request carrying the 2026-07-28 per-request envelope opens a 2026 connection, anything else (the initialize handshake) opens a 2025 one, and the deciding frame is replayed into the chosen serving loop. A conflicting later claim is refused in one place (initialize on a 2026 connection gets -32022 naming the served versions; an enveloped request on a handshake connection gets -32600). The listen refusal is deleted; the handler was always transport-agnostic.
1 parent 837ef90 commit fee8c52

3 files changed

Lines changed: 214 additions & 229 deletions

File tree

docs/whats-new.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,7 @@ That file is the pitch in one place: one server, one `Resolve`-backed tool, and
191191

192192
### Change notifications become one stream
193193

194-
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver. One honest caveat: over stdio the server does not serve the stream yet.
194+
At 2026-07-28 the standalone HTTP GET stream and `resources/subscribe` are replaced by `subscriptions/listen`: the client opens one long-lived stream and names the notification kinds it wants. `MCPServer` serves it out of the box; you publish with `await ctx.notify_resource_updated(uri)` (and `notify_tools_changed()`, and so on), and multi-replica deployments plug in a shared `SubscriptionBus`. On the client (since `2.0.0b2`), `async with client.listen(...)` opens the stream: the filter goes in as keyword arguments, typed change events come back, and `sub.honored` is the subset the server agreed to deliver.
195195

196196
**[Subscriptions](handlers/subscriptions.md)** covers publishing and serving, **[its Clients twin](client/subscriptions.md)** the watching end, and **[Deploy & scale](run/deploy.md)** the bus.
197197

src/mcp/server/runner.py

Lines changed: 142 additions & 121 deletions
Original file line numberDiff line numberDiff line change
@@ -14,10 +14,11 @@
1414
from __future__ import annotations
1515

1616
import logging
17-
from collections.abc import Awaitable, Mapping
17+
from collections.abc import AsyncIterator, Awaitable, Mapping
18+
from contextlib import asynccontextmanager
1819
from dataclasses import KW_ONLY, dataclass, replace
1920
from functools import cached_property, partial
20-
from typing import TYPE_CHECKING, Any, Generic, Literal, cast
21+
from typing import TYPE_CHECKING, Any, Generic, cast
2122

2223
import anyio
2324
import anyio.abc
@@ -37,6 +38,7 @@
3738
Implementation,
3839
InitializeRequestParams,
3940
InitializeResult,
41+
JSONRPCRequest,
4042
RequestId,
4143
RequestParams,
4244
RequestParamsMeta,
@@ -605,91 +607,153 @@ async def serve_dual_era_loop(
605607
init_options: InitializationOptions | None = None,
606608
raise_exceptions: bool = False,
607609
) -> None:
608-
"""Drive `server` over a duplex stream pair, serving both protocol eras.
609-
610-
The stream-pair counterpart of the modern HTTP entry's era router. Era is
611-
a property of the connection, decided by how the client opens it, and
612-
mid-stream switching is undefined - so the first era-distinctive message
613-
to SUCCEED locks the connection (matching the typescript-sdk):
614-
615-
- A successful `initialize` locks legacy: the connection behaves exactly
616-
like `serve_loop` for its lifetime, and modern envelope traffic is then
617-
rejected with INVALID_REQUEST. `initialize` never routes modern - the
618-
method is legacy-distinctive by definition - even when a confused
619-
client stamps the envelope keys on it.
620-
- A request whose `_meta` declares the modern protocol version - or
621-
`server/discover`, a modern-only method - is classified
622-
(`classify_inbound_request`) and served single-exchange via `serve_one`
623-
with a born-ready per-request `Connection`, the same dispatch model as
624-
the modern HTTP entry. The first such request to succeed locks the
625-
connection modern; a later `initialize` is then rejected with
626-
UNSUPPORTED_PROTOCOL_VERSION naming the modern versions.
627-
628-
Modern connections push notifications over the duplex pipe but refuse
629-
server-initiated requests on both channels (the modern protocol forbids
630-
them). A request that fails - rejected classification, malformed envelope
631-
content, unknown method - never locks either era, so a failed probe
632-
leaves the legacy handshake available: released auto-negotiating clients
633-
fall back on any error code except -32022, and that code is only emitted
634-
for genuine version negotiation or for `initialize` on an
635-
already-modern connection.
636-
637-
The era lock rides the request's own dispatch. For the inline methods
638-
(`initialize`, `server/discover`) that completes before the next frame is
639-
read, so the canonical probe-then-go flow is race-free; a pinned-modern
640-
client that pipelines frames ahead of its first response should expect
641-
envelope-less notifications sent in that window to be dropped. The lock
642-
settles exactly once: a request from the other era that was already in
643-
flight when the lock committed may still complete and its response
644-
stands, but the era does not move; and a success the peer cancelled away
645-
(it sees "Request cancelled", not the result) does not lock either.
610+
"""Drive `server` over a duplex stream pair, in the era the client opens with.
611+
612+
The client's first request decides the connection's protocol era, once:
613+
a request carrying the 2026-07-28 per-request `_meta` envelope opens a
614+
modern connection, and anything else - the `initialize` handshake, which
615+
does not exist at 2026 versions even when a client stamps the envelope on
616+
it - opens a legacy one. The deciding frame is replayed into the chosen
617+
serving loop along with everything the client sent before it. A later
618+
claim from the other era is refused: `initialize` on a modern connection
619+
gets UNSUPPORTED_PROTOCOL_VERSION naming the served versions, and an
620+
enveloped request on a legacy connection gets INVALID_REQUEST.
646621
"""
622+
# This loop owns both streams from the moment it is called, so the write
623+
# stream is closed even if the client leaves before sending any request.
624+
try:
625+
async with _replay_from_opening_request(read_stream) as (opening, replayed):
626+
opens_modern = (
627+
opening is not None and opening.method != "initialize" and _has_modern_envelope(opening.params)
628+
)
629+
if opens_modern:
630+
await _serve_modern_stream(
631+
server, replayed, write_stream, lifespan_state=lifespan_state, raise_exceptions=raise_exceptions
632+
)
633+
else:
634+
await _serve_legacy_stream(
635+
server,
636+
replayed,
637+
write_stream,
638+
lifespan_state=lifespan_state,
639+
session_id=session_id,
640+
init_options=init_options,
641+
raise_exceptions=raise_exceptions,
642+
)
643+
finally:
644+
await write_stream.aclose()
645+
646+
647+
@asynccontextmanager
648+
async def _replay_from_opening_request(
649+
read_stream: ReadStream[SessionMessage | Exception],
650+
) -> AsyncIterator[tuple[JSONRPCRequest | None, ReadStream[SessionMessage | Exception]]]:
651+
"""Peek at the client's first request without consuming anything.
652+
653+
Reads frames until the first JSON-RPC request arrives, then yields that
654+
request together with a stream that replays every frame read so far and
655+
relays the rest of `read_stream` behind it. The request is `None` if the
656+
client closed the channel before sending any request.
657+
"""
658+
peeked: list[SessionMessage | Exception] = []
659+
opening: JSONRPCRequest | None = None
660+
replay_send, replayed = anyio.create_memory_object_stream[SessionMessage | Exception]()
661+
662+
async def replay_then_relay() -> None:
663+
async with replay_send:
664+
for item in peeked:
665+
await replay_send.send(item)
666+
async for item in read_stream:
667+
await replay_send.send(item)
668+
669+
# This helper takes ownership of `read_stream` from the serving loop, so
670+
# every exit - including cancellation while awaiting the first request -
671+
# closes it and the replay channel.
672+
try:
673+
async for item in read_stream:
674+
peeked.append(item)
675+
if isinstance(item, SessionMessage) and isinstance(item.message, JSONRPCRequest):
676+
opening = item.message
677+
break
678+
async with anyio.create_task_group() as tg:
679+
tg.start_soon(replay_then_relay)
680+
yield opening, replayed
681+
tg.cancel_scope.cancel()
682+
finally:
683+
await read_stream.aclose()
684+
replay_send.close()
685+
replayed.close()
686+
687+
688+
async def _serve_legacy_stream(
689+
server: Server[LifespanT],
690+
read_stream: ReadStream[SessionMessage | Exception],
691+
write_stream: WriteStream[SessionMessage],
692+
*,
693+
lifespan_state: LifespanT,
694+
session_id: str | None,
695+
init_options: InitializationOptions | None,
696+
raise_exceptions: bool,
697+
) -> None:
698+
"""Serve a 2025 handshake connection; enveloped requests are refused."""
647699
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
648700
read_stream,
649701
write_stream,
650702
raise_handler_exceptions=raise_exceptions,
651-
# `initialize` inline for the same pipelining reason as `serve_loop`;
652-
# `server/discover` inline so the modern era lock commits before the
653-
# next pipelined message is read.
654-
inline_methods=frozenset({"initialize", "server/discover"}),
703+
# `initialize` inline for the same pipelining reason as `serve_loop`.
704+
inline_methods=frozenset({"initialize"}),
705+
)
706+
connection = Connection.for_loop(dispatcher, session_id=session_id)
707+
runner = ServerRunner(server, connection, lifespan_state, init_options=init_options)
708+
709+
async def on_request(
710+
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
711+
) -> dict[str, Any]:
712+
if method != "initialize" and _has_modern_envelope(params):
713+
raise MCPError(
714+
code=INVALID_REQUEST,
715+
message="connection was opened with the initialize handshake; "
716+
"2026-07-28 envelope requests are not accepted on it",
717+
)
718+
return await runner.on_request(dctx, method, params)
719+
720+
try:
721+
await dispatcher.run(on_request, runner.on_notify)
722+
finally:
723+
await aclose_shielded(connection)
724+
725+
726+
async def _serve_modern_stream(
727+
server: Server[LifespanT],
728+
read_stream: ReadStream[SessionMessage | Exception],
729+
write_stream: WriteStream[SessionMessage],
730+
*,
731+
lifespan_state: LifespanT,
732+
raise_exceptions: bool,
733+
) -> None:
734+
"""Serve a 2026-07-28 connection: every request carries its own envelope."""
735+
dispatcher: JSONRPCDispatcher[TransportContext] = JSONRPCDispatcher(
736+
read_stream, write_stream, raise_handler_exceptions=raise_exceptions
655737
)
656-
loop_connection = Connection.for_loop(dispatcher, session_id=session_id)
657-
loop_runner = ServerRunner(server, loop_connection, lifespan_state, init_options=init_options)
658-
standalone_outbound = NotifyOnlyOutbound(dispatcher)
659-
era: Literal["unlocked", "legacy", "modern"] = "unlocked"
660-
modern_version = LATEST_MODERN_VERSION
661-
662-
def era_settles(dctx: DispatchContext[TransportContext]) -> bool:
663-
# The one definition of "this request may lock the era": it settled as
664-
# a client-visible success on a still-unlocked connection. The lock is
665-
# monotone - the first success wins, so a straggling request from the
666-
# other era can never overwrite a committed lock. A pending peer
667-
# cancel means the dispatcher is about to replace this response with
668-
# "Request cancelled": the client never sees the success, no lock.
669-
return era == "unlocked" and not dctx.cancel_requested.is_set()
670-
671-
async def serve_modern(
738+
outbound = NotifyOnlyOutbound(dispatcher)
739+
740+
async def on_request(
672741
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
673742
) -> dict[str, Any]:
674-
nonlocal era, modern_version
743+
if method == "initialize":
744+
raise MCPError(
745+
code=UNSUPPORTED_PROTOCOL_VERSION,
746+
message="connection is serving the 2026-07-28 protocol; the initialize handshake is not accepted",
747+
data=_initialize_after_modern_data(params),
748+
)
675749
route = classify_inbound_request({"method": method, "params": params})
676750
if isinstance(route, InboundLadderRejection):
677751
raise MCPError(code=route.code, message=route.message, data=route.data)
678-
if method == "subscriptions/listen":
679-
# The registered listen handler assumes the HTTP entry's stream
680-
# semantics; served over a stream pair it would wedge. Reject until
681-
# this transport grows its own listen design.
682-
raise MCPError(
683-
code=METHOD_NOT_FOUND, message="subscriptions/listen is not served over this transport", data=method
684-
)
685752
connection = Connection.from_envelope(
686-
route.protocol_version,
687-
route.client_info,
688-
route.client_capabilities,
689-
outbound=standalone_outbound,
753+
route.protocol_version, route.client_info, route.client_capabilities, outbound=outbound
690754
)
691755
try:
692-
result = await serve_one(
756+
return await serve_one(
693757
server,
694758
_NoServerRequestsDispatchContext(dctx),
695759
method,
@@ -698,68 +762,25 @@ async def serve_modern(
698762
lifespan_state=lifespan_state,
699763
)
700764
except (MCPError, ValidationError):
701-
# The dispatcher's shared ladder maps these to the same wire error
702-
# the modern HTTP entry produces.
765+
# The dispatcher's shared ladder maps these to the wire error.
703766
raise
704767
except Exception as exc:
705768
if raise_exceptions:
706769
raise
707770
error = modern_error_data(exc)
708771
raise MCPError(code=error.code, message=error.message, data=error.data) from exc
709-
if era_settles(dctx):
710-
era, modern_version = "modern", route.protocol_version
711-
return result
712-
713-
async def on_request(
714-
dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None
715-
) -> dict[str, Any]:
716-
nonlocal era
717-
if era == "legacy":
718-
if _has_modern_envelope(params):
719-
raise MCPError(
720-
code=INVALID_REQUEST,
721-
message="connection is locked to the legacy handshake era; "
722-
"modern envelope requests are not accepted",
723-
)
724-
# Bare modern-only methods (e.g. `server/discover`) fall through to
725-
# the loop runner's per-version surface validation - the same
726-
# METHOD_NOT_FOUND a handshake-only server produced, byte for byte.
727-
return await loop_runner.on_request(dctx, method, params)
728-
if era == "modern":
729-
if method == "initialize":
730-
raise MCPError(
731-
code=UNSUPPORTED_PROTOCOL_VERSION,
732-
message="connection already negotiated a modern protocol version",
733-
data=_initialize_after_modern_data(params),
734-
)
735-
return await serve_modern(dctx, method, params)
736-
# Unlocked. `initialize` is legacy-distinctive by definition (the
737-
# method does not exist at modern versions), so it takes the handshake
738-
# path even when the envelope keys are stamped on it.
739-
if method != "initialize" and (method == "server/discover" or _has_modern_envelope(params)):
740-
return await serve_modern(dctx, method, params)
741-
result = await loop_runner.on_request(dctx, method, params)
742-
if method == "initialize" and era_settles(dctx):
743-
# Lock only on success: a failed handshake leaves both eras open.
744-
era = "legacy"
745-
return result
746772

747773
async def on_notify(dctx: DispatchContext[TransportContext], method: str, params: Mapping[str, Any] | None) -> None:
748-
if era != "modern":
749-
return await loop_runner.on_notify(dctx, method, params)
750-
# The envelope is request-only, so notifications inherit the
751-
# connection's locked version.
752-
connection = Connection.from_envelope(modern_version, None, None, outbound=standalone_outbound)
774+
# The envelope is request-only, so a notification runs at the latest
775+
# served version; the modern protocol has nothing version-specific here.
776+
connection = Connection.from_envelope(LATEST_MODERN_VERSION, None, None, outbound=outbound)
753777
notify_runner = ServerRunner(server, connection, lifespan_state)
754778
try:
755779
await notify_runner.on_notify(_NoServerRequestsDispatchContext(dctx), method, params)
756780
finally:
757781
await aclose_shielded(connection)
758782

759-
try:
760-
await dispatcher.run(on_request, on_notify)
761-
finally:
762-
await aclose_shielded(loop_connection)
783+
await dispatcher.run(on_request, on_notify)
763784

764785

765786
async def serve_one(

0 commit comments

Comments
 (0)