From ce91e6f0a7731c9e1498f7e6940abef9a0be33a6 Mon Sep 17 00:00:00 2001 From: Tirth <131750711+tirthfx@users.noreply.github.com> Date: Sun, 26 Jul 2026 22:24:49 +0530 Subject: [PATCH] fix: return HTTP 400 PARSE_ERROR for non-UTF-8 POST bodies The Streamable HTTP POST handler parsed the request body with json.loads() under `except json.JSONDecodeError`. When the body bytes are not valid UTF-8, json.loads() raises UnicodeDecodeError from its internal decode step, which is not a JSONDecodeError, so it bypassed the parse-error branch and reached the generic exception handler. The client got an unexplained HTTP 500 INTERNAL_ERROR and the server logged a traceback at ERROR plus a second ERROR record from the forwarded exception, while a merely malformed UTF-8 body got a clean HTTP 400. Widen the catch to ValueError. Both json.JSONDecodeError and UnicodeDecodeError subclass it, so a body that cannot be parsed for either reason is now answered as the client error it is, with no ERROR-level server logging. Fixes #3150 Github-Issue: #3150 Reported-by: Aleksandr Filippov --- src/mcp/server/streamable_http.py | 4 +- tests/server/test_streamable_http_manager.py | 56 +++++++++++++++++++- 2 files changed, 58 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 8e8d902ccc..63e1269206 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -492,7 +492,9 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re try: raw_message = json.loads(body) - except json.JSONDecodeError as e: + except ValueError as e: + # Both json.JSONDecodeError (bad syntax) and UnicodeDecodeError (body bytes + # that are not valid UTF-8) subclass ValueError; both are client errors. response = self._create_error_response(f"Parse error: {str(e)}", HTTPStatus.BAD_REQUEST, PARSE_ERROR) await response(scope, receive, send) return diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index 9deeeeb37a..ec78a7cf4b 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -1,6 +1,7 @@ """Tests for StreamableHTTPSessionManager.""" import json +import logging from collections.abc import Iterator from typing import Any from unittest.mock import AsyncMock, patch @@ -19,7 +20,7 @@ RequestBodyLimitMiddleware, StreamableHTTPSessionManager, ) -from mcp.types import INVALID_REQUEST +from mcp.types import INVALID_REQUEST, PARSE_ERROR @pytest.mark.anyio @@ -442,6 +443,59 @@ async def mock_receive(): assert error_data["error"]["message"] == "Session not found" +@pytest.mark.anyio +async def test_non_utf8_body_returns_parse_error(caplog: pytest.LogCaptureFixture): + """A POST body that is not valid UTF-8 is a client error, not a server error. + + json.loads() raises UnicodeDecodeError rather than json.JSONDecodeError for such a + body, so it used to escape the parse-error branch and surface as HTTP 500 with an + ERROR-level traceback. + """ + app = Server("test-non-utf8-body") + manager = StreamableHTTPSessionManager(app=app, stateless=True) + + async with manager.run(): + sent_messages: list[Message] = [] + response_body = b"" + + async def mock_send(message: Message): + nonlocal response_body + sent_messages.append(message) + if message["type"] == "http.response.body": + response_body += message.get("body", b"") + + scope: Scope = { + "type": "http", + "method": "POST", + "path": "/mcp", + "headers": [ + (b"content-type", b"application/json"), + (b"accept", b"application/json, text/event-stream"), + ], + } + + # Valid JSON syntax, but encoded as Windows-1252: the em dash is byte 0x97. + body = '{"jsonrpc": "2.0", "id": 1, "method": "x — y"}'.encode("cp1252") + + async def mock_receive(): + return {"type": "http.request", "body": body, "more_body": False} + + with caplog.at_level(logging.DEBUG): + await manager.handle_request(scope, mock_receive, mock_send) + + response_start = next((msg for msg in sent_messages if msg["type"] == "http.response.start"), None) + assert response_start is not None, "Should have sent a response" + assert response_start["status"] == 400 + + error_data = json.loads(response_body) + assert error_data["jsonrpc"] == "2.0" + assert error_data["error"]["code"] == PARSE_ERROR + assert error_data["error"]["message"].startswith("Parse error:") + + error_records = [record for record in caplog.records if record.levelno >= logging.ERROR] + assert error_records == [], f"Unparseable body should not log at ERROR: {[r.getMessage() for r in error_records]}" + + @pytest.mark.anyio async def test_idle_session_is_reaped(): """After idle timeout fires, the session returns 404."""