You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
[v1.x] Backport #1912 and cut a release: a non-UTF-8 POST body on Streamable HTTP gets HTTP 500 INTERNAL_ERROR plus an ERROR cascade instead of the HTTP 400 PARSE_ERROR; the parse broadening ships only on main and the 2.0.0 pre-releases #3150
Note on the first box: we are on v1.28.1, the latest stable release; the only later versions are the 2.0.0 pre-releases (2.0.0a1 through 2.0.0b2).
Description
What this asks for. Please backport the POST-body parse change from PR #1912 ("refactor: replace pydantic_core.from_json by json.loads", merge commit 3dc8b72, merged into main on 2026-01-19) to the v1.x branch and cut a v1.x patch release. This is filed on the bug template because the tracker has no backport template and blank issues are disabled. The PR was motivated by performance, but the part that matters here is behavioral: it replaced raw_message = json.loads(body) under except json.JSONDecodeError with raw_message = pydantic_core.from_json(body) under except ValueError, which routes a request body in a non-UTF-8 encoding into the clean HTTP 400 PARSE_ERROR response instead of letting it escape as an unhandled exception. This is the same backport-and-release shape as #3142 (which asks the same for #2257).
What we're seeing (reproduced deterministically on v1.28.1 with the example below)._handle_post_request in src/mcp/server/streamable_http.py parses the POST body with json.loads(body) catching only json.JSONDecodeError (lines 494-495, identical in the v1.28.1 release and at the current v1.x branch tip). When the body bytes are not valid UTF-8, json.loads raises UnicodeDecodeError from its internal decode step — a ValueError that is NOT a json.JSONDecodeError — so it bypasses the parse-error branch and lands in the generic except Exception as err handler at line 649. The client receives an unexplained HTTP 500 INTERNAL_ERROR, the server logs Error handling POST request at ERROR with a full traceback (line 650), and the handler forwards Exception(err) into the session's incoming stream (line 657), which produces a second ERROR record from mcp.server.lowlevel.server. A syntactically broken but UTF-8 body gets the friendly HTTP 400 PARSE_ERROR with a precise message and zero ERROR records.
Client-side results of the example below on v1.28.1 (both requests POST to the same stateless Streamable HTTP server; the two bodies differ only as described):
CONTROL (malformed but UTF-8 JSON): HTTP 400
CONTROL body: {"jsonrpc":"2.0","id":"server-error","error":{"code":-32700,"message":"Parse error: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)"}}
BUG (same JSON, Windows-1252 encoded, em dash = byte 0x97): HTTP 500
BUG body: {"jsonrpc":"2.0","id":"server-error","error":{"code":-32603,"message":"Error handling POST request"}}
Server-side log for the BUG request (logging.basicConfig(level=logging.DEBUG); client-side httpx/httpcore lines elided; local path prefixes shortened to <venv> / <python>):
DEBUG mcp.server.streamable_http_manager Stateless mode: Creating new transport for this request
ERROR mcp.server.streamable_http Error handling POST request
Traceback (most recent call last):
File "<venv>\Lib\site-packages\mcp\server\streamable_http.py", line 494, in _handle_post_request
raw_message = json.loads(body)
^^^^^^^^^^^^^^^^
File "<python>\Lib\json\__init__.py", line 341, in loads
s = s.decode(detect_encoding(s), 'surrogatepass')
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
UnicodeDecodeError: 'utf-8' codec can't decode byte 0x97 in position 41: invalid start byte
INFO mcp.server.streamable_http Terminating session: None
DEBUG mcp.server.lowlevel.server Received message: 'utf-8' codec can't decode byte 0x97 in position 41: invalid start byte
ERROR mcp.server.lowlevel.server Received exception from stream: 'utf-8' codec can't decode byte 0x97 in position 41: invalid start byte
Signature counts per bad-encoding request on bare v1.28.1: Error handling POST request = 1, Received exception from stream = 1, Stateless session crashed = 0 — the third record is absent for the cancellation-timing reason detailed in #3142, and per that analysis the same forwarded Exception(err) reaches the case Exception():send_log_message path in lowlevel/server.py whenever Server.run is overridden, so this defect also feeds the crash class that #3142 asks to close. The CONTROL request logs zero ERROR records. The repro uses stateless=True as the smallest harness, but the narrow catch sits in StreamableHTTPServerTransport._handle_post_request, which both stateless and stateful modes share.
Why a non-UTF-8 body is a realistic input, not an adversarial one. The typical shape is Windows-1252 punctuation — an em dash (0x97) or curly quotes (0x93/0x94) — inside a string value of otherwise perfectly valid JSON, produced by a client that serializes with a legacy default codepage. The JSON is syntactically fine; only the encoding is off. Today the syntactically broken body gets the actionable 400 while the arguably more understandable encoding mistake gets an unexplained 500 plus a server-side ERROR cascade that looks like a server defect to operators.
The fix already exists on main and behaves as expected (verified by run). Running the byte-identical example script against mcp 2.0.0b2 (which contains #1912's merge commit) answers both requests with HTTP 400 PARSE_ERROR and logs zero ERROR records:
CONTROL (malformed but UTF-8 JSON): HTTP 400
CONTROL body: {"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error: key must be a string at line 1 column 2"}}
BUG (same JSON, Windows-1252 encoded, em dash = byte 0x97): HTTP 400
BUG body: {"jsonrpc":"2.0","id":null,"error":{"code":-32700,"message":"Parse error: invalid unicode code point at line 1 column 43"}}
Release containment (checked via the GitHub compare API). Comparing the release tags against merge commit 3dc8b72: v2.0.0a1...3dc8b72 and v2.0.0b2...3dc8b72 are "behind" with ahead_by: 0 (contained in the 2.0.0 pre-releases), while v1.27.0...3dc8b72 and v1.28.1...3dc8b72 are "diverged" (absent). The v1.x branch tip's streamable_http.py is byte-identical to the v1.28.1 copy, still parsing with the narrow catch at lines 494-495. No stable release contains the broadening, and none of the recent release notes mention #1912.
What we would expect to see. A request body the server cannot parse — for either reason, bad syntax or bad encoding — is a client error: HTTP 400 with the JSON-RPC PARSE_ERROR (-32700) body, no ERROR-level server logging, and no Exception forwarded into the session stream. Exactly what current main and the 2.0.0 pre-releases already do.
The ask.
Backport refactor: replace pydantic_core.from_json by json.loads #1912 (cherry-pick of 3dc8b72) to v1.x — small and non-breaking: the functional diff replaces two lines plus an import swap, matching the CONTRIBUTING.md branch-strategy table, which routes bug fixes for v1 (its example: non-breaking fixes) to the v1.x branch. If swapping the parser on the stable branch is unwanted, the minimal alternative with the same behavioral effect is to keep json.loads(body) and widen the catch to except ValueError — json.JSONDecodeError and UnicodeDecodeError are both ValueError subclasses, so the parse-error branch then covers both failure shapes with no new import.
Cut a v1.x patch release containing it, since the README states v1.x "is the only stable release line and remains recommended for production" and "continues to receive critical bug fixes and security patches".
Related issues and PRs found while searching (why this is not a duplicate re-report): #1912 (the fix, main only, motivated as a performance refactor — this issue is about its behavioral side); the stdio-transport siblings of the same defect class, none of which covers the Streamable HTTP POST path — #2454 / #2873 (client side: malformed UTF-8 from server stdout), #2302 (stdio server stdin), #2755 (stdio parse failures answered with a JSON-RPC parse error); #820 (closed; an older Streamable HTTP report where a malformed tools/call payload broke the task group and made the server answer 500 until restart - a different mechanism, listed as the closest streamable precedent); #3142 (the open v1.x backport-and-release request for #2257 — the downstream half of the same ERROR cascade this issue's 500 path feeds).
python repro.py — CONTROL answers 400/-32700, BUG answers 500/-32603 with the two ERROR records above.
Counter-check in a second venv: uv pip install --prerelease=allow "mcp==2.0.0b2" uvicorn httpx, then python repro.py unchanged — both requests answer 400/-32700, zero ERROR records.
Initial Checks
Note on the first box: we are on v1.28.1, the latest stable release; the only later versions are the 2.0.0 pre-releases (2.0.0a1 through 2.0.0b2).
Description
What this asks for. Please backport the POST-body parse change from PR #1912 ("refactor: replace
pydantic_core.from_jsonbyjson.loads", merge commit 3dc8b72, merged intomainon 2026-01-19) to thev1.xbranch and cut a v1.x patch release. This is filed on the bug template because the tracker has no backport template and blank issues are disabled. The PR was motivated by performance, but the part that matters here is behavioral: it replacedraw_message = json.loads(body)underexcept json.JSONDecodeErrorwithraw_message = pydantic_core.from_json(body)underexcept ValueError, which routes a request body in a non-UTF-8 encoding into the clean HTTP 400PARSE_ERRORresponse instead of letting it escape as an unhandled exception. This is the same backport-and-release shape as #3142 (which asks the same for #2257).What we're seeing (reproduced deterministically on v1.28.1 with the example below).
_handle_post_requestinsrc/mcp/server/streamable_http.pyparses the POST body withjson.loads(body)catching onlyjson.JSONDecodeError(lines 494-495, identical in the v1.28.1 release and at the currentv1.xbranch tip). When the body bytes are not valid UTF-8,json.loadsraisesUnicodeDecodeErrorfrom its internal decode step — aValueErrorthat is NOT ajson.JSONDecodeError— so it bypasses the parse-error branch and lands in the genericexcept Exception as errhandler at line 649. The client receives an unexplained HTTP 500INTERNAL_ERROR, the server logsError handling POST requestat ERROR with a full traceback (line 650), and the handler forwardsException(err)into the session's incoming stream (line 657), which produces a second ERROR record frommcp.server.lowlevel.server. A syntactically broken but UTF-8 body gets the friendly HTTP 400PARSE_ERRORwith a precise message and zero ERROR records.Client-side results of the example below on v1.28.1 (both requests POST to the same stateless Streamable HTTP server; the two bodies differ only as described):
Server-side log for the BUG request (
logging.basicConfig(level=logging.DEBUG); client-side httpx/httpcore lines elided; local path prefixes shortened to<venv>/<python>):Signature counts per bad-encoding request on bare v1.28.1:
Error handling POST request= 1,Received exception from stream= 1,Stateless session crashed= 0 — the third record is absent for the cancellation-timing reason detailed in #3142, and per that analysis the same forwardedException(err)reaches thecase Exception():send_log_messagepath inlowlevel/server.pywheneverServer.runis overridden, so this defect also feeds the crash class that #3142 asks to close. The CONTROL request logs zero ERROR records. The repro usesstateless=Trueas the smallest harness, but the narrow catch sits inStreamableHTTPServerTransport._handle_post_request, which both stateless and stateful modes share.Why a non-UTF-8 body is a realistic input, not an adversarial one. The typical shape is Windows-1252 punctuation — an em dash (0x97) or curly quotes (0x93/0x94) — inside a string value of otherwise perfectly valid JSON, produced by a client that serializes with a legacy default codepage. The JSON is syntactically fine; only the encoding is off. Today the syntactically broken body gets the actionable 400 while the arguably more understandable encoding mistake gets an unexplained 500 plus a server-side ERROR cascade that looks like a server defect to operators.
The fix already exists on
mainand behaves as expected (verified by run). Running the byte-identical example script against mcp 2.0.0b2 (which contains #1912's merge commit) answers both requests with HTTP 400PARSE_ERRORand logs zero ERROR records:Release containment (checked via the GitHub compare API). Comparing the release tags against merge commit
3dc8b72:v2.0.0a1...3dc8b72andv2.0.0b2...3dc8b72are "behind" withahead_by: 0(contained in the 2.0.0 pre-releases), whilev1.27.0...3dc8b72andv1.28.1...3dc8b72are "diverged" (absent). Thev1.xbranch tip'sstreamable_http.pyis byte-identical to the v1.28.1 copy, still parsing with the narrow catch at lines 494-495. No stable release contains the broadening, and none of the recent release notes mention #1912.What we would expect to see. A request body the server cannot parse — for either reason, bad syntax or bad encoding — is a client error: HTTP 400 with the JSON-RPC
PARSE_ERROR(-32700) body, no ERROR-level server logging, and noExceptionforwarded into the session stream. Exactly what currentmainand the 2.0.0 pre-releases already do.The ask.
pydantic_core.from_jsonbyjson.loads#1912 (cherry-pick of3dc8b72) tov1.x— small and non-breaking: the functional diff replaces two lines plus an import swap, matching the CONTRIBUTING.md branch-strategy table, which routes bug fixes for v1 (its example: non-breaking fixes) to thev1.xbranch. If swapping the parser on the stable branch is unwanted, the minimal alternative with the same behavioral effect is to keepjson.loads(body)and widen the catch toexcept ValueError—json.JSONDecodeErrorandUnicodeDecodeErrorare bothValueErrorsubclasses, so the parse-error branch then covers both failure shapes with no new import.Related issues and PRs found while searching (why this is not a duplicate re-report): #1912 (the fix,
mainonly, motivated as a performance refactor — this issue is about its behavioral side); the stdio-transport siblings of the same defect class, none of which covers the Streamable HTTP POST path — #2454 / #2873 (client side: malformed UTF-8 from server stdout), #2302 (stdio server stdin), #2755 (stdio parse failures answered with a JSON-RPC parse error); #820 (closed; an older Streamable HTTP report where a malformedtools/callpayload broke the task group and made the server answer 500 until restart - a different mechanism, listed as the closest streamable precedent); #3142 (the open v1.x backport-and-release request for #2257 — the downstream half of the same ERROR cascade this issue's 500 path feeds).Example Code
Steps to reproduce:
uv venv --python 3.12 && uv pip install "mcp==1.28.1" uvicorn httpxpython repro.py— CONTROL answers 400/-32700, BUG answers 500/-32603with the two ERROR records above.uv pip install --prerelease=allow "mcp==2.0.0b2" uvicorn httpx, thenpython repro.pyunchanged — both requests answer 400/-32700, zero ERROR records.Python & MCP Python SDK