Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/mcp/server/streamable_http.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
56 changes: 55 additions & 1 deletion tests/server/test_streamable_http_manager.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -19,7 +20,7 @@
RequestBodyLimitMiddleware,
StreamableHTTPSessionManager,
)
from mcp.types import INVALID_REQUEST
from mcp.types import INVALID_REQUEST, PARSE_ERROR


@pytest.mark.anyio
Expand Down Expand Up @@ -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."""
Expand Down
Loading