Skip to content

Commit e2f8d62

Browse files
committed
fix(hang): fix streamable_http GET 405 handling to prevent hangs with POST-only MCP servers
Signed-off-by: Samantha Coyle <sam@diagrid.io>
1 parent a1d330d commit e2f8d62

File tree

2 files changed

+111
-0
lines changed

2 files changed

+111
-0
lines changed

src/mcp/client/streamable_http.py

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,18 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer:
200200
# Stream ended normally (server closed) - reset attempt counter
201201
attempt = 0
202202

203+
except httpx.HTTPStatusError as exc: # pragma: no cover
204+
# Handle HTTP errors that are retryable
205+
if exc.response.status_code == 405:
206+
# Method Not Allowed - server doesn't support GET for SSE
207+
logger.warning(
208+
"Server does not support GET for SSE events (405 Method Not Allowed). "
209+
"Server-initiated messages will not be available."
210+
)
211+
return
212+
# For other HTTP errors, log and retry
213+
logger.debug(f"GET stream HTTP error: {exc.response.status_code} - {exc}")
214+
attempt += 1
203215
except Exception as exc: # pragma: no cover
204216
logger.debug(f"GET stream error: {exc}")
205217
attempt += 1
Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
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

Comments
 (0)