@@ -175,6 +175,10 @@ def __init__(self, stream_id: StreamId, event_store: EventStore | None) -> None:
175175 self ._event_store = event_store
176176 self ._writer : MemoryObjectSendStream [EventMessage ] | None = None
177177 self ._closed = False
178+ # Store-then-forward is one atomic step per channel, so the wire order
179+ # of concurrent writers always matches the order the event store saw
180+ # (what a `Last-Event-ID` resume replays from).
181+ self ._write_lock = anyio .Lock ()
178182 self .terminal : JSONRPCResponse | JSONRPCError | None = None
179183 """The terminal outcome once the request this channel serves has finished."""
180184 self .finished = anyio .Event ()
@@ -186,33 +190,41 @@ def attached(self) -> bool:
186190 return self ._writer is not None
187191
188192 async def write (self , message : JSONRPCMessage ) -> None :
189- """Store-then-forward one outbound message. Never raises for a dropped connection."""
190- if self ._closed :
191- logger .debug ("dropped message on closed stream %s" , self .stream_id )
192- return
193- # Store the event if we have an event store,
194- # regardless of whether a client is connected
195- # messages will be replayed on the re-connect
196- event_id : EventId | None = None
197- if self ._event_store is not None :
198- event_id = await self ._event_store .store_event (self .stream_id , message )
199- logger .debug (f"Stored { event_id } from { self .stream_id } " )
200- if isinstance (message , JSONRPCResponse | JSONRPCError ):
201- self .terminal = message
202- self .finished .set ()
203- writer = self ._writer
204- if writer is None :
205- logger .debug (
206- f"""Request stream { self .stream_id } is not connected
207- for message. Still processing message as the client
208- might reconnect and replay."""
209- )
210- return
211- try :
212- await writer .send (EventMessage (message , event_id ))
213- except (anyio .BrokenResourceError , anyio .ClosedResourceError ):
214- # The SSE response went away between the attach check and the send.
215- self ._detach (writer )
193+ """Store-then-forward one outbound message. Never raises."""
194+ async with self ._write_lock :
195+ if self ._closed :
196+ logger .debug ("dropped message on closed stream %s" , self .stream_id )
197+ return
198+ # Store the event if we have an event store,
199+ # regardless of whether a client is connected
200+ # messages will be replayed on the re-connect
201+ event_id : EventId | None = None
202+ if self ._event_store is not None :
203+ try :
204+ event_id = await self ._event_store .store_event (self .stream_id , message )
205+ except Exception :
206+ # A broken store must not leak its exception into the caller's
207+ # write (nor its text onto the wire): this stream is over.
208+ logger .exception ("EventStore.store_event failed for stream %s; closing the stream" , self .stream_id )
209+ self .close ()
210+ return
211+ logger .debug (f"Stored { event_id } from { self .stream_id } " )
212+ if isinstance (message , JSONRPCResponse | JSONRPCError ):
213+ self .terminal = message
214+ self .finished .set ()
215+ writer = self ._writer
216+ if writer is None :
217+ logger .debug (
218+ f"""Request stream { self .stream_id } is not connected
219+ for message. Still processing message as the client
220+ might reconnect and replay."""
221+ )
222+ return
223+ try :
224+ await writer .send (EventMessage (message , event_id ))
225+ except (anyio .BrokenResourceError , anyio .ClosedResourceError ):
226+ # The SSE response went away between the attach check and the send.
227+ self ._detach (writer )
216228
217229 def attach (self ) -> MemoryObjectReceiveStream [EventMessage ]:
218230 """Attach a fresh SSE response and return the reader it drains.
@@ -877,19 +889,27 @@ async def _run_notification() -> None:
877889 def _can_send_request (self ) -> bool :
878890 """Whether a request handler on this transport has a back-channel for server-to-client requests.
879891
880- Stateless mode and JSON-response mode have none: a nested elicitation
881- or sampling request has no stream to ride, and no POST of the client's
882- answer could be correlated back to a session.
892+ JSON-response mode has none: the request's response is one JSON body,
893+ so a nested elicitation or sampling request has no stream to ride.
894+ Stateless mode additionally lacks a session, so no POST of the client's
895+ answer could be correlated back to a waiting handler.
883896 """
884897 return self .mcp_session_id is not None and not self .is_json_response_enabled
885898
886899 async def _spawn_or_run (self , fn : Callable [..., Awaitable [None ]], * args : Any ) -> None :
887- """Run `fn(*args)` on the session task group if one is running, else inline."""
888- tg = self ._task_group
889- if tg is not None :
890- tg .start_soon (fn , * args )
891- else :
900+ """Run `fn(*args)`: on the session task group when session-bound, inline when stateless.
901+
902+ A session-bound transport whose session has ended drops the work: the
903+ session is being torn down, so no handler may run against it.
904+ """
905+ if self .mcp_session_id is None :
892906 await fn (* args )
907+ return
908+ tg = self ._task_group
909+ if tg is None or self ._terminated :
910+ logger .debug ("dropped work for ended session %s" , self .mcp_session_id )
911+ return
912+ tg .start_soon (fn , * args )
893913
894914 async def _serve_request (
895915 self ,
@@ -912,18 +932,32 @@ async def _serve_request(
912932 None if self .is_json_response_enabled else await self ._mint_priming_event (stream_id , protocol_version )
913933 )
914934
935+ # The session may have ended (DELETE, idle timeout, manager shutdown)
936+ # while this request was suspended above; a session-bound transport must
937+ # refuse the request instead of running it against a dead session. No
938+ # await from here to the dispatch, so the answer holds when we act on it.
939+ stateful = self .mcp_session_id is not None
940+ session_task_group = self ._task_group
941+ if stateful and (self ._terminated or session_task_group is None ):
942+ response = self ._create_error_response (
943+ "Not Found: Session has been terminated" ,
944+ HTTPStatus .NOT_FOUND ,
945+ )
946+ await response (scope , receive , send )
947+ return
948+
915949 channel = _MessageChannel (stream_id , self ._event_store )
916950 self ._channels [stream_id ] = channel
917951 # Attach the response's writer before the handler starts, so nothing
918952 # the handler emits early lands on an unattached channel.
919953 reader = None if self .is_json_response_enabled else channel .attach ()
920954
921- if self .mcp_session_id is None :
922- runner = self ._stateless_runner (request )
923- connection = runner .connection
924- else :
955+ if stateful :
925956 runner = self ._session_runner ()
926957 connection = None
958+ else :
959+ runner = self ._stateless_runner (request )
960+ connection = runner .connection
927961 dctx = _HTTPRequestDispatchContext (
928962 transport = self ._transport_context (request , can_send_request = self ._can_send_request ),
929963 _corr = self ._corr ,
@@ -937,6 +971,8 @@ async def _serve_request(
937971
938972 async def _run_handler () -> None :
939973 try :
974+ # `serve_inbound` contains handler exceptions and `channel.write`
975+ # never raises, so this task always completes on its own.
940976 await self ._corr .serve_inbound (
941977 request_id ,
942978 dctx ,
@@ -945,11 +981,6 @@ async def _run_handler() -> None:
945981 write_result = partial (self ._write_result , channel , request_id ),
946982 write_error = partial (self ._write_error , channel , request_id ),
947983 )
948- except Exception :
949- # `serve_inbound` contains handler exceptions itself; anything
950- # escaping it is a channel write that raised (e.g. a broken
951- # EventStore), which must cost this request, not the session.
952- logger .exception ("Error handling request %r" , request_id )
953984 finally :
954985 # The channel stays registered until the handler is done, so a
955986 # `Last-Event-ID` reconnect can re-attach while it still runs.
@@ -959,11 +990,11 @@ async def _run_handler() -> None:
959990 if connection is not None :
960991 await aclose_shielded (connection )
961992
962- if self . _task_group is not None :
993+ if session_task_group is not None :
963994 # Session-scoped: the handler outlives this HTTP request. A client
964995 # that drops the connection is not cancelling the request (it may
965996 # resume via Last-Event-ID); it cancels by POSTing notifications/cancelled.
966- self . _task_group .start_soon (_run_handler )
997+ session_task_group .start_soon (_run_handler )
967998 if reader is None :
968999 await self ._respond_json (scope , receive , send , channel )
9691000 else :
@@ -1051,10 +1082,6 @@ async def _pump_channel(
10511082 await sse_send .send (self ._create_event_data (event_message ))
10521083 if stop_at_response and isinstance (event_message .message , JSONRPCResponse | JSONRPCError ):
10531084 break
1054- except anyio .ClosedResourceError : # pragma: lax no cover
1055- logger .debug ("SSE stream closed by close_sse_stream()" )
1056- except Exception : # pragma: lax no cover
1057- logger .exception ("Error in SSE writer" )
10581085 finally :
10591086 logger .debug ("Closing SSE writer" )
10601087 channel .detach (reader )
@@ -1256,14 +1283,23 @@ async def send_event(event_message: EventMessage) -> None:
12561283 # stream is re-registered.
12571284 priming_event = await self ._mint_priming_event (stream_id , replay_protocol_version )
12581285
1259- # Forward messages to SSE
1260- await self ._pump_channel (channel , reader , sse_send , priming_event , stop_at_response = True )
1286+ # Forward messages to SSE: a request's stream ends after
1287+ # its response frame; the standalone stream carries no
1288+ # response and tails until the client leaves again.
1289+ await self ._pump_channel (
1290+ channel ,
1291+ reader ,
1292+ sse_send ,
1293+ priming_event ,
1294+ stop_at_response = stream_id != GET_STREAM_KEY ,
1295+ )
12611296 finally :
12621297 channel .detach (reader )
1263- except anyio .ClosedResourceError : # pragma: lax no cover
1264- # Expected when close_sse_stream() is called
1265- logger .debug ("Replay SSE stream closed by close_sse_stream()" )
1266- except Exception : # pragma: lax no cover
1298+ # The pump closes the reader it drained; this covers a
1299+ # priming failure that never handed the reader over.
1300+ reader .close ()
1301+ except Exception :
1302+ # `replay_events_after` is user code; a failing replay ends this response only.
12671303 logger .exception ("Error in replay sender" )
12681304
12691305 # Create and start EventSourceResponse
0 commit comments