@@ -296,6 +296,78 @@ async def call() -> None:
296296 assert received == snapshot (["before close" , "after close" ])
297297
298298
299+ async def test_a_call_whose_stream_closes_and_cannot_be_resumed_fails_instead_of_hanging () -> None :
300+ """If a resumable response stream disconnects and the server session is gone, the client fails
301+ the request instead of hanging forever.
302+
303+ The server closes the call's SSE stream after emitting one related notification. The test then
304+ deletes the active server-side session to force the client's reconnect GET to return 404.
305+ Without a terminal response/error on the read stream, ClientSession.send_request waits forever
306+ (read timeout defaults to None). The transport must surface a request-scoped error when it
307+ gives up reconnecting.
308+ """
309+ reconnect_attempted = anyio .Event ()
310+ allow_exit = anyio .Event ()
311+ done = anyio .Event ()
312+ raised : list [BaseException ] = []
313+ manager_ref = None
314+ deleted_session = False
315+
316+ mcp = MCPServer ("resumable" )
317+
318+ @mcp .tool ()
319+ async def interrupt (ctx : Context ) -> str :
320+ await ctx .info ("before close" )
321+ await ctx .close_sse_stream ()
322+ await allow_exit .wait ()
323+ return "unreachable"
324+
325+ async def record_request (request : httpx .Request ) -> None :
326+ nonlocal deleted_session
327+ if request .method != "GET" :
328+ return
329+ if request .headers .get ("last-event-id" ) is None :
330+ return
331+ reconnect_attempted .set ()
332+ if deleted_session or manager_ref is None :
333+ return
334+ session_ids = list (manager_ref ._server_instances .keys ())
335+ if session_ids : # pragma: no branch
336+ del manager_ref ._server_instances [session_ids [0 ]]
337+ deleted_session = True
338+
339+ async with mounted_app (mcp , event_store = SequencedEventStore (), retry_interval = 0 , on_request = record_request ) as (
340+ http ,
341+ manager ,
342+ ):
343+ manager_ref = manager
344+ with anyio .fail_after (5 ): # pragma: no branch
345+ async with (
346+ streamable_http_client (f"{ BASE_URL } /mcp" , http_client = http , terminate_on_close = False ) as (r , w ),
347+ ClientSession (r , w ) as session ,
348+ anyio .create_task_group () as tg ,
349+ ):
350+ await session .initialize ()
351+
352+ async def call () -> None :
353+ try :
354+ await session .call_tool ("interrupt" , {})
355+ except BaseException as exc :
356+ raised .append (exc )
357+ finally :
358+ done .set ()
359+
360+ tg .start_soon (call )
361+ await reconnect_attempted .wait ()
362+ await done .wait ()
363+ allow_exit .set ()
364+ tg .cancel_scope .cancel ()
365+
366+ assert len (raised ) == 1
367+ assert isinstance (raised [0 ], Exception )
368+ assert "disconnected" in str (raised [0 ]).lower ()
369+
370+
299371@requirement ("client-transport:http:resume-stream-api" )
300372async def test_a_captured_resumption_token_replays_missed_messages_on_a_new_connection () -> None :
301373 """A resumption token captured via on_resumption_token_update on one connection lets a fresh
0 commit comments