@@ -390,3 +390,116 @@ def test_session_idle_timeout_rejects_non_positive():
390390def test_session_idle_timeout_rejects_stateless ():
391391 with pytest .raises (RuntimeError , match = "not supported in stateless" ):
392392 StreamableHTTPSessionManager (app = Server ("test" ), session_idle_timeout = 30 , stateless = True )
393+
394+
395+ async def _collect_stateless_response (
396+ method : str ,
397+ ) -> tuple [Message | None , bytes ]:
398+ """Send a request of the given method to a stateless manager and return
399+ (response.start message, response body)."""
400+ app = Server ("test-stateless-method" )
401+ manager = StreamableHTTPSessionManager (app = app , stateless = True )
402+
403+ sent_messages : list [Message ] = []
404+ response_body = b""
405+
406+ async def mock_send (message : Message ):
407+ nonlocal response_body
408+ sent_messages .append (message )
409+ if message ["type" ] == "http.response.body" :
410+ response_body += message .get ("body" , b"" )
411+
412+ scope = {
413+ "type" : "http" ,
414+ "method" : method ,
415+ "path" : "/mcp" ,
416+ "headers" : [
417+ (b"content-type" , b"application/json" ),
418+ (b"accept" , b"application/json, text/event-stream" ),
419+ ],
420+ }
421+
422+ async def mock_receive (): # pragma: no cover
423+ return {"type" : "http.request" , "body" : b"" , "more_body" : False }
424+
425+ async with manager .run ():
426+ await manager .handle_request (scope , mock_receive , mock_send )
427+
428+ response_start = next (
429+ (msg for msg in sent_messages if msg ["type" ] == "http.response.start" ),
430+ None ,
431+ )
432+ return response_start , response_body
433+
434+
435+ @pytest .mark .anyio
436+ async def test_stateless_get_returns_405 ():
437+ """GET requests return 405 in stateless mode since SSE streams require session state."""
438+ response_start , response_body = await _collect_stateless_response ("GET" )
439+
440+ assert response_start is not None
441+ assert response_start ["status" ] == 405
442+
443+ headers = {name .decode ().lower (): value .decode () for name , value in response_start .get ("headers" , [])}
444+ assert headers .get ("allow" ) == "POST"
445+
446+ error_data = json .loads (response_body )
447+ assert error_data ["jsonrpc" ] == "2.0"
448+ assert error_data ["id" ] is None
449+ assert error_data ["error" ]["code" ] == INVALID_REQUEST
450+ assert "GET" in error_data ["error" ]["message" ]
451+ assert "stateless" in error_data ["error" ]["message" ].lower ()
452+
453+
454+ @pytest .mark .anyio
455+ async def test_stateless_delete_returns_405 ():
456+ """DELETE requests return 405 in stateless mode since there is no session to terminate."""
457+ response_start , response_body = await _collect_stateless_response ("DELETE" )
458+
459+ assert response_start is not None
460+ assert response_start ["status" ] == 405
461+
462+ headers = {name .decode ().lower (): value .decode () for name , value in response_start .get ("headers" , [])}
463+ assert headers .get ("allow" ) == "POST"
464+
465+ error_data = json .loads (response_body )
466+ assert error_data ["jsonrpc" ] == "2.0"
467+ assert error_data ["id" ] is None
468+ assert error_data ["error" ]["code" ] == INVALID_REQUEST
469+ assert "DELETE" in error_data ["error" ]["message" ]
470+
471+
472+ @pytest .mark .anyio
473+ async def test_stateless_get_does_not_create_transport ():
474+ """A GET in stateless mode should short-circuit without spinning up a transport."""
475+ app = Server ("test-stateless-no-transport" )
476+ manager = StreamableHTTPSessionManager (app = app , stateless = True )
477+
478+ created_transports : list [StreamableHTTPServerTransport ] = []
479+ original_constructor = StreamableHTTPServerTransport
480+
481+ def track_transport (* args : Any , ** kwargs : Any ) -> StreamableHTTPServerTransport :
482+ transport = original_constructor (* args , ** kwargs ) # pragma: no cover
483+ created_transports .append (transport ) # pragma: no cover
484+ return transport # pragma: no cover
485+
486+ with patch .object (streamable_http_manager , "StreamableHTTPServerTransport" , side_effect = track_transport ):
487+ async with manager .run ():
488+ sent_messages : list [Message ] = []
489+
490+ async def mock_send (message : Message ):
491+ sent_messages .append (message )
492+
493+ scope = {
494+ "type" : "http" ,
495+ "method" : "GET" ,
496+ "path" : "/mcp" ,
497+ "headers" : [(b"accept" , b"text/event-stream" )],
498+ }
499+
500+ async def mock_receive (): # pragma: no cover
501+ return {"type" : "http.request" , "body" : b"" , "more_body" : False }
502+
503+ await manager .handle_request (scope , mock_receive , mock_send )
504+
505+ assert created_transports == [], "Stateless GET must not create a transport"
0 commit comments