Skip to content

Commit d739a27

Browse files
committed
test(client): cover 405 GET stream behavior
- 405 on GET must stop the reconnect loop immediately (one attempt, no backoff sleep) since the server has definitively signaled that it does not offer a server-initiated SSE stream. - Other HTTP errors keep the existing bounded-retry behavior.
1 parent 686c967 commit d739a27

1 file changed

Lines changed: 84 additions & 0 deletions

File tree

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
"""Tests for GET stream handling when the server rejects GET with 405.
2+
3+
Some production MCP servers (e.g. GitHub Copilot's MCP endpoint) do not offer
4+
a server-initiated SSE stream and answer every GET with ``405 Method Not
5+
Allowed``. Per the Streamable HTTP spec, 405 is the server's definitive way of
6+
saying "no GET stream", so the client must not keep retrying: it burns the
7+
reconnection budget on every session and spams logs with reconnect noise.
8+
"""
9+
10+
import time
11+
from typing import Any, cast
12+
from unittest.mock import MagicMock
13+
14+
import httpx2
15+
import pytest
16+
17+
from mcp.client.streamable_http import StreamableHTTPTransport
18+
19+
20+
class _FailingEventSource:
21+
"""Async context manager that raises immediately on ``__aenter__``."""
22+
23+
def __init__(self, error: Exception, counter: list[int]) -> None:
24+
self._error = error
25+
self._counter = counter
26+
27+
async def __aenter__(self) -> None:
28+
self._counter[0] += 1
29+
raise self._error
30+
31+
async def __aexit__(self, *exc_info: object) -> bool:
32+
return False
33+
34+
35+
class _FailingClient:
36+
def __init__(self, error: Exception, counter: list[int]) -> None:
37+
self._error = error
38+
self._counter = counter
39+
40+
def sse(self, url: str, headers: dict[str, str] | None = None) -> _FailingEventSource:
41+
return _FailingEventSource(self._error, self._counter)
42+
43+
44+
def _status_error(status_code: int) -> httpx2.HTTPStatusError:
45+
request = httpx2.Request("GET", "http://localhost:8000/mcp")
46+
response = httpx2.Response(status_code, request=request)
47+
return httpx2.HTTPStatusError(
48+
f"Server returned status {status_code}", request=request, response=response
49+
)
50+
51+
52+
@pytest.mark.anyio
53+
async def test_get_stream_405_disables_retry() -> None:
54+
"""405 on GET is definitive: stop retrying instead of exhausting attempts."""
55+
transport = StreamableHTTPTransport("http://localhost:8000/mcp")
56+
transport.session_id = "session-1"
57+
58+
attempts = [0]
59+
client = _FailingClient(_status_error(405), attempts)
60+
61+
start = time.monotonic()
62+
await transport.handle_get_stream(client, cast(Any, MagicMock()))
63+
elapsed = time.monotonic() - start
64+
65+
assert attempts == [1] # no retry after a definitive 405
66+
assert elapsed < 1.0 # no reconnect backoff sleep
67+
68+
69+
@pytest.mark.anyio
70+
async def test_get_stream_other_http_errors_still_retry(monkeypatch: pytest.MonkeyPatch) -> None:
71+
"""Non-405 errors keep the existing bounded-retry behavior."""
72+
from mcp.client import streamable_http as sh
73+
74+
monkeypatch.setattr(sh, "DEFAULT_RECONNECTION_DELAY_MS", 0)
75+
76+
transport = StreamableHTTPTransport("http://localhost:8000/mcp")
77+
transport.session_id = "session-1"
78+
79+
attempts = [0]
80+
client = _FailingClient(_status_error(500), attempts)
81+
82+
await transport.handle_get_stream(client, cast(Any, MagicMock()))
83+
84+
assert attempts == [sh.MAX_RECONNECTION_ATTEMPTS]

0 commit comments

Comments
 (0)