1313
1414from __future__ import annotations
1515
16+ import contextvars
1617import logging
1718from collections .abc import AsyncIterator , Awaitable , Mapping
1819from contextlib import asynccontextmanager
5960from mcp .server .context import CallNext , HandlerResult , ServerMiddleware , ServerRequestContext
6061from mcp .server .models import InitializationOptions
6162from mcp .server .session import ServerSession
63+ from mcp .shared ._context_streams import ContextReceiveStream
6264from mcp .shared ._stream_protocols import ReadStream , WriteStream
6365from mcp .shared .dispatcher import CallOptions , DispatchContext , Dispatcher , OnNotify , OnRequest
6466from mcp .shared .exceptions import MCPError , NoBackChannelError
@@ -499,15 +501,12 @@ async def serve_loop(
499501def _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
648658async 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
688704async def _serve_legacy_stream (
0 commit comments