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