diff --git a/pyproject.toml b/pyproject.toml index c6119867ef..fc2cc70576 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -24,7 +24,7 @@ classifiers = [ dependencies = [ "anyio>=4.5", "httpx>=0.27.1", - "httpx-sse>=0.4", + "httpx-sse>=0.4.2", "pydantic>=2.11.0,<3.0.0", "starlette>=0.27", "python-multipart>=0.0.9", diff --git a/tests/issues/test_1356_sse_parsing_line_separator.py b/tests/issues/test_1356_sse_parsing_line_separator.py new file mode 100644 index 0000000000..5e87d17ac4 --- /dev/null +++ b/tests/issues/test_1356_sse_parsing_line_separator.py @@ -0,0 +1,164 @@ +"""Test for issue #1356: SSE parsing fails with Unicode line separator characters.""" + +import multiprocessing +import socket +import time +from collections.abc import Generator +from typing import Any + +import anyio +import pytest +import uvicorn +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import Response +from starlette.routing import Mount, Route + +from mcp.client.session import ClientSession +from mcp.client.sse import sse_client +from mcp.server import Server +from mcp.server.sse import SseServerTransport +from mcp.server.transport_security import TransportSecuritySettings +from mcp.shared.exceptions import McpError +from mcp.types import TextContent, Tool + +pytestmark = pytest.mark.anyio + + +class ProblematicUnicodeServer(Server): + """Test server that returns problematic Unicode characters.""" + + def __init__(self): + super().__init__("ProblematicUnicodeServer") + + @self.list_tools() + async def handle_list_tools() -> list[Tool]: + return [ + Tool( + name="get_problematic_unicode", + description="Returns text with problematic Unicode character U+2028", + inputSchema={"type": "object", "properties": {}}, + ) + ] + + @self.call_tool() + async def handle_call_tool(name: str, args: dict[str, Any]) -> list[TextContent]: + if name == "get_problematic_unicode": + # Return text with U+2028 (LINE SEPARATOR) which can cause JSON parsing issues + # U+2028 is a valid Unicode character but can break JSON parsing in some contexts + problematic_text = "This text contains a line separator\u2028character that may break JSON parsing" + return [TextContent(type="text", text=problematic_text)] + return [TextContent(type="text", text=f"Unknown tool: {name}")] + + +def make_problematic_server_app() -> Starlette: + """Create test Starlette app with SSE transport.""" + security_settings = TransportSecuritySettings( + allowed_hosts=["127.0.0.1:*", "localhost:*"], + allowed_origins=["http://127.0.0.1:*", "http://localhost:*"], + ) + sse = SseServerTransport("/messages/", security_settings=security_settings) + server = ProblematicUnicodeServer() + + async def handle_sse(request: Request) -> Response: + async with sse.connect_sse(request.scope, request.receive, request._send) as streams: + await server.run(streams[0], streams[1], server.create_initialization_options()) + return Response() + + app = Starlette( + routes=[ + Route("/sse", endpoint=handle_sse), + Mount("/messages/", app=sse.handle_post_message), + ] + ) + + return app + + +def run_problematic_server(server_port: int) -> None: + """Run the problematic Unicode test server.""" + app = make_problematic_server_app() + server = uvicorn.Server(config=uvicorn.Config(app=app, host="127.0.0.1", port=server_port, log_level="error")) + server.run() + + +@pytest.fixture +def problematic_server_port() -> int: + """Get an available port for the test server.""" + with socket.socket() as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +@pytest.fixture +def problematic_server(problematic_server_port: int) -> Generator[str, None, None]: + """Start the problematic Unicode test server in a separate process.""" + proc = multiprocessing.Process( + target=run_problematic_server, kwargs={"server_port": problematic_server_port}, daemon=True + ) + proc.start() + + # Wait for server to be running + max_attempts = 20 + attempt = 0 + while attempt < max_attempts: + try: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.connect(("127.0.0.1", problematic_server_port)) + break + except ConnectionRefusedError: + time.sleep(0.1) + attempt += 1 + else: + raise RuntimeError(f"Server failed to start after {max_attempts} attempts") + + yield f"http://127.0.0.1:{problematic_server_port}" + + # Clean up + proc.kill() + proc.join(timeout=2) + + +async def test_json_parsing_with_problematic_unicode(problematic_server: str) -> None: + """Test that special Unicode characters like U+2028 are handled properly. + + This test reproduces issue #1356 where special Unicode characters + cause JSON parsing to fail and the raw exception is sent to the stream, + preventing proper error handling. + """ + # Connect to the server using SSE client + async with sse_client(problematic_server + "/sse") as streams: + async with ClientSession(*streams) as session: + # Initialize the connection + result = await session.initialize() + assert result.serverInfo.name == "ProblematicUnicodeServer" + + # Call the tool that returns problematic Unicode + # This should succeed and not hang + + # Use a timeout to detect if we're hanging + with anyio.fail_after(5): # 5 second timeout + try: + response = await session.call_tool("get_problematic_unicode", {}) + + # If we get here, the Unicode was handled properly + assert len(response.content) == 1 + text_content = response.content[0] + assert hasattr(text_content, "text"), f"Response doesn't have text: {text_content}" + + # Type narrowing for pyright + from mcp.types import TextContent + + assert isinstance(text_content, TextContent) + + expected = "This text contains a line separator\u2028character that may break JSON parsing" + assert text_content.text == expected, f"Expected: {expected!r}, Got: {text_content.text!r}" + + except McpError: + pytest.fail("Unexpected error with tool call") + except TimeoutError: + # If we timeout, the issue is confirmed - the client hangs + pytest.fail("Client hangs when handling problematic Unicode (issue #1356 confirmed)") + except Exception as e: + # We should not get raw exceptions - they should be wrapped as McpError + pytest.fail(f"Got raw exception instead of McpError: {type(e).__name__}: {e}") diff --git a/uv.lock b/uv.lock index 68abdcc4f5..45f46a0f3e 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.10" [manifest] @@ -442,11 +442,11 @@ wheels = [ [[package]] name = "httpx-sse" -version = "0.4.1" +version = "0.4.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/6e/fa/66bd985dd0b7c109a3bcb89272ee0bfb7e2b4d06309ad7b38ff866734b2a/httpx_sse-0.4.1.tar.gz", hash = "sha256:8f44d34414bc7b21bf3602713005c5df4917884f76072479b21f68befa4ea26e", size = 12998, upload-time = "2025-06-24T13:21:05.71Z" } +sdist = { url = "https://files.pythonhosted.org/packages/63/7a/280d644f906f077e4f4a6d327e9b6e5a936624395ad1bf6ee9165a9d9959/httpx_sse-0.4.2.tar.gz", hash = "sha256:5bb6a2771a51e6c7a5f5c645e40b8a5f57d8de708f46cb5f3868043c3c18124e", size = 16000, upload-time = "2025-10-07T08:10:05.219Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/25/0a/6269e3473b09aed2dab8aa1a600c70f31f00ae1349bee30658f7e358a159/httpx_sse-0.4.1-py3-none-any.whl", hash = "sha256:cba42174344c3a5b06f255ce65b350880f962d99ead85e776f23c6618a377a37", size = 8054, upload-time = "2025-06-24T13:21:04.772Z" }, + { url = "https://files.pythonhosted.org/packages/4f/e5/ec31165492ecc52426370b9005e0637d6da02f9579283298affcb1ab614d/httpx_sse-0.4.2-py3-none-any.whl", hash = "sha256:a9fa4afacb293fa50ef9bacb6cae8287ba5fd1f4b1c2d10a35bb981c41da31ab", size = 9018, upload-time = "2025-10-07T08:10:04.257Z" }, ] [[package]] @@ -654,7 +654,7 @@ docs = [ requires-dist = [ { name = "anyio", specifier = ">=4.5" }, { name = "httpx", specifier = ">=0.27.1" }, - { name = "httpx-sse", specifier = ">=0.4" }, + { name = "httpx-sse", specifier = ">=0.4.2" }, { name = "jsonschema", specifier = ">=4.20.0" }, { name = "pydantic", specifier = ">=2.11.0,<3.0.0" }, { name = "pydantic-settings", specifier = ">=2.5.2" },