From 029680ce534c0d0f8cc3d59a590a0a4a707e07f3 Mon Sep 17 00:00:00 2001 From: lvdou <494553948@qq.com> Date: Mon, 20 Oct 2025 21:56:01 +0800 Subject: [PATCH 1/7] client(streamable_http): raise JSON-RPC error on unexpected content-type so ClientSession receives McpError; keep fallback exception if no request id; add request id plumbing --- src/mcp/client/streamable_http.py | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/streamable_http.py b/src/mcp/client/streamable_http.py index 57df64705..6d8a0658a 100644 --- a/src/mcp/client/streamable_http.py +++ b/src/mcp/client/streamable_http.py @@ -288,9 +288,11 @@ async def _handle_post_request(self, ctx: RequestContext) -> None: elif content_type.startswith(SSE): await self._handle_sse_response(response, ctx, is_initialization) else: + # Propagate an error bound to the originating request id so callers get McpError await self._handle_unexpected_content_type( content_type, ctx.read_stream_writer, + message.root.id, ) async def _handle_json_response( @@ -343,11 +345,22 @@ async def _handle_unexpected_content_type( self, content_type: str, read_stream_writer: StreamWriter, + request_id: RequestId | None, ) -> None: """Handle unexpected content type in response.""" error_msg = f"Unexpected content type: {content_type}" logger.error(error_msg) - await read_stream_writer.send(ValueError(error_msg)) + if request_id is not None: + jsonrpc_error = JSONRPCError( + jsonrpc="2.0", + id=request_id, + error=ErrorData(code=32600, message=error_msg), + ) + session_message = SessionMessage(JSONRPCMessage(jsonrpc_error)) + await read_stream_writer.send(session_message) + else: + # Fallback: send as exception if we somehow lack a request id + await read_stream_writer.send(ValueError(error_msg)) async def _send_session_terminated_error( self, From e0331e66bd09d7d1f8650299f671d9be7e5ebfe7 Mon Sep 17 00:00:00 2001 From: lvdou <494553948@qq.com> Date: Wed, 22 Oct 2025 19:32:25 +0800 Subject: [PATCH 2/7] test: add test case for unexpected content type raising McpError - Test verifies that when server returns non-MCP content type (HTML), client raises McpError instead of just printing - Handles nested ExceptionGroup structure from task groups - Confirms the fix works end-to-end --- tests/shared/test_streamable_http.py | 72 ++++++++++++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 55800da33..020039f74 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1597,3 +1597,75 @@ async def bad_client(): assert isinstance(result, InitializeResult) tools = await session.list_tools() assert tools.tools + + +@pytest.mark.anyio +async def test_client_unexpected_content_type_raises_mcp_error(): + """Test that unexpected content types raise McpError instead of just printing.""" + # Use a real server that returns HTML to test the actual behavior + from starlette.responses import HTMLResponse + from starlette.routing import Route + + # Create a simple server that returns HTML instead of MCP JSON + async def html_endpoint(request: Request): + return HTMLResponse("Not an MCP server") + + app = Starlette(routes=[ + Route("/mcp", html_endpoint, methods=["GET", "POST"]), + ]) + + # Start server on a random port using a simpler approach + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + port = s.getsockname()[1] + + # Use a thread instead of multiprocessing to avoid pickle issues + import threading + import asyncio + + def run_server(): + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + uvicorn.run(app, host="127.0.0.1", port=port, log_level="error") + + server_thread = threading.Thread(target=run_server, daemon=True) + server_thread.start() + + try: + # Give server time to start + await asyncio.sleep(0.5) + + server_url = f"http://127.0.0.1:{port}" + + # Test that the client raises McpError when server returns HTML + with pytest.raises(ExceptionGroup) as exc_info: + async with streamablehttp_client(f"{server_url}/mcp") as ( + read_stream, + write_stream, + _, + ): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + # Extract the McpError from the ExceptionGroup (handle nested groups) + mcp_error = None + + def find_mcp_error(exc_group): + for exc in exc_group.exceptions: + if isinstance(exc, McpError): + return exc + elif isinstance(exc, ExceptionGroup): + result = find_mcp_error(exc) + if result: + return result + return None + + mcp_error = find_mcp_error(exc_info.value) + + assert mcp_error is not None, f"Expected McpError in ExceptionGroup hierarchy" + assert "Unexpected content type" in str(mcp_error) + assert "text/html" in str(mcp_error) + + finally: + # Server thread will be cleaned up automatically as daemon + pass From 227a8f863d8cb8ca3c25c2307c91f7210eee43d1 Mon Sep 17 00:00:00 2001 From: lvdou <494553948@qq.com> Date: Wed, 22 Oct 2025 19:41:57 +0800 Subject: [PATCH 3/7] fix: resolve linting errors in test case - Sort imports alphabetically (asyncio before threading) - Add proper ExceptionGroup import with fallback for older Python versions - Remove unnecessary f-string prefix - All linting checks now pass --- tests/shared/test_streamable_http.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 020039f74..09cbdafad 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -11,6 +11,11 @@ from collections.abc import Generator from typing import Any +try: + from builtins import ExceptionGroup +except ImportError: + from exceptiongroup import ExceptionGroup + import anyio import httpx import pytest @@ -1620,8 +1625,8 @@ async def html_endpoint(request: Request): port = s.getsockname()[1] # Use a thread instead of multiprocessing to avoid pickle issues - import threading import asyncio + import threading def run_server(): loop = asyncio.new_event_loop() @@ -1662,7 +1667,7 @@ def find_mcp_error(exc_group): mcp_error = find_mcp_error(exc_info.value) - assert mcp_error is not None, f"Expected McpError in ExceptionGroup hierarchy" + assert mcp_error is not None, "Expected McpError in ExceptionGroup hierarchy" assert "Unexpected content type" in str(mcp_error) assert "text/html" in str(mcp_error) From 4f47dc2f375e278bb0a10d2252755c641791c8b0 Mon Sep 17 00:00:00 2001 From: lvdou <494553948@qq.com> Date: Wed, 22 Oct 2025 19:58:39 +0800 Subject: [PATCH 4/7] fix: resolve pyright type checking errors - Add type ignore comments for ExceptionGroup compatibility issues - Add proper type annotations for find_mcp_error function - All pre-commit checks now pass including pyright type checking --- tests/shared/test_streamable_http.py | 42 +++++++++++++++------------- 1 file changed, 22 insertions(+), 20 deletions(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 09cbdafad..4883e0a49 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -14,7 +14,7 @@ try: from builtins import ExceptionGroup except ImportError: - from exceptiongroup import ExceptionGroup + from exceptiongroup import ExceptionGroup # type: ignore import anyio import httpx @@ -1615,35 +1615,37 @@ async def test_client_unexpected_content_type_raises_mcp_error(): async def html_endpoint(request: Request): return HTMLResponse("Not an MCP server") - app = Starlette(routes=[ - Route("/mcp", html_endpoint, methods=["GET", "POST"]), - ]) - + app = Starlette( + routes=[ + Route("/mcp", html_endpoint, methods=["GET", "POST"]), + ] + ) + # Start server on a random port using a simpler approach with socket.socket() as s: s.bind(("127.0.0.1", 0)) port = s.getsockname()[1] - + # Use a thread instead of multiprocessing to avoid pickle issues import asyncio import threading - + def run_server(): loop = asyncio.new_event_loop() asyncio.set_event_loop(loop) uvicorn.run(app, host="127.0.0.1", port=port, log_level="error") - + server_thread = threading.Thread(target=run_server, daemon=True) server_thread.start() - + try: # Give server time to start await asyncio.sleep(0.5) - + server_url = f"http://127.0.0.1:{port}" - + # Test that the client raises McpError when server returns HTML - with pytest.raises(ExceptionGroup) as exc_info: + with pytest.raises(ExceptionGroup) as exc_info: # type: ignore async with streamablehttp_client(f"{server_url}/mcp") as ( read_stream, write_stream, @@ -1651,26 +1653,26 @@ def run_server(): ): async with ClientSession(read_stream, write_stream) as session: await session.initialize() - + # Extract the McpError from the ExceptionGroup (handle nested groups) mcp_error = None - - def find_mcp_error(exc_group): - for exc in exc_group.exceptions: + + def find_mcp_error(exc_group: ExceptionGroup) -> McpError | None: # type: ignore + for exc in exc_group.exceptions: # type: ignore if isinstance(exc, McpError): return exc - elif isinstance(exc, ExceptionGroup): + elif isinstance(exc, ExceptionGroup): # type: ignore result = find_mcp_error(exc) if result: return result return None - + mcp_error = find_mcp_error(exc_info.value) - + assert mcp_error is not None, "Expected McpError in ExceptionGroup hierarchy" assert "Unexpected content type" in str(mcp_error) assert "text/html" in str(mcp_error) - + finally: # Server thread will be cleaned up automatically as daemon pass From 396ec84087e0103fd16ce049c9f4ed60cc8629b7 Mon Sep 17 00:00:00 2001 From: lvdou <494553948@qq.com> Date: Wed, 22 Oct 2025 20:06:54 +0800 Subject: [PATCH 5/7] fix: add type ignore to builtins ExceptionGroup import - CI pyright version updated and now reports ExceptionGroup as unknown import - Add type ignore comment to both import paths for compatibility - Resolves CI type checking errors --- tests/shared/test_streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 4883e0a49..af2c5c80d 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -12,7 +12,7 @@ from typing import Any try: - from builtins import ExceptionGroup + from builtins import ExceptionGroup # type: ignore except ImportError: from exceptiongroup import ExceptionGroup # type: ignore From e166ba7526de57d8b3c9d4cf18b5adbee0ee8532 Mon Sep 17 00:00:00 2001 From: lvdou <494553948@qq.com> Date: Wed, 22 Oct 2025 20:30:34 +0800 Subject: [PATCH 6/7] docs: improve test docstring for unexpected content type test - Add more detailed description of what the test verifies - Clarify that it tests HTML response handling - Trigger CI checks for PR validation --- tests/shared/test_streamable_http.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index af2c5c80d..9f54079f7 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1606,7 +1606,11 @@ async def bad_client(): @pytest.mark.anyio async def test_client_unexpected_content_type_raises_mcp_error(): - """Test that unexpected content types raise McpError instead of just printing.""" + """Test that unexpected content types raise McpError instead of just printing. + + This test verifies that when a server returns HTML instead of MCP JSON, + the client properly raises McpError wrapped in ExceptionGroup. + """ # Use a real server that returns HTML to test the actual behavior from starlette.responses import HTMLResponse from starlette.routing import Route From a4c28096563f996d0694413f517efc2f59472d8e Mon Sep 17 00:00:00 2001 From: lvdou <494553948@qq.com> Date: Wed, 22 Oct 2025 20:33:00 +0800 Subject: [PATCH 7/7] fix: remove trailing whitespace in docstring - Remove trailing whitespace that was causing pre-commit formatting issues - Ensure clean formatting for CI checks --- tests/shared/test_streamable_http.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/shared/test_streamable_http.py b/tests/shared/test_streamable_http.py index 9f54079f7..4bea609dd 100644 --- a/tests/shared/test_streamable_http.py +++ b/tests/shared/test_streamable_http.py @@ -1607,7 +1607,7 @@ async def bad_client(): @pytest.mark.anyio async def test_client_unexpected_content_type_raises_mcp_error(): """Test that unexpected content types raise McpError instead of just printing. - + This test verifies that when a server returns HTML instead of MCP JSON, the client properly raises McpError wrapped in ExceptionGroup. """