@@ -337,10 +337,9 @@ 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+ manager = StreamableHTTPSessionManager (app = app , session_idle_timeout = 0.05 )
344343
345344 async with manager .run ():
346345 sent_messages : list [Message ] = []
@@ -358,7 +357,6 @@ async def mock_send(message: Message):
358357 async def mock_receive (): # pragma: no cover
359358 return {"type" : "http.request" , "body" : b"" , "more_body" : False }
360359
361- before = anyio .current_time ()
362360 await manager .handle_request (scope , mock_receive , mock_send )
363361
364362 session_id = None
@@ -372,35 +370,43 @@ async def mock_receive(): # pragma: no cover
372370 break
373371
374372 assert session_id is not None , "Session ID not found in response headers"
375- assert session_id in manager ._server_instances
376-
377- # Verify the idle deadline was set correctly
378- transport = manager ._server_instances [session_id ]
379- 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
383- transport .idle_scope .deadline = anyio .current_time ()
384-
385- 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 )
373+
374+ # Wait for the 50ms idle timeout to fire and cleanup to complete
375+ await anyio .sleep (0.5 )
376+
377+ # Verify via public API: old session ID now returns 404
378+ response_messages : list [Message ] = []
379+
380+ async def capture_send (message : Message ):
381+ response_messages .append (message )
382+
383+ scope_with_session = {
384+ "type" : "http" ,
385+ "method" : "POST" ,
386+ "path" : "/mcp" ,
387+ "headers" : [
388+ (b"content-type" , b"application/json" ),
389+ (b"mcp-session-id" , session_id .encode ()),
390+ ],
391+ }
392+
393+ await manager .handle_request (scope_with_session , mock_receive , capture_send )
394+
395+ response_start = next (
396+ (msg for msg in response_messages if msg ["type" ] == "http.response.start" ),
397+ None ,
398+ )
399+ assert response_start is not None
400+ assert response_start ["status" ] == 404
401+
402+
403+ def test_session_idle_timeout_rejects_non_positive ():
404+ with pytest .raises (ValueError , match = "positive number" ):
405+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = - 1 )
406+ with pytest .raises (ValueError , match = "positive number" ):
407+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = 0 )
408+
409+
410+ def test_session_idle_timeout_rejects_stateless ():
411+ with pytest .raises (RuntimeError , match = "not supported in stateless" ):
412+ StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = 30 , stateless = True )
0 commit comments