Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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)
Comment thread
tirthfx marked this conversation as resolved.
Outdated
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