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