@@ -373,6 +373,78 @@ async def call() -> None:
373373 assert received == snapshot (["before close" , "after close" ])
374374
375375
376+ async def test_a_call_whose_stream_closes_and_cannot_be_resumed_fails_instead_of_hanging () -> None :
377+ """If a resumable response stream disconnects and the server session is gone, the client fails
378+ the request instead of hanging forever.
379+
380+ The server closes the call's SSE stream after emitting one related notification. The test then
381+ deletes the active server-side session to force the client's reconnect GET to return 404.
382+ Without a terminal response/error on the read stream, ClientSession.send_request waits forever
383+ (read timeout defaults to None). The transport must surface a request-scoped error when it
384+ gives up reconnecting.
385+ """
386+ reconnect_attempted = anyio .Event ()
387+ allow_exit = anyio .Event ()
388+ done = anyio .Event ()
389+ raised : list [BaseException ] = []
390+ manager_ref = None
391+ deleted_session = False
392+
393+ mcp = MCPServer ("resumable" )
394+
395+ @mcp .tool ()
396+ async def interrupt (ctx : Context ) -> str :
397+ await ctx .info ("before close" )
398+ await ctx .close_sse_stream ()
399+ await allow_exit .wait ()
400+ return "unreachable"
401+
402+ async def record_request (request : httpx .Request ) -> None :
403+ nonlocal deleted_session
404+ if request .method != "GET" :
405+ return
406+ if request .headers .get ("last-event-id" ) is None :
407+ return
408+ reconnect_attempted .set ()
409+ if deleted_session or manager_ref is None :
410+ return
411+ session_ids = list (manager_ref ._server_instances .keys ())
412+ if session_ids : # pragma: no branch
413+ del manager_ref ._server_instances [session_ids [0 ]]
414+ deleted_session = True
415+
416+ async with mounted_app (mcp , event_store = SequencedEventStore (), retry_interval = 0 , on_request = record_request ) as (
417+ http ,
418+ manager ,
419+ ):
420+ manager_ref = manager
421+ with anyio .fail_after (5 ): # pragma: no branch
422+ async with (
423+ streamable_http_client (f"{ BASE_URL } /mcp" , http_client = http , terminate_on_close = False ) as (r , w ),
424+ ClientSession (r , w ) as session ,
425+ anyio .create_task_group () as tg ,
426+ ):
427+ await session .initialize ()
428+
429+ async def call () -> None :
430+ try :
431+ await session .call_tool ("interrupt" , {})
432+ except BaseException as exc :
433+ raised .append (exc )
434+ finally :
435+ done .set ()
436+
437+ tg .start_soon (call )
438+ await reconnect_attempted .wait ()
439+ await done .wait ()
440+ allow_exit .set ()
441+ tg .cancel_scope .cancel ()
442+
443+ assert len (raised ) == 1
444+ assert isinstance (raised [0 ], Exception )
445+ assert "disconnected" in str (raised [0 ]).lower ()
446+
447+
376448@requirement ("client-transport:http:resume-stream-api" )
377449async def test_a_captured_resumption_token_replays_missed_messages_on_a_new_connection () -> None :
378450 """A resumption token captured via on_resumption_token_update on one connection lets a fresh
0 commit comments