From e2f8d62e7c850cb9e9551bd8ae0903582131c245 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Fri, 23 Jan 2026 10:00:12 -0600 Subject: [PATCH 01/16] fix(hang): fix streamable_http GET 405 handling to prevent hangs with POST-only MCP servers Signed-off-by: Samantha Coyle --- src/mcp/client/streamable_http.py | 12 +++ .../test_streamable_http_405_get_stream.py | 99 +++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 tests/issues/test_streamable_http_405_get_stream.py diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 555dd1290..17eaa453e 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -200,6 +200,18 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: # Stream ended normally (server closed) - reset attempt counter attempt = 0 + except httpx.HTTPStatusError as exc: # pragma: no cover + # Handle HTTP errors that are retryable + if exc.response.status_code == 405: + # Method Not Allowed - server doesn't support GET for SSE + logger.warning( + "Server does not support GET for SSE events (405 Method Not Allowed). " + "Server-initiated messages will not be available." + ) + return + # For other HTTP errors, log and retry + logger.debug(f"GET stream HTTP error: {exc.response.status_code} - {exc}") + attempt += 1 except Exception as exc: # pragma: no cover logger.debug(f"GET stream error: {exc}") attempt += 1 diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py new file mode 100644 index 000000000..9967a5965 --- /dev/null +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -0,0 +1,99 @@ +"""Test for streamable_http client handling of 405 Method Not Allowed on GET requests. + +This test verifies the fix for the race condition where the client hangs when connecting +to servers (like GitHub MCP) that don't support GET for SSE events. +""" + +import logging + +import anyio +import httpx +import pytest +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse, Response +from starlette.routing import Route + +from mcp.client.session import ClientSession +from mcp.client.streamable_http import streamable_http_client +from mcp.types import InitializeResult + + +async def mock_github_endpoint(request: Request) -> Response: + """Mock endpoint that returns 405 for GET (like GitHub MCP).""" + if request.method == "GET": + return Response( + content="Method Not Allowed", + status_code=405, + headers={"Allow": "POST, DELETE"}, + ) + elif request.method == "POST": + body = await request.json() + if body.get("method") == "initialize": + return JSONResponse( + { + "jsonrpc": "2.0", + "id": body.get("id"), + "result": { + "protocolVersion": "2025-03-26", + "serverInfo": {"name": "mock_github_server", "version": "1.0"}, + "capabilities": {"tools": {}}, + }, + }, + headers={"mcp-session-id": "test-session"}, + ) + elif body.get("method") == "notifications/initialized": + return Response(status_code=202) + elif body.get("method") == "tools/list": + return JSONResponse( + { + "jsonrpc": "2.0", + "id": body.get("id"), + "result": { + "tools": [ + { + "name": "test_tool", + "description": "A test tool", + "inputSchema": {"type": "object", "properties": {}}, + } + ] + }, + } + ) + return Response(status_code=405) + +@pytest.mark.anyio +async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): + """Test that client handles 405 on GET gracefully and doesn't hang.""" + app = Starlette(routes=[Route("/mcp", mock_github_endpoint, methods=["GET", "POST"])]) + + with caplog.at_level(logging.INFO): + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 + ) as http_client: + async with streamable_http_client("http://testserver/mcp", http_client=http_client) as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + # Initialize sends the initialized notification internally + init_result = await session.initialize() + assert isinstance(init_result, InitializeResult) + + # Give the GET stream task time to fail with 405 + await anyio.sleep(0.2) + + # This should not hang and will now complete successfully + tools_result = await session.list_tools() + assert len(tools_result.tools) == 1 + assert tools_result.tools[0].name == "test_tool" + + # Verify the 405 was logged and no retries occurred + log_messages = [record.getMessage() for record in caplog.records] + assert any( + "Server does not support GET for SSE events (405 Method Not Allowed)" in msg for msg in log_messages + ), f"Expected 405 log message not found in: {log_messages}" + + reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] + assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" \ No newline at end of file From 48cfb0051f3bc99e6ea3c4a03c17b1292c93f1e6 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Thu, 29 Jan 2026 15:54:13 -0600 Subject: [PATCH 02/16] style: end with newline char Signed-off-by: Samantha Coyle --- tests/issues/test_streamable_http_405_get_stream.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 9967a5965..3d0d969b1 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -96,4 +96,5 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): ), f"Expected 405 log message not found in: {log_messages}" reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] - assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" \ No newline at end of file + assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" + \ No newline at end of file From 581c81728051e4a9eb9abd137b77bff7dc89e11d Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 3 Feb 2026 14:45:29 -0600 Subject: [PATCH 03/16] style: appease linter Signed-off-by: Samantha Coyle --- tests/issues/test_streamable_http_405_get_stream.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 3d0d969b1..9967a5965 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -96,5 +96,4 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): ), f"Expected 405 log message not found in: {log_messages}" reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] - assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" - \ No newline at end of file + assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" \ No newline at end of file From 6b172ccc1be19e78f68c043eb26f4ecc380759e9 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 3 Feb 2026 14:48:15 -0600 Subject: [PATCH 04/16] fix(build): fixes for pre-commit run --all-files Signed-off-by: Samantha Coyle --- src/mcp/client/streamable_http.py | 2 +- tests/issues/test_streamable_http_405_get_stream.py | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 7ade270c0..b7f3ef28e 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -200,7 +200,7 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: # Stream ended normally (server closed) - reset attempt counter attempt = 0 - + except httpx.HTTPStatusError as exc: # pragma: lax no cover # Handle HTTP errors that are retryable if exc.response.status_code == 405: diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 9967a5965..0bb2edac4 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -62,6 +62,7 @@ async def mock_github_endpoint(request: Request) -> Response: ) return Response(status_code=405) + @pytest.mark.anyio async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): """Test that client handles 405 on GET gracefully and doesn't hang.""" @@ -91,9 +92,9 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): # Verify the 405 was logged and no retries occurred log_messages = [record.getMessage() for record in caplog.records] - assert any( - "Server does not support GET for SSE events (405 Method Not Allowed)" in msg for msg in log_messages - ), f"Expected 405 log message not found in: {log_messages}" + assert any("Server does not support GET for SSE events (405 Method Not Allowed)" in msg for msg in log_messages), ( + f"Expected 405 log message not found in: {log_messages}" + ) reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] - assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" \ No newline at end of file + assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" From 22134faf2451acd3fb98c66bef84e0c183612fbe Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 16 Mar 2026 14:53:10 -0500 Subject: [PATCH 05/16] style: appease linter Signed-off-by: Samantha Coyle --- src/mcp/client/streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 20f3b0a20..c66766636 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -222,7 +222,7 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: # For other HTTP errors, log and retry logger.debug(f"GET stream HTTP error: {exc.response.status_code} - {exc}") attempt += 1 - except Exception as exc: # pragma: lax no cover + except Exception: # pragma: lax no cover logger.debug("GET stream error", exc_info=True) attempt += 1 From 9c24e70665c49a2486f336d282cfdd2958681dfa Mon Sep 17 00:00:00 2001 From: Sam Date: Mon, 16 Mar 2026 14:56:56 -0500 Subject: [PATCH 06/16] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- src/mcp/client/streamable_http.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index c66766636..57d7bf78c 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -222,8 +222,8 @@ async def handle_get_stream(self, client: httpx.AsyncClient, read_stream_writer: # For other HTTP errors, log and retry logger.debug(f"GET stream HTTP error: {exc.response.status_code} - {exc}") attempt += 1 - except Exception: # pragma: lax no cover - logger.debug("GET stream error", exc_info=True) + except Exception as exc: # pragma: lax no cover + logger.debug("GET stream error: %s", exc, exc_info=True) attempt += 1 if attempt >= MAX_RECONNECTION_ATTEMPTS: # pragma: no cover From 0ee54bc270f875a6f1c5cae84bdf03817492c938 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 16 Mar 2026 15:00:30 -0500 Subject: [PATCH 07/16] fix: address copilot feedback Signed-off-by: Samantha Coyle --- .../issues/test_streamable_http_405_get_stream.py | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 0bb2edac4..19c4dc6fc 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -75,18 +75,22 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): async with streamable_http_client("http://testserver/mcp", http_client=http_client) as ( read_stream, write_stream, - _, ): async with ClientSession(read_stream, write_stream) as session: # Initialize sends the initialized notification internally - init_result = await session.initialize() + with anyio.fail_after(5.0): + init_result = await session.initialize() assert isinstance(init_result, InitializeResult) - # Give the GET stream task time to fail with 405 - await anyio.sleep(0.2) + # Wait until the GET stream task fails with 405 and logs the expected message + expected_log = "Server does not support GET for SSE events (405 Method Not Allowed)" + with anyio.fail_after(5.0): + while not any(expected_log in record.getMessage() for record in caplog.records): + await anyio.sleep(0.05) # This should not hang and will now complete successfully - tools_result = await session.list_tools() + with anyio.fail_after(5.0): + tools_result = await session.list_tools() assert len(tools_result.tools) == 1 assert tools_result.tools[0].name == "test_tool" From f0d5fb3a7d343630bbc621a0aaa772620df22dae Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 16 Mar 2026 15:12:57 -0500 Subject: [PATCH 08/16] fix(build): up test coverage Signed-off-by: Samantha Coyle --- .../issues/test_streamable_http_405_get_stream.py | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 19c4dc6fc..83cb4f3a8 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -102,3 +102,18 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" + + +@pytest.mark.anyio +async def test_mock_github_endpoint_other_method_returns_405() -> None: + """Ensure fallback 405 branch is covered for non-GET/POST methods.""" + app = Starlette(routes=[Route("/mcp", mock_github_endpoint, methods=["GET", "POST", "DELETE"])]) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + timeout=5.0, + ) as http_client: + response = await http_client.delete("/mcp") + + assert response.status_code == 405 From 7d8ec651322505975af03ead239ee802d7f4c704 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 16 Mar 2026 15:52:21 -0500 Subject: [PATCH 09/16] fix(build): add more test coverage Signed-off-by: Samantha Coyle --- .../test_streamable_http_405_get_stream.py | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 83cb4f3a8..2a7df3567 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -117,3 +117,21 @@ async def test_mock_github_endpoint_other_method_returns_405() -> None: response = await http_client.delete("/mcp") assert response.status_code == 405 + + +@pytest.mark.anyio +async def test_mock_github_endpoint_post_unknown_method_returns_405() -> None: + """Ensure POST with unknown method hits fallback 405 branch.""" + app = Starlette(routes=[Route("/mcp", mock_github_endpoint, methods=["POST"])]) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + timeout=5.0, + ) as http_client: + response = await http_client.post( + "/mcp", + json={"jsonrpc": "2.0", "id": 1, "method": "unknown/method"}, + ) + + assert response.status_code == 405 From 23d8ee8c5b89eb7a7df854f24c11cf6c12bcffaa Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Mon, 16 Mar 2026 16:02:05 -0500 Subject: [PATCH 10/16] fix: update to make build pass Signed-off-by: Samantha Coyle --- tests/issues/test_streamable_http_405_get_stream.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 2a7df3567..69d91db18 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -96,12 +96,17 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): # Verify the 405 was logged and no retries occurred log_messages = [record.getMessage() for record in caplog.records] - assert any("Server does not support GET for SSE events (405 Method Not Allowed)" in msg for msg in log_messages), ( + assert any( + "Server does not support GET for SSE events (405 Method Not Allowed)" in msg + for msg in log_messages + ), ( # pragma: no branch f"Expected 405 log message not found in: {log_messages}" ) reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] - assert len(reconnect_messages) == 0, f"Should not retry on 405, but found: {reconnect_messages}" + assert len(reconnect_messages) == 0, ( # pragma: no branch + f"Should not retry on 405, but found: {reconnect_messages}" + ) @pytest.mark.anyio From 03406025355b8d316570e009c0a40eb84595cd0e Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 17 Mar 2026 08:40:31 -0500 Subject: [PATCH 11/16] fix: update coverage for 3.11 and 3.14 Signed-off-by: Samantha Coyle --- .../test_streamable_http_405_get_stream.py | 25 +++++++++---------- 1 file changed, 12 insertions(+), 13 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 69d91db18..936624df2 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -94,19 +94,18 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): assert len(tools_result.tools) == 1 assert tools_result.tools[0].name == "test_tool" - # Verify the 405 was logged and no retries occurred - log_messages = [record.getMessage() for record in caplog.records] - assert any( - "Server does not support GET for SSE events (405 Method Not Allowed)" in msg - for msg in log_messages - ), ( # pragma: no branch - f"Expected 405 log message not found in: {log_messages}" - ) - - reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] - assert len(reconnect_messages) == 0, ( # pragma: no branch - f"Should not retry on 405, but found: {reconnect_messages}" - ) + # Verify the 405 was logged and no retries occurred + log_messages = [record.getMessage() for record in caplog.records] + assert any( + "Server does not support GET for SSE events (405 Method Not Allowed)" in msg for msg in log_messages + ), ( # pragma: no branch + f"Expected 405 log message not found in: {log_messages}" + ) + + reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] + assert len(reconnect_messages) == 0, ( # pragma: no branch + f"Should not retry on 405, but found: {reconnect_messages}" + ) @pytest.mark.anyio From 740b503f83e411244b7fc4a533d73e4b476bc317 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 17 Mar 2026 09:11:38 -0500 Subject: [PATCH 12/16] fix: see if this makes build happy Signed-off-by: Samantha Coyle --- .../test_streamable_http_405_get_stream.py | 25 ++++++++++--------- 1 file changed, 13 insertions(+), 12 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 936624df2..fd05a0384 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -94,18 +94,19 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): assert len(tools_result.tools) == 1 assert tools_result.tools[0].name == "test_tool" - # Verify the 405 was logged and no retries occurred - log_messages = [record.getMessage() for record in caplog.records] - assert any( - "Server does not support GET for SSE events (405 Method Not Allowed)" in msg for msg in log_messages - ), ( # pragma: no branch - f"Expected 405 log message not found in: {log_messages}" - ) - - reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] - assert len(reconnect_messages) == 0, ( # pragma: no branch - f"Should not retry on 405, but found: {reconnect_messages}" - ) + # Verify the 405 was logged and no retries occurred + log_messages = [record.getMessage() for record in caplog.records] + assert any( + "Server does not support GET for SSE events (405 Method Not Allowed)" in msg + for msg in log_messages + ), ( # pragma: no branch + f"Expected 405 log message not found in: {log_messages}" + ) + + reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] + assert len(reconnect_messages) == 0, ( # pragma: no branch + f"Should not retry on 405, but found: {reconnect_messages}" + ) @pytest.mark.anyio From ca12fcd2ca01982cac947026055868c9403466e4 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 17 Mar 2026 09:20:15 -0500 Subject: [PATCH 13/16] fix: udpates for build Signed-off-by: Samantha Coyle --- tests/issues/test_streamable_http_405_get_stream.py | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index fd05a0384..3b76febd6 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -72,10 +72,9 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as http_client: - async with streamable_http_client("http://testserver/mcp", http_client=http_client) as ( - read_stream, - write_stream, - ): + transport_cm = streamable_http_client("http://testserver/mcp", http_client=http_client) + async with transport_cm as transport_streams: # pragma: no cover + read_stream, write_stream = transport_streams async with ClientSession(read_stream, write_stream) as session: # Initialize sends the initialized notification internally with anyio.fail_after(5.0): From 1aaad1d6e5bfd63bc893b2a4f6356f30478bf21a Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 17 Mar 2026 09:29:19 -0500 Subject: [PATCH 14/16] fix: build again hopefully Signed-off-by: Samantha Coyle --- .../test_streamable_http_405_get_stream.py | 35 ++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 3b76febd6..485199717 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -5,6 +5,7 @@ """ import logging +from typing import Protocol, cast import anyio import httpx @@ -19,6 +20,10 @@ from mcp.types import InitializeResult +class _ExceptionGroupWithExceptions(Protocol): + exceptions: tuple[BaseException, ...] + + async def mock_github_endpoint(request: Request) -> Response: """Mock endpoint that returns 405 for GET (like GitHub MCP).""" if request.method == "GET": @@ -73,7 +78,7 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as http_client: transport_cm = streamable_http_client("http://testserver/mcp", http_client=http_client) - async with transport_cm as transport_streams: # pragma: no cover + async with transport_cm as transport_streams: read_stream, write_stream = transport_streams async with ClientSession(read_stream, write_stream) as session: # Initialize sends the initialized notification internally @@ -108,6 +113,34 @@ async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): ) +@pytest.mark.anyio +async def test_streamable_http_client_context_manager_exception_exit_is_covered() -> None: + """Cover the exceptional exit path of the streamable HTTP transport context manager. + + Branch coverage can vary across Python versions for `async with` teardown paths. + This test ensures the exception-unwind path is exercised without relying on pragmas. + """ + app = Starlette(routes=[Route("/mcp", mock_github_endpoint, methods=["GET", "POST"])]) + + async with httpx.AsyncClient( + transport=httpx.ASGITransport(app=app), + base_url="http://testserver", + timeout=5.0, + ) as http_client: + transport_cm = streamable_http_client("http://testserver/mcp", http_client=http_client) + with pytest.raises(BaseException) as excinfo: + async with transport_cm: + raise RuntimeError("boom") + + exc = excinfo.value + if hasattr(exc, "exceptions"): + excs = cast(_ExceptionGroupWithExceptions, exc).exceptions + else: + excs = (exc,) + + assert any(isinstance(inner, RuntimeError) and str(inner) == "boom" for inner in excs) + + @pytest.mark.anyio async def test_mock_github_endpoint_other_method_returns_405() -> None: """Ensure fallback 405 branch is covered for non-GET/POST methods.""" From a368054a9d0adcc4284749e5d25321a8a35be35b Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 17 Mar 2026 09:35:04 -0500 Subject: [PATCH 15/16] fix: try this Signed-off-by: Samantha Coyle --- tests/issues/test_streamable_http_405_get_stream.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 485199717..34e2b5764 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -133,11 +133,9 @@ async def test_streamable_http_client_context_manager_exception_exit_is_covered( raise RuntimeError("boom") exc = excinfo.value - if hasattr(exc, "exceptions"): - excs = cast(_ExceptionGroupWithExceptions, exc).exceptions - else: - excs = (exc,) - + # anyio always wraps exceptions in an ExceptionGroup + assert hasattr(exc, "exceptions") + excs = cast(_ExceptionGroupWithExceptions, exc).exceptions assert any(isinstance(inner, RuntimeError) and str(inner) == "boom" for inner in excs) From 333e9ad3b5a363669b8bc9f5309c2ec3859ede79 Mon Sep 17 00:00:00 2001 From: Samantha Coyle Date: Tue, 17 Mar 2026 09:57:45 -0500 Subject: [PATCH 16/16] fix: updates for biuld Signed-off-by: Samantha Coyle --- .../test_streamable_http_405_get_stream.py | 37 +++++++++---------- 1 file changed, 18 insertions(+), 19 deletions(-) diff --git a/tests/issues/test_streamable_http_405_get_stream.py b/tests/issues/test_streamable_http_405_get_stream.py index 34e2b5764..3053a8b5a 100644 --- a/tests/issues/test_streamable_http_405_get_stream.py +++ b/tests/issues/test_streamable_http_405_get_stream.py @@ -69,48 +69,47 @@ async def mock_github_endpoint(request: Request) -> Response: @pytest.mark.anyio -async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture): +async def test_405_get_stream_does_not_hang(caplog: pytest.LogCaptureFixture) -> None: """Test that client handles 405 on GET gracefully and doesn't hang.""" app = Starlette(routes=[Route("/mcp", mock_github_endpoint, methods=["GET", "POST"])]) + expected_log = "Server does not support GET for SSE events (405 Method Not Allowed)" + got_405 = anyio.Event() + + class _405LogHandler(logging.Handler): + def emit(self, record: logging.LogRecord) -> None: + if expected_log in record.getMessage(): + got_405.set() + + mcp_logger = logging.getLogger("mcp.client.streamable_http") + handler = _405LogHandler() + mcp_logger.addHandler(handler) with caplog.at_level(logging.INFO): async with httpx.AsyncClient( transport=httpx.ASGITransport(app=app), base_url="http://testserver", timeout=5.0 ) as http_client: transport_cm = streamable_http_client("http://testserver/mcp", http_client=http_client) - async with transport_cm as transport_streams: + async with transport_cm as transport_streams: # pragma: no branch read_stream, write_stream = transport_streams - async with ClientSession(read_stream, write_stream) as session: - # Initialize sends the initialized notification internally + async with ClientSession(read_stream, write_stream) as session: # pragma: no branch with anyio.fail_after(5.0): init_result = await session.initialize() assert isinstance(init_result, InitializeResult) - # Wait until the GET stream task fails with 405 and logs the expected message - expected_log = "Server does not support GET for SSE events (405 Method Not Allowed)" with anyio.fail_after(5.0): - while not any(expected_log in record.getMessage() for record in caplog.records): - await anyio.sleep(0.05) + await got_405.wait() - # This should not hang and will now complete successfully with anyio.fail_after(5.0): tools_result = await session.list_tools() assert len(tools_result.tools) == 1 assert tools_result.tools[0].name == "test_tool" - # Verify the 405 was logged and no retries occurred log_messages = [record.getMessage() for record in caplog.records] - assert any( - "Server does not support GET for SSE events (405 Method Not Allowed)" in msg - for msg in log_messages - ), ( # pragma: no branch - f"Expected 405 log message not found in: {log_messages}" - ) + assert any(expected_log in msg for msg in log_messages) # pragma: no branch reconnect_messages = [msg for msg in log_messages if "reconnecting" in msg.lower()] - assert len(reconnect_messages) == 0, ( # pragma: no branch - f"Should not retry on 405, but found: {reconnect_messages}" - ) + assert len(reconnect_messages) == 0 # pragma: no branch + mcp_logger.removeHandler(handler) @pytest.mark.anyio