Skip to content

Commit 1e3be94

Browse files
committed
Fix defects found in review of the streamable HTTP rewrite
- Serialize store-then-forward per channel: concurrent writers on one channel (the standalone GET stream) could put frames on the wire out of event-store order, breaking Last-Event-ID resumption. A per-channel lock restores the store-order == wire-order invariant the serial router used to provide. - Refuse a request that arrives across session termination instead of running it: dispatch now branches on the transport's identity (stateful vs stateless) rather than on session-task presence, re-checks liveness after the request's awaits, and answers 404 for an ended session; queued work for an ended session is dropped. - Contain event-store failures at the channel: a raising store_event was reaching the correlator as a handler error and leaking its text onto the wire; write() now never raises (a broken store ends that stream), which also protects the courtesy-cancel write. - Close the replay reader when priming fails; drop dead exception arms in the SSE pump. - Correct the migration note's JSON-mode claim to request-scoped requests, drop a claim handlers cannot observe, fix a vacuous test assertion, and add regression tests for each fix.
1 parent edb0abd commit 1e3be94

5 files changed

Lines changed: 271 additions & 68 deletions

File tree

docs/migration.md

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -750,11 +750,11 @@ session_manager = StreamableHTTPSessionManager(app=server, event_store=..., json
750750

751751
Behaviour clarified in the same change:
752752

753-
- In JSON-response mode a server-to-client request (sampling, elicitation) now raises
754-
`NoBackChannelError` — a JSON response has no stream to carry the request, and previously the
755-
call would hang waiting for an answer that could never be delivered.
756-
- Stateful streamable-HTTP handlers see `TransportContext(kind="streamable-http")` carrying the
757-
request `headers`; the stream-pair path previously reported `kind="jsonrpc"` with no headers.
753+
- In JSON-response mode a *request-scoped* server-to-client request (`ctx.elicit()`, or any
754+
`ctx.session` call carrying `related_request_id`) now raises `NoBackChannelError`the POST's
755+
single JSON body has no stream to carry the nested request, and previously the call would hang
756+
waiting for an answer that could never be delivered. Connection-scoped sends (calls without
757+
`related_request_id`) are unchanged and still ride the standalone GET stream.
758758
- A GET carrying `Last-Event-ID` on a server without an `EventStore` opens the standalone stream
759759
as a plain GET would, since there is nothing to replay.
760760

src/mcp/server/streamable_http.py

Lines changed: 92 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -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

tests/interaction/README.md

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -279,8 +279,9 @@ this hits any test that must run statements after a `ClientSession`/`streamable_
279279
but still inside an outer `async with`, and no restructure can avoid it.
280280

281281
A handful of `# pragma: lax no cover` markers in `src/` cover teardown exception handlers whose
282-
execution is timing-dependent under the in-process HTTP bridge — the SSE-writer and replay
283-
`except` handlers in `server/streamable_http.py`. `strict-no-cover` does not check `lax` lines; do not
282+
execution is timing-dependent under the in-process HTTP bridge — the `except Exception` arms
283+
around the standalone-GET and replay `response(...)` calls in `server/streamable_http.py`.
284+
`strict-no-cover` does not check `lax` lines; do not
284285
promote them to strict `no cover` without first making the teardown ordering deterministic. The
285286
suite also relies on a one-line `src/mcp/server/sse.py` fix (`sse_stream_reader.aclose()`) that
286287
closes a stream the SSE leg would otherwise leak.

0 commit comments

Comments
 (0)