|
| 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