@@ -337,12 +337,24 @@ async def handle_list_tools(ctx: ServerRequestContext, params: PaginatedRequestP
337337
338338@pytest .mark .anyio
339339async def test_idle_session_is_reaped ():
340- """Idle timeout sets a cancel scope deadline and reaps the session when it fires."""
341- idle_timeout = 300
340+ """After idle timeout fires, the session returns 404."""
342341 app = Server ("test-idle-reap" )
343- manager = StreamableHTTPSessionManager (app = app , session_idle_timeout = idle_timeout )
342+
343+ run_finished = anyio .Event ()
344+ original_run = app .run
345+
346+ async def tracked_run (* args : Any , ** kwargs : Any ) -> None :
347+ try :
348+ await original_run (* args , ** kwargs )
349+ finally :
350+ run_finished .set ()
351+
352+ app .run = tracked_run # type: ignore[assignment]
353+
354+ manager = StreamableHTTPSessionManager (app = app , session_idle_timeout = 300 )
344355
345356 async with manager .run ():
357+ # Create a session
346358 sent_messages : list [Message ] = []
347359
348360 async def mock_send (message : Message ):
@@ -358,7 +370,6 @@ async def mock_send(message: Message):
358370 async def mock_receive (): # pragma: no cover
359371 return {"type" : "http.request" , "body" : b"" , "more_body" : False }
360372
361- before = anyio .current_time ()
362373 await manager .handle_request (scope , mock_receive , mock_send )
363374
364375 session_id = None
@@ -372,35 +383,54 @@ async def mock_receive(): # pragma: no cover
372383 break
373384
374385 assert session_id is not None , "Session ID not found in response headers"
375- assert session_id in manager ._server_instances
376386
377- # Verify the idle deadline was set correctly
387+ # Force the idle deadline to expire
378388 transport = manager ._server_instances [session_id ]
379389 assert transport .idle_scope is not None
380- assert transport .idle_scope .deadline >= before + idle_timeout
381-
382- # Simulate time passing by expiring the deadline
383390 transport .idle_scope .deadline = anyio .current_time ()
384391
392+ # Wait for app.run to exit via the cancel scope, then one checkpoint for cleanup
385393 with anyio .fail_after (5 ):
386- while session_id in manager ._server_instances :
387- await anyio .sleep (0 )
388-
389- assert session_id not in manager ._server_instances
390-
391- # Verify terminate() is idempotent
392- await transport .terminate ()
393- assert transport .is_terminated
394-
395-
396- @pytest .mark .parametrize (
397- "kwargs,match" ,
398- [
399- ({"session_idle_timeout" : - 1 }, "positive number" ),
400- ({"session_idle_timeout" : 0 }, "positive number" ),
401- ({"session_idle_timeout" : 30 , "stateless" : True }, "not supported in stateless" ),
402- ],
403- )
404- def test_session_idle_timeout_validation (kwargs : dict [str , Any ], match : str ):
405- with pytest .raises (ValueError , match = match ):
406- StreamableHTTPSessionManager (app = Server ("test" ), ** kwargs )
394+ await run_finished .wait ()
395+ await anyio .sleep (0 )
396+
397+ # Verify session is gone via public API: request with old session ID returns 404
398+ response_messages : list [Message ] = []
399+ response_body = b""
400+
401+ async def capture_send (message : Message ):
402+ nonlocal response_body
403+ response_messages .append (message )
404+ if message ["type" ] == "http.response.body" :
405+ response_body += message .get ("body" , b"" )
406+
407+ scope_with_session = {
408+ "type" : "http" ,
409+ "method" : "POST" ,
410+ "path" : "/mcp" ,
411+ "headers" : [
412+ (b"content-type" , b"application/json" ),
413+ (b"mcp-session-id" , session_id .encode ()),
414+ ],
415+ }
416+
417+ await manager .handle_request (scope_with_session , mock_receive , capture_send )
418+
419+ response_start = next (
420+ (msg for msg in response_messages if msg ["type" ] == "http.response.start" ),
421+ None ,
422+ )
423+ assert response_start is not None
424+ assert response_start ["status" ] == 404
425+
426+
427+ def test_session_idle_timeout_rejects_non_positive ():
428+ with pytest .raises (ValueError , match = "positive number" ):
429+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = - 1 )
430+ with pytest .raises (ValueError , match = "positive number" ):
431+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = 0 )
432+
433+
434+ def test_session_idle_timeout_rejects_stateless ():
435+ with pytest .raises (RuntimeError , match = "not supported in stateless" ):
436+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = 30 , stateless = True )
0 commit comments