1+ """Test for streamable_http client handling of 405 Method Not Allowed on GET requests.
2+
3+ This test verifies the fix for the race condition where the client hangs when connecting
4+ to servers (like GitHub MCP) that don't support GET for SSE events.
5+ """
6+
7+ import logging
8+
9+ import anyio
10+ import httpx
11+ import pytest
12+ from starlette .applications import Starlette
13+ from starlette .requests import Request
14+ from starlette .responses import JSONResponse , Response
15+ from starlette .routing import Route
16+
17+ from mcp .client .session import ClientSession
18+ from mcp .client .streamable_http import streamable_http_client
19+ from mcp .types import InitializeResult
20+
21+
22+ async def mock_github_endpoint (request : Request ) -> Response :
23+ """Mock endpoint that returns 405 for GET (like GitHub MCP)."""
24+ if request .method == "GET" :
25+ return Response (
26+ content = "Method Not Allowed" ,
27+ status_code = 405 ,
28+ headers = {"Allow" : "POST, DELETE" },
29+ )
30+ elif request .method == "POST" :
31+ body = await request .json ()
32+ if body .get ("method" ) == "initialize" :
33+ return JSONResponse (
34+ {
35+ "jsonrpc" : "2.0" ,
36+ "id" : body .get ("id" ),
37+ "result" : {
38+ "protocolVersion" : "2025-03-26" ,
39+ "serverInfo" : {"name" : "mock_github_server" , "version" : "1.0" },
40+ "capabilities" : {"tools" : {}},
41+ },
42+ },
43+ headers = {"mcp-session-id" : "test-session" },
44+ )
45+ elif body .get ("method" ) == "notifications/initialized" :
46+ return Response (status_code = 202 )
47+ elif body .get ("method" ) == "tools/list" :
48+ return JSONResponse (
49+ {
50+ "jsonrpc" : "2.0" ,
51+ "id" : body .get ("id" ),
52+ "result" : {
53+ "tools" : [
54+ {
55+ "name" : "test_tool" ,
56+ "description" : "A test tool" ,
57+ "inputSchema" : {"type" : "object" , "properties" : {}},
58+ }
59+ ]
60+ },
61+ }
62+ )
63+ return Response (status_code = 405 )
64+
65+ @pytest .mark .anyio
66+ async def test_405_get_stream_does_not_hang (caplog : pytest .LogCaptureFixture ):
67+ """Test that client handles 405 on GET gracefully and doesn't hang."""
68+ app = Starlette (routes = [Route ("/mcp" , mock_github_endpoint , methods = ["GET" , "POST" ])])
69+
70+ with caplog .at_level (logging .INFO ):
71+ async with httpx .AsyncClient (
72+ transport = httpx .ASGITransport (app = app ), base_url = "http://testserver" , timeout = 5.0
73+ ) as http_client :
74+ async with streamable_http_client ("http://testserver/mcp" , http_client = http_client ) as (
75+ read_stream ,
76+ write_stream ,
77+ _ ,
78+ ):
79+ async with ClientSession (read_stream , write_stream ) as session :
80+ # Initialize sends the initialized notification internally
81+ init_result = await session .initialize ()
82+ assert isinstance (init_result , InitializeResult )
83+
84+ # Give the GET stream task time to fail with 405
85+ await anyio .sleep (0.2 )
86+
87+ # This should not hang and will now complete successfully
88+ tools_result = await session .list_tools ()
89+ assert len (tools_result .tools ) == 1
90+ assert tools_result .tools [0 ].name == "test_tool"
91+
92+ # Verify the 405 was logged and no retries occurred
93+ log_messages = [record .getMessage () for record in caplog .records ]
94+ assert any (
95+ "Server does not support GET for SSE events (405 Method Not Allowed)" in msg for msg in log_messages
96+ ), f"Expected 405 log message not found in: { log_messages } "
97+
98+ reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg .lower ()]
99+ assert len (reconnect_messages ) == 0 , f"Should not retry on 405, but found: { reconnect_messages } "
0 commit comments