From d97ada4c8911751218a6c08f02c4009b5c385b53 Mon Sep 17 00:00:00 2001 From: zsxh1990 <445655361@qq.com> Date: Tue, 14 Jul 2026 20:23:15 +0800 Subject: [PATCH 1/7] test: add tests for non-2xx HTTP status handling (fixes #3091) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Verify that StreamableHTTPTransport._handle_post_request properly handles 401/403/5xx by sending JSONRPCError to the caller instead of letting the request timeout. Tests: - 401 → JSONRPCError with INTERNAL_ERROR code - 403 → JSONRPCError with INTERNAL_ERROR code - 500 → JSONRPCError with INTERNAL_ERROR code - JSON error body → parsed and used directly Closes #3091 --- tests/client/test_non2xx_status.py | 146 +++++++++++++++++++++++++++++ 1 file changed, 146 insertions(+) create mode 100644 tests/client/test_non2xx_status.py diff --git a/tests/client/test_non2xx_status.py b/tests/client/test_non2xx_status.py new file mode 100644 index 0000000000..9f29271f16 --- /dev/null +++ b/tests/client/test_non2xx_status.py @@ -0,0 +1,146 @@ +"""Tests for non-2xx HTTP status handling in StreamableHTTPTransport. + +Verifies that when the server returns 401/403/5xx, the caller receives +a proper JSONRPCError (not a timeout). + +Closes #3091 +""" +import json +import pytest +import httpx +from unittest.mock import AsyncMock, MagicMock, patch + +from mcp.client.streamable_http import StreamableHTTPTransport, RequestContext +from mcp_types import JSONRPCRequest, JSONRPCError, ErrorData, SessionMessage + + +class TestNon2xxStatusHandling: + """Test that non-2xx status codes produce proper error responses.""" + + def _make_request_context(self, request_id: str = "test-123") -> MagicMock: + """Create a mock RequestContext.""" + ctx = MagicMock(spec=RequestContext) + ctx.session_message = MagicMock() + ctx.session_message.message = MagicMock(spec=JSONRPCRequest) + ctx.session_message.message.id = request_id + ctx.read_stream_writer = AsyncMock() + ctx.client = AsyncMock() + ctx.metadata = None + return ctx + + @pytest.mark.anyio + async def test_401_produces_error_response(self): + """401 Unauthorized should produce a JSONRPCError with the request's ID.""" + # This test verifies the fix for #3091 + # When server returns 401, caller should get a proper error, not a timeout + transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) + transport.url = "http://test" + transport.session_id = None + + ctx = self._make_request_context() + + # Mock the response to return 401 + mock_response = MagicMock() + mock_response.status_code = 401 + mock_response.headers = {"content-type": "text/plain"} + mock_response.aread = AsyncMock(return_value=b"Unauthorized") + + # Mock the context manager for stream() + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + ctx.client.stream.return_value = mock_stream_ctx + + # Call _handle_post_request + await transport._handle_post_request(ctx) + + # Verify that an error was sent to the read_stream_writer + ctx.read_stream_writer.send.assert_called_once() + sent_message = ctx.read_stream_writer.send.call_args[0][0] + assert isinstance(sent_message, SessionMessage) + assert isinstance(sent_message.message, JSONRPCError) + assert sent_message.message.id == "test-123" + assert sent_message.message.error.code == -32603 # INTERNAL_ERROR + + @pytest.mark.anyio + async def test_403_produces_error_response(self): + """403 Forbidden should produce a JSONRPCError with the request's ID.""" + transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) + transport.url = "http://test" + transport.session_id = None + + ctx = self._make_request_context() + + mock_response = MagicMock() + mock_response.status_code = 403 + mock_response.headers = {"content-type": "text/plain"} + mock_response.aread = AsyncMock(return_value=b"Forbidden") + + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + ctx.client.stream.return_value = mock_stream_ctx + + await transport._handle_post_request(ctx) + + ctx.read_stream_writer.send.assert_called_once() + sent_message = ctx.read_stream_writer.send.call_args[0][0] + assert isinstance(sent_message.message, JSONRPCError) + assert sent_message.message.id == "test-123" + + @pytest.mark.anyio + async def test_500_produces_error_response(self): + """500 Internal Server Error should produce a JSONRPCError.""" + transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) + transport.url = "http://test" + transport.session_id = None + + ctx = self._make_request_context() + + mock_response = MagicMock() + mock_response.status_code = 500 + mock_response.headers = {"content-type": "text/plain"} + mock_response.aread = AsyncMock(return_value=b"Internal Server Error") + + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + ctx.client.stream.return_value = mock_stream_ctx + + await transport._handle_post_request(ctx) + + ctx.read_stream_writer.send.assert_called_once() + sent_message = ctx.read_stream_writer.send.call_args[0][0] + assert isinstance(sent_message.message, JSONRPCError) + + @pytest.mark.anyio + async def test_json_error_body_is_parsed(self): + """When server returns JSON-RPC error body, it should be used directly.""" + transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) + transport.url = "http://test" + transport.session_id = None + + ctx = self._make_request_context() + + error_body = json.dumps({ + "jsonrpc": "2.0", + "id": "test-123", + "error": {"code": -32600, "message": "Invalid Request"} + }).encode() + + mock_response = MagicMock() + mock_response.status_code = 400 + mock_response.headers = {"content-type": "application/json"} + mock_response.aread = AsyncMock(return_value=error_body) + + mock_stream_ctx = AsyncMock() + mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) + mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) + ctx.client.stream.return_value = mock_stream_ctx + + await transport._handle_post_request(ctx) + + ctx.read_stream_writer.send.assert_called_once() + sent_message = ctx.read_stream_writer.send.call_args[0][0] + assert isinstance(sent_message.message, JSONRPCError) + assert sent_message.message.error.code == -32600 From b6f7fb2ce2b57ac75c55d1880f24739896817839 Mon Sep 17 00:00:00 2001 From: zsxh1990 <445655361@qq.com> Date: Tue, 14 Jul 2026 20:44:03 +0800 Subject: [PATCH 2/7] fix: correct import path for SessionMessage - SessionMessage is in mcp.shared.message, not mcp_types - Format with ruff --- tests/client/test_non2xx_status.py | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/tests/client/test_non2xx_status.py b/tests/client/test_non2xx_status.py index 9f29271f16..a6cecb12af 100644 --- a/tests/client/test_non2xx_status.py +++ b/tests/client/test_non2xx_status.py @@ -5,13 +5,15 @@ Closes #3091 """ + import json import pytest import httpx from unittest.mock import AsyncMock, MagicMock, patch from mcp.client.streamable_http import StreamableHTTPTransport, RequestContext -from mcp_types import JSONRPCRequest, JSONRPCError, ErrorData, SessionMessage +from mcp.shared.message import SessionMessage +from mcp_types import JSONRPCRequest, JSONRPCError, ErrorData class TestNon2xxStatusHandling: @@ -122,11 +124,9 @@ async def test_json_error_body_is_parsed(self): ctx = self._make_request_context() - error_body = json.dumps({ - "jsonrpc": "2.0", - "id": "test-123", - "error": {"code": -32600, "message": "Invalid Request"} - }).encode() + error_body = json.dumps( + {"jsonrpc": "2.0", "id": "test-123", "error": {"code": -32600, "message": "Invalid Request"}} + ).encode() mock_response = MagicMock() mock_response.status_code = 400 From 233f36884ee0e02aa67a9ab7f987f0d0db10a546 Mon Sep 17 00:00:00 2001 From: zsxh1990 <445655361@qq.com> Date: Tue, 14 Jul 2026 20:53:39 +0800 Subject: [PATCH 3/7] fix: use proper StreamableHTTPTransport initialization in tests Use StreamableHTTPTransport(url) instead of __new__() to avoid AttributeError on uninitialized attributes. --- tests/client/test_non2xx_status.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/tests/client/test_non2xx_status.py b/tests/client/test_non2xx_status.py index a6cecb12af..846b02a66c 100644 --- a/tests/client/test_non2xx_status.py +++ b/tests/client/test_non2xx_status.py @@ -30,14 +30,14 @@ def _make_request_context(self, request_id: str = "test-123") -> MagicMock: ctx.metadata = None return ctx + def _make_transport(self) -> StreamableHTTPTransport: + """Create a StreamableHTTPTransport for testing.""" + return StreamableHTTPTransport("http://test/mcp") + @pytest.mark.anyio async def test_401_produces_error_response(self): """401 Unauthorized should produce a JSONRPCError with the request's ID.""" - # This test verifies the fix for #3091 - # When server returns 401, caller should get a proper error, not a timeout - transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) - transport.url = "http://test" - transport.session_id = None + transport = self._make_transport() ctx = self._make_request_context() @@ -67,9 +67,7 @@ async def test_401_produces_error_response(self): @pytest.mark.anyio async def test_403_produces_error_response(self): """403 Forbidden should produce a JSONRPCError with the request's ID.""" - transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) - transport.url = "http://test" - transport.session_id = None + transport = self._make_transport() ctx = self._make_request_context() @@ -93,9 +91,7 @@ async def test_403_produces_error_response(self): @pytest.mark.anyio async def test_500_produces_error_response(self): """500 Internal Server Error should produce a JSONRPCError.""" - transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) - transport.url = "http://test" - transport.session_id = None + transport = self._make_transport() ctx = self._make_request_context() @@ -118,9 +114,7 @@ async def test_500_produces_error_response(self): @pytest.mark.anyio async def test_json_error_body_is_parsed(self): """When server returns JSON-RPC error body, it should be used directly.""" - transport = StreamableHTTPTransport.__new__(StreamableHTTPTransport) - transport.url = "http://test" - transport.session_id = None + transport = self._make_transport() ctx = self._make_request_context() From 887ed851325228a2a52c0409c81d8c8823560db0 Mon Sep 17 00:00:00 2001 From: zsxh1990 <445655361@qq.com> Date: Tue, 14 Jul 2026 20:59:52 +0800 Subject: [PATCH 4/7] fix: ruff lint fixes for test file --- tests/client/test_non2xx_status.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/tests/client/test_non2xx_status.py b/tests/client/test_non2xx_status.py index 846b02a66c..2908d86e2b 100644 --- a/tests/client/test_non2xx_status.py +++ b/tests/client/test_non2xx_status.py @@ -7,13 +7,13 @@ """ import json +from unittest.mock import AsyncMock, MagicMock + import pytest -import httpx -from unittest.mock import AsyncMock, MagicMock, patch +from mcp_types import JSONRPCError, JSONRPCRequest -from mcp.client.streamable_http import StreamableHTTPTransport, RequestContext +from mcp.client.streamable_http import RequestContext, StreamableHTTPTransport from mcp.shared.message import SessionMessage -from mcp_types import JSONRPCRequest, JSONRPCError, ErrorData class TestNon2xxStatusHandling: From 2d0befb068263e012538c5e008ff3a57188b610b Mon Sep 17 00:00:00 2001 From: zsxh1990 <445655361@qq.com> Date: Wed, 15 Jul 2026 09:11:12 +0800 Subject: [PATCH 5/7] fix: use MagicMock for async context manager in tests --- tests/client/test_non2xx_status.py | 19 ++++++------------- 1 file changed, 6 insertions(+), 13 deletions(-) diff --git a/tests/client/test_non2xx_status.py b/tests/client/test_non2xx_status.py index 2908d86e2b..690296fa30 100644 --- a/tests/client/test_non2xx_status.py +++ b/tests/client/test_non2xx_status.py @@ -38,37 +38,32 @@ def _make_transport(self) -> StreamableHTTPTransport: async def test_401_produces_error_response(self): """401 Unauthorized should produce a JSONRPCError with the request's ID.""" transport = self._make_transport() - ctx = self._make_request_context() - # Mock the response to return 401 mock_response = MagicMock() mock_response.status_code = 401 mock_response.headers = {"content-type": "text/plain"} mock_response.aread = AsyncMock(return_value=b"Unauthorized") - # Mock the context manager for stream() - mock_stream_ctx = AsyncMock() + # Use async context manager mock + mock_stream_ctx = MagicMock() mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) ctx.client.stream.return_value = mock_stream_ctx - # Call _handle_post_request await transport._handle_post_request(ctx) - # Verify that an error was sent to the read_stream_writer ctx.read_stream_writer.send.assert_called_once() sent_message = ctx.read_stream_writer.send.call_args[0][0] assert isinstance(sent_message, SessionMessage) assert isinstance(sent_message.message, JSONRPCError) assert sent_message.message.id == "test-123" - assert sent_message.message.error.code == -32603 # INTERNAL_ERROR + assert sent_message.message.error.code == -32603 @pytest.mark.anyio async def test_403_produces_error_response(self): """403 Forbidden should produce a JSONRPCError with the request's ID.""" transport = self._make_transport() - ctx = self._make_request_context() mock_response = MagicMock() @@ -76,7 +71,7 @@ async def test_403_produces_error_response(self): mock_response.headers = {"content-type": "text/plain"} mock_response.aread = AsyncMock(return_value=b"Forbidden") - mock_stream_ctx = AsyncMock() + mock_stream_ctx = MagicMock() mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) ctx.client.stream.return_value = mock_stream_ctx @@ -92,7 +87,6 @@ async def test_403_produces_error_response(self): async def test_500_produces_error_response(self): """500 Internal Server Error should produce a JSONRPCError.""" transport = self._make_transport() - ctx = self._make_request_context() mock_response = MagicMock() @@ -100,7 +94,7 @@ async def test_500_produces_error_response(self): mock_response.headers = {"content-type": "text/plain"} mock_response.aread = AsyncMock(return_value=b"Internal Server Error") - mock_stream_ctx = AsyncMock() + mock_stream_ctx = MagicMock() mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) ctx.client.stream.return_value = mock_stream_ctx @@ -115,7 +109,6 @@ async def test_500_produces_error_response(self): async def test_json_error_body_is_parsed(self): """When server returns JSON-RPC error body, it should be used directly.""" transport = self._make_transport() - ctx = self._make_request_context() error_body = json.dumps( @@ -127,7 +120,7 @@ async def test_json_error_body_is_parsed(self): mock_response.headers = {"content-type": "application/json"} mock_response.aread = AsyncMock(return_value=error_body) - mock_stream_ctx = AsyncMock() + mock_stream_ctx = MagicMock() mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) ctx.client.stream.return_value = mock_stream_ctx From ce68b2570b44779f0c4af31dbf6682b9523040f9 Mon Sep 17 00:00:00 2001 From: zsxh1990 <445655361@qq.com> Date: Wed, 15 Jul 2026 09:15:49 +0800 Subject: [PATCH 6/7] fix: use MagicMock with async __aenter__/__aexit__ for context manager --- tests/client/test_non2xx_status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_non2xx_status.py b/tests/client/test_non2xx_status.py index 690296fa30..27a4f75f35 100644 --- a/tests/client/test_non2xx_status.py +++ b/tests/client/test_non2xx_status.py @@ -45,7 +45,7 @@ async def test_401_produces_error_response(self): mock_response.headers = {"content-type": "text/plain"} mock_response.aread = AsyncMock(return_value=b"Unauthorized") - # Use async context manager mock + # Use MagicMock with async __aenter__/__aexit__ mock_stream_ctx = MagicMock() mock_stream_ctx.__aenter__ = AsyncMock(return_value=mock_response) mock_stream_ctx.__aexit__ = AsyncMock(return_value=False) From 8f5df9690429ab3928604b9f55b3586532f69cad Mon Sep 17 00:00:00 2001 From: zsxh1990 <445655361@qq.com> Date: Wed, 15 Jul 2026 09:21:39 +0800 Subject: [PATCH 7/7] fix: use MagicMock for client to support async context manager ctx.client needs to be MagicMock (not AsyncMock) because stream() returns an async context manager, not a coroutine. --- tests/client/test_non2xx_status.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/client/test_non2xx_status.py b/tests/client/test_non2xx_status.py index 27a4f75f35..f3c22613f1 100644 --- a/tests/client/test_non2xx_status.py +++ b/tests/client/test_non2xx_status.py @@ -26,7 +26,7 @@ def _make_request_context(self, request_id: str = "test-123") -> MagicMock: ctx.session_message.message = MagicMock(spec=JSONRPCRequest) ctx.session_message.message.id = request_id ctx.read_stream_writer = AsyncMock() - ctx.client = AsyncMock() + ctx.client = MagicMock() # Use MagicMock for client (not AsyncMock) ctx.metadata = None return ctx