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 1/2] 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.""" From a9d98481cd2e47005c654b35c0d413d63046c18f Mon Sep 17 00:00:00 2001 From: Tirth <131750711+tirthfx@users.noreply.github.com> Date: Tue, 28 Jul 2026 23:15:35 +0530 Subject: [PATCH 2/2] fix: decode body as UTF-8 explicitly to reject UTF-16/32 JSON json.loads() auto-detects UTF-8/16/32 per RFC 4627 sec. 3, so a POST body that is valid JSON when read as UTF-16 or UTF-32 was parsed and accepted even though the MCP spec requires UTF-8. Decoding the body explicitly before parsing closes that gap; UnicodeDecodeError still subclasses ValueError, so it's caught by the existing parse-error branch and reported as HTTP 400 PARSE_ERROR. Parameterizes the regression test across cp1252, utf-16, and utf-32. Reported-by: iamroylim --- src/mcp/server/streamable_http.py | 6 +++++- tests/server/test_streamable_http_manager.py | 21 +++++++++++++++----- 2 files changed, 21 insertions(+), 6 deletions(-) diff --git a/src/mcp/server/streamable_http.py b/src/mcp/server/streamable_http.py index 63e1269206..0c40f3bef0 100644 --- a/src/mcp/server/streamable_http.py +++ b/src/mcp/server/streamable_http.py @@ -491,7 +491,11 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re body = await request.body() try: - raw_message = json.loads(body) + # Decode explicitly rather than passing bytes to json.loads(): the latter + # auto-detects UTF-8/16/32 (RFC 4627 sec. 3), so a UTF-16/32 body that is + # otherwise valid JSON would parse successfully even though the MCP spec + # requires UTF-8. + raw_message = json.loads(body.decode("utf-8")) 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. diff --git a/tests/server/test_streamable_http_manager.py b/tests/server/test_streamable_http_manager.py index ec78a7cf4b..bd76255fd1 100644 --- a/tests/server/test_streamable_http_manager.py +++ b/tests/server/test_streamable_http_manager.py @@ -443,13 +443,21 @@ async def mock_receive(): assert error_data["error"]["message"] == "Session not found" +@pytest.mark.parametrize( + "encoding", + ["cp1252", "utf-16", "utf-32"], +) @pytest.mark.anyio -async def test_non_utf8_body_returns_parse_error(caplog: pytest.LogCaptureFixture): +async def test_non_utf8_body_returns_parse_error(caplog: pytest.LogCaptureFixture, encoding: str): """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 + For cp1252, json.loads() raises UnicodeDecodeError rather than json.JSONDecodeError, + so it used to escape the parse-error branch and surface as HTTP 500 with an ERROR-level traceback. + + For utf-16/utf-32, json.loads() auto-detects the encoding (per RFC 4627 sec. 3) and + parses the body successfully even though the MCP spec requires UTF-8, so it used to + bypass error handling entirely. """ app = Server("test-non-utf8-body") manager = StreamableHTTPSessionManager(app=app, stateless=True) @@ -474,8 +482,11 @@ async def mock_send(message: Message): ], } - # 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") + # Valid JSON syntax, but encoded as something other than UTF-8. For cp1252 the + # em dash (byte 0x97) makes the body invalid UTF-8; for utf-16/utf-32 the body + # is well-formed under that encoding and would otherwise be auto-detected and + # accepted by json.loads(). + body = '{"jsonrpc": "2.0", "id": 1, "method": "x — y"}'.encode(encoding) async def mock_receive(): return {"type": "http.request", "body": body, "more_body": False}