Skip to content

[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

Description

@alex-feel

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_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.

  1. 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 ValueErrorjson.JSONDecodeError and UnicodeDecodeError are both ValueError subclasses, so the parse-error branch then covers both failure shapes with no new import.
  2. 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).

Example Code

"""Repro: non-UTF-8 POST body gets HTTP 500 INTERNAL_ERROR instead of HTTP 400 PARSE_ERROR.

mcp 1.28.1, stateless Streamable HTTP transport.
Run: python repro.py   (server logs go to stderr, request results to stdout)
"""

import contextlib
import logging
import socket
import threading
import time

import httpx
import uvicorn
from mcp.server.lowlevel import Server
from mcp.server.streamable_http_manager import StreamableHTTPSessionManager
from starlette.applications import Starlette
from starlette.routing import Mount

logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s %(message)s")

mcp_server = Server("repro-server")
session_manager = StreamableHTTPSessionManager(app=mcp_server, stateless=True)


@contextlib.asynccontextmanager
async def lifespan(app: Starlette):
    async with session_manager.run():
        yield


starlette_app = Starlette(
    routes=[Mount("/mcp", app=session_manager.handle_request)],
    lifespan=lifespan,
)


def free_port() -> int:
    with socket.socket() as s:
        s.bind(("127.0.0.1", 0))
        return s.getsockname()[1]


def main() -> None:
    port = free_port()
    server = uvicorn.Server(uvicorn.Config(starlette_app, host="127.0.0.1", port=port, log_level="warning"))
    thread = threading.Thread(target=server.run, daemon=True)
    thread.start()
    while not server.started:
        time.sleep(0.05)

    url = f"http://127.0.0.1:{port}/mcp/"
    headers = {
        "Accept": "application/json, text/event-stream",
        "Content-Type": "application/json",
    }

    control = httpx.post(url, content=b"{not json", headers=headers)
    print(f"CONTROL (malformed but UTF-8 JSON): HTTP {control.status_code}")
    print(f"CONTROL body: {control.text}")

    bug_body = '{"jsonrpc": "2.0", "id": 1, "method": "x — y"}'.encode("cp1252")
    bug = httpx.post(url, content=bug_body, headers=headers)
    print(f"BUG (same JSON, Windows-1252 encoded, em dash = byte 0x97): HTTP {bug.status_code}")
    print(f"BUG body: {bug.text}")

    time.sleep(0.5)  # let any deferred server-side logging flush
    server.should_exit = True
    thread.join(timeout=5)


if __name__ == "__main__":
    main()

Steps to reproduce:

  1. uv venv --python 3.12 && uv pip install "mcp==1.28.1" uvicorn httpx
  2. python repro.py — CONTROL answers 400/-32700, BUG answers 500/-32603 with the two ERROR records above.
  3. 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.

Python & MCP Python SDK

Python 3.12.0 (Windows 11)
mcp==1.28.1 (latest stable; venv created with uv; mcp, uvicorn, and httpx installed explicitly, everything else resolved transitively)

Counter-check venv: identical except mcp==2.0.0b2 (installed with --prerelease=allow).

pip freeze (v1.28.1 venv):
annotated-types==0.7.0
anyio==4.14.2
attrs==26.1.0
certifi==2026.7.22
cffi==2.1.0
click==8.4.2
colorama==0.4.6
cryptography==49.0.0
h11==0.16.0
httpcore==1.0.9
httpx==0.28.1
httpx-sse==0.4.3
idna==3.18
jsonschema==4.26.0
jsonschema-specifications==2025.9.1
mcp==1.28.1
pycparser==3.0
pydantic==2.13.4
pydantic-core==2.46.4
pydantic-settings==2.14.2
pyjwt==2.13.0
python-dotenv==1.2.2
python-multipart==0.0.32
pywin32==312
referencing==0.37.0
rpds-py==2026.6.3
sse-starlette==3.4.6
starlette==1.3.1
typing-extensions==4.16.0
typing-inspection==0.4.2
uvicorn==0.51.0

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions