Skip to content

Commit a9d9848

Browse files
committed
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
1 parent ce91e6f commit a9d9848

2 files changed

Lines changed: 21 additions & 6 deletions

File tree

src/mcp/server/streamable_http.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,11 @@ async def _handle_post_request(self, scope: Scope, request: Request, receive: Re
491491
body = await request.body()
492492

493493
try:
494-
raw_message = json.loads(body)
494+
# Decode explicitly rather than passing bytes to json.loads(): the latter
495+
# auto-detects UTF-8/16/32 (RFC 4627 sec. 3), so a UTF-16/32 body that is
496+
# otherwise valid JSON would parse successfully even though the MCP spec
497+
# requires UTF-8.
498+
raw_message = json.loads(body.decode("utf-8"))
495499
except ValueError as e:
496500
# Both json.JSONDecodeError (bad syntax) and UnicodeDecodeError (body bytes
497501
# that are not valid UTF-8) subclass ValueError; both are client errors.

tests/server/test_streamable_http_manager.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -443,13 +443,21 @@ async def mock_receive():
443443
assert error_data["error"]["message"] == "Session not found"
444444

445445

446+
@pytest.mark.parametrize(
447+
"encoding",
448+
["cp1252", "utf-16", "utf-32"],
449+
)
446450
@pytest.mark.anyio
447-
async def test_non_utf8_body_returns_parse_error(caplog: pytest.LogCaptureFixture):
451+
async def test_non_utf8_body_returns_parse_error(caplog: pytest.LogCaptureFixture, encoding: str):
448452
"""A POST body that is not valid UTF-8 is a client error, not a server error.
449453
450-
json.loads() raises UnicodeDecodeError rather than json.JSONDecodeError for such a
451-
body, so it used to escape the parse-error branch and surface as HTTP 500 with an
454+
For cp1252, json.loads() raises UnicodeDecodeError rather than json.JSONDecodeError,
455+
so it used to escape the parse-error branch and surface as HTTP 500 with an
452456
ERROR-level traceback.
457+
458+
For utf-16/utf-32, json.loads() auto-detects the encoding (per RFC 4627 sec. 3) and
459+
parses the body successfully even though the MCP spec requires UTF-8, so it used to
460+
bypass error handling entirely.
453461
"""
454462
app = Server("test-non-utf8-body")
455463
manager = StreamableHTTPSessionManager(app=app, stateless=True)
@@ -474,8 +482,11 @@ async def mock_send(message: Message):
474482
],
475483
}
476484

477-
# Valid JSON syntax, but encoded as Windows-1252: the em dash is byte 0x97.
478-
body = '{"jsonrpc": "2.0", "id": 1, "method": "x — y"}'.encode("cp1252")
485+
# Valid JSON syntax, but encoded as something other than UTF-8. For cp1252 the
486+
# em dash (byte 0x97) makes the body invalid UTF-8; for utf-16/utf-32 the body
487+
# is well-formed under that encoding and would otherwise be auto-detected and
488+
# accepted by json.loads().
489+
body = '{"jsonrpc": "2.0", "id": 1, "method": "x — y"}'.encode(encoding)
479490

480491
async def mock_receive():
481492
return {"type": "http.request", "body": body, "more_body": False}

0 commit comments

Comments
 (0)