Skip to content

Commit c3b0e01

Browse files
committed
Address review: keep sender contexts through the replay, bound the opening peek
- The opening-request replay now carries each frame's captured sender context, so the streams the SSE transport hands the loop keep their contextvars propagation; pinned by a test on the dual-era loop. - Bound how many frames may precede the client's first request; past the limit the loop stops looking for an opening request and serves a handshake connection, so a peer streaming pre-request frames cannot grow the buffer without bound. - Drop the stale docs admonition that stdio still rejects listen, and trim the era-decision docstring to the rule it now implements.
1 parent fee8c52 commit c3b0e01

3 files changed

Lines changed: 82 additions & 25 deletions

File tree

docs/handlers/subscriptions.md

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -52,13 +52,6 @@ Two things the stream is *not*:
5252
handler on the low-level `Server` and acknowledge a smaller filter than the client asked
5353
for; the acknowledgment is how the client learns what it actually got.
5454

55-
!!! warning "Streamable HTTP only, for now"
56-
`subscriptions/listen` needs a transport that can stream a request's response, which today
57-
means streamable HTTP. Over stdio a 2026-07-28 connection rejects the method with
58-
METHOD_NOT_FOUND, even though `server/discover` advertises the subscription capabilities
59-
there. Serving it over stdio is planned; the open-stream semantics for that transport are
60-
not built yet.
61-
6255
## The client end
6356

6457
Here is a client on the other side of that stream, following the board:

src/mcp/server/runner.py

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313

1414
from __future__ import annotations
1515

16+
import contextvars
1617
import logging
1718
from collections.abc import AsyncIterator, Awaitable, Mapping
1819
from contextlib import asynccontextmanager
@@ -59,6 +60,7 @@
5960
from mcp.server.context import CallNext, HandlerResult, ServerMiddleware, ServerRequestContext
6061
from mcp.server.models import InitializationOptions
6162
from mcp.server.session import ServerSession
63+
from mcp.shared._context_streams import ContextReceiveStream
6264
from mcp.shared._stream_protocols import ReadStream, WriteStream
6365
from mcp.shared.dispatcher import CallOptions, DispatchContext, Dispatcher, OnNotify, OnRequest
6466
from mcp.shared.exceptions import MCPError, NoBackChannelError
@@ -499,15 +501,12 @@ async def serve_loop(
499501
def _has_modern_envelope(params: Mapping[str, Any] | None) -> bool:
500502
"""Whether `params._meta` carries the reserved protocol-version key.
501503
502-
Era evidence is the client's explicit version declaration: the
503-
`io.modelcontextprotocol/protocolVersion` key exists only in 2026-07-28+
504-
envelopes, and the `io.modelcontextprotocol/` prefix is spec-reserved, so
505-
legacy traffic never mints it (bare `_meta` is NOT evidence - legacy
506-
requests carry `progressToken` there). Presence of the version key alone
507-
is the rule, not the full required pair, so a half-built envelope
508-
(version present, capabilities missing) still routes modern and gets the
509-
classifier's INVALID_PARAMS naming the missing key instead of the legacy
510-
path's generic one - and, like every failed classification, locks no era.
504+
The `io.modelcontextprotocol/protocolVersion` key exists only in
505+
2026-07-28+ envelopes and its prefix is spec-reserved, so legacy traffic
506+
never mints it (a bare `_meta` is not evidence - legacy requests carry
507+
`progressToken` there). The version key alone is the signal, not the full
508+
required pair, so a half-built envelope still routes modern and gets the
509+
classifier's INVALID_PARAMS naming the missing key.
511510
"""
512511
if not params:
513512
return False
@@ -644,6 +643,17 @@ async def serve_dual_era_loop(
644643
await write_stream.aclose()
645644

646645

646+
_OPENING_PEEK_LIMIT: int = 32
647+
"""How many frames may precede the client's first request before the loop
648+
stops looking for one. Every legitimate client opens with a request, so this
649+
only bounds a peer that streams other frames without ever sending one."""
650+
651+
652+
def _sender_context(stream: ReadStream[Any]) -> contextvars.Context:
653+
"""The per-message sender context a context-aware stream carries, else the current one."""
654+
return getattr(stream, "last_context", None) or contextvars.copy_context()
655+
656+
647657
@asynccontextmanager
648658
async def _replay_from_opening_request(
649659
read_stream: ReadStream[SessionMessage | Exception],
@@ -652,37 +662,43 @@ async def _replay_from_opening_request(
652662
653663
Reads frames until the first JSON-RPC request arrives, then yields that
654664
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.
665+
relays the rest of `read_stream` behind it, sender contexts included. The
666+
request is `None` if the channel closes - or `_OPENING_PEEK_LIMIT` frames
667+
pass - before any request appears.
657668
"""
658-
peeked: list[SessionMessage | Exception] = []
669+
peeked: list[tuple[contextvars.Context, SessionMessage | Exception]] = []
659670
opening: JSONRPCRequest | None = None
660-
replay_send, replayed = anyio.create_memory_object_stream[SessionMessage | Exception]()
671+
replay_send, replay_receive = anyio.create_memory_object_stream[
672+
tuple[contextvars.Context, SessionMessage | Exception]
673+
]()
674+
replayed = ContextReceiveStream(replay_receive)
661675

662676
async def replay_then_relay() -> None:
663677
async with replay_send:
664-
for item in peeked:
665-
await replay_send.send(item)
678+
for envelope in peeked:
679+
await replay_send.send(envelope)
666680
async for item in read_stream:
667-
await replay_send.send(item)
681+
await replay_send.send((_sender_context(read_stream), item))
668682

669683
# This helper takes ownership of `read_stream` from the serving loop, so
670684
# every exit - including cancellation while awaiting the first request -
671685
# closes it and the replay channel.
672686
try:
673687
async for item in read_stream:
674-
peeked.append(item)
688+
peeked.append((_sender_context(read_stream), item))
675689
if isinstance(item, SessionMessage) and isinstance(item.message, JSONRPCRequest):
676690
opening = item.message
677691
break
692+
if len(peeked) >= _OPENING_PEEK_LIMIT:
693+
break
678694
async with anyio.create_task_group() as tg:
679695
tg.start_soon(replay_then_relay)
680696
yield opening, replayed
681697
tg.cancel_scope.cancel()
682698
finally:
683699
await read_stream.aclose()
684700
replay_send.close()
685-
replayed.close()
701+
replay_receive.close()
686702

687703

688704
async def _serve_legacy_stream(

tests/server/test_runner.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
`aclose_shielded`) follow at the bottom.
88
"""
99

10+
import contextvars
1011
from collections.abc import AsyncIterator, Mapping
1112
from contextlib import asynccontextmanager
1213
from dataclasses import dataclass, field, replace
@@ -57,6 +58,7 @@
5758
from mcp.server.lowlevel.server import NotificationOptions, Server
5859
from mcp.server.models import InitializationOptions
5960
from mcp.server.runner import (
61+
_OPENING_PEEK_LIMIT,
6062
ServerRunner,
6163
_extract_meta,
6264
_has_modern_envelope,
@@ -69,6 +71,7 @@
6971
)
7072
from mcp.server.session import ServerSession
7173
from mcp.server.subscriptions import SUBSCRIPTION_ID_META_KEY, InMemorySubscriptionBus, ListenHandler
74+
from mcp.shared._context_streams import create_context_streams
7275
from mcp.shared.dispatcher import CallOptions
7376
from mcp.shared.exceptions import MCPError, NoBackChannelError
7477
from mcp.shared.jsonrpc_dispatcher import JSONRPCDispatcher
@@ -1356,6 +1359,8 @@ async def _append_async(dst: list[int], v: int) -> None:
13561359

13571360
_LIFESPAN: dict[str, Any] = {}
13581361

1362+
_SENDER_VAR: contextvars.ContextVar[str] = contextvars.ContextVar("dual_era_sender_var", default="unset")
1363+
13591364

13601365
@pytest.mark.anyio
13611366
async def test_serve_one_runs_handler_and_returns_result_dict(server: SrvT):
@@ -1972,6 +1977,49 @@ async def test_notify_only_outbound_forwards_notifications_and_refuses_requests(
19721977
await outbound.send_raw_request("ping", None)
19731978

19741979

1980+
@pytest.mark.anyio
1981+
async def test_dual_era_loop_carries_the_sender_context_through_the_replay():
1982+
"""A context-aware read stream's per-message sender context still reaches
1983+
handlers over the dual-era loop (the SSE transport delivers requests this
1984+
way): the opening-request replay forwards each frame's captured context."""
1985+
seen: list[str] = []
1986+
1987+
async def probe(ctx: Ctx, params: RequestParams | None) -> dict[str, Any]:
1988+
seen.append(_SENDER_VAR.get())
1989+
return {}
1990+
1991+
server = Server(name="ctx-server", version="0.0.1")
1992+
server.add_request_handler("x/probe", RequestParams, probe)
1993+
c2s_send, c2s_recv = create_context_streams[SessionMessage | Exception](8)
1994+
s2c_send, s2c_recv = anyio.create_memory_object_stream[SessionMessage | Exception](8)
1995+
frame = JSONRPCRequest(jsonrpc="2.0", id=1, method="x/probe", params=_modern_params())
1996+
async with anyio.create_task_group() as tg, s2c_recv:
1997+
tg.start_soon(partial(serve_dual_era_loop, server, c2s_recv, s2c_send, lifespan_state=_LIFESPAN))
1998+
token = _SENDER_VAR.set("from-the-sender")
1999+
try:
2000+
await c2s_send.send(SessionMessage(message=frame))
2001+
finally:
2002+
_SENDER_VAR.reset(token)
2003+
with anyio.fail_after(5):
2004+
await s2c_recv.receive()
2005+
tg.cancel_scope.cancel()
2006+
await c2s_send.aclose()
2007+
assert seen == ["from-the-sender"]
2008+
2009+
2010+
@pytest.mark.anyio
2011+
async def test_dual_era_loop_stops_peeking_after_a_flood_of_pre_request_frames(server: SrvT):
2012+
"""A peer that streams notifications and never opens with a request only
2013+
gets the first `_OPENING_PEEK_LIMIT` frames buffered: past that the loop
2014+
stops looking for an opening request and serves the rest as an ordinary
2015+
handshake connection, so the handshake that follows still lands."""
2016+
async with dual_era_client(server) as (client, _):
2017+
for _ in range(_OPENING_PEEK_LIMIT + 5):
2018+
await client.notify("notifications/flood", None)
2019+
init = await client.send_raw_request("initialize", _initialize_params())
2020+
assert init["protocolVersion"] == LATEST_HANDSHAKE_VERSION
2021+
2022+
19752023
@pytest.mark.anyio
19762024
async def test_dual_era_client_propagates_body_exception_unwrapped(server: SrvT):
19772025
"""The harness re-raises body exceptions as-is, not as `ExceptionGroup`."""

0 commit comments

Comments
 (0)