1414from __future__ import annotations
1515
1616import logging
17- from collections .abc import Awaitable , Mapping
17+ from collections .abc import AsyncIterator , Awaitable , Mapping
18+ from contextlib import asynccontextmanager
1819from dataclasses import KW_ONLY , dataclass , replace
1920from 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
2223import anyio
2324import anyio .abc
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
765786async def serve_one (
0 commit comments