From 686c967be489c579bb9da125494a80d96688b18f Mon Sep 17 00:00:00 2001 From: lccstc <85264839+lccstc@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:22:12 +0700 Subject: [PATCH 1/2] fix(client): stop retrying GET stream when server answers 405 Per the Streamable HTTP spec, a 405 response to GET is the server's definitive signal that it does not offer a server-initiated SSE stream. The transport currently treats it like any other disconnect and retries (MAX_RECONNECTION_ATTEMPTS times per session), producing pointless requests, reconnect backoff sleeps, and "GET stream disconnected, reconnecting in ...ms" log noise on every session creation. Treat HTTP 405 as terminal: log once and disable the GET stream for the session. POST-based request/response flows are unaffected. --- src/mcp/client/streamable_http.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 226b0fecf9..f564f776b8 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -227,6 +227,17 @@ async def handle_get_stream(self, client: httpx2.AsyncClient, read_stream_writer # Stream ended normally (server closed) - reset attempt counter attempt = 0 + except httpx2.HTTPStatusError as exc: + if exc.response.status_code == 405: + # Per the Streamable HTTP spec, a 405 response to GET means + # the server does not offer a server-initiated SSE stream. + # Retrying can never succeed, so disable the GET stream for + # this session instead of pointlessly retrying (and logging + # a reconnect every time a session is created). + logger.info("GET stream disabled: server does not support server-initiated SSE (405)") + return + logger.debug("GET stream error", exc_info=True) + attempt += 1 except Exception: logger.debug("GET stream error", exc_info=True) attempt += 1 From d739a27a4959c787032769a7df308b4cb3f91121 Mon Sep 17 00:00:00 2001 From: lccstc <85264839+lccstc@users.noreply.github.com> Date: Mon, 31 Aug 2026 06:22:49 +0700 Subject: [PATCH 2/2] 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. --- tests/client/test_streamable_http_405.py | 84 ++++++++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 tests/client/test_streamable_http_405.py diff --git a/tests/client/test_streamable_http_405.py b/tests/client/test_streamable_http_405.py new file mode 100644 index 0000000000..e1ced44c26 --- /dev/null +++ b/tests/client/test_streamable_http_405.py @@ -0,0 +1,84 @@ +"""Tests for GET stream handling when the server rejects GET with 405. + +Some production MCP servers (e.g. GitHub Copilot's MCP endpoint) do not offer +a server-initiated SSE stream and answer every GET with ``405 Method Not +Allowed``. Per the Streamable HTTP spec, 405 is the server's definitive way of +saying "no GET stream", so the client must not keep retrying: it burns the +reconnection budget on every session and spams logs with reconnect noise. +""" + +import time +from typing import Any, cast +from unittest.mock import MagicMock + +import httpx2 +import pytest + +from mcp.client.streamable_http import StreamableHTTPTransport + + +class _FailingEventSource: + """Async context manager that raises immediately on ``__aenter__``.""" + + def __init__(self, error: Exception, counter: list[int]) -> None: + self._error = error + self._counter = counter + + async def __aenter__(self) -> None: + self._counter[0] += 1 + raise self._error + + async def __aexit__(self, *exc_info: object) -> bool: + return False + + +class _FailingClient: + def __init__(self, error: Exception, counter: list[int]) -> None: + self._error = error + self._counter = counter + + def sse(self, url: str, headers: dict[str, str] | None = None) -> _FailingEventSource: + return _FailingEventSource(self._error, self._counter) + + +def _status_error(status_code: int) -> httpx2.HTTPStatusError: + request = httpx2.Request("GET", "http://localhost:8000/mcp") + response = httpx2.Response(status_code, request=request) + return httpx2.HTTPStatusError( + f"Server returned status {status_code}", request=request, response=response + ) + + +@pytest.mark.anyio +async def test_get_stream_405_disables_retry() -> None: + """405 on GET is definitive: stop retrying instead of exhausting attempts.""" + transport = StreamableHTTPTransport("http://localhost:8000/mcp") + transport.session_id = "session-1" + + attempts = [0] + client = _FailingClient(_status_error(405), attempts) + + start = time.monotonic() + await transport.handle_get_stream(client, cast(Any, MagicMock())) + elapsed = time.monotonic() - start + + assert attempts == [1] # no retry after a definitive 405 + assert elapsed < 1.0 # no reconnect backoff sleep + + +@pytest.mark.anyio +async def test_get_stream_other_http_errors_still_retry(monkeypatch: pytest.MonkeyPatch) -> None: + """Non-405 errors keep the existing bounded-retry behavior.""" + from mcp.client import streamable_http as sh + + monkeypatch.setattr(sh, "DEFAULT_RECONNECTION_DELAY_MS", 0) + + transport = StreamableHTTPTransport("http://localhost:8000/mcp") + transport.session_id = "session-1" + + attempts = [0] + client = _FailingClient(_status_error(500), attempts) + + await transport.handle_get_stream(client, cast(Any, MagicMock())) + + assert attempts == [sh.MAX_RECONNECTION_ATTEMPTS]