From 416f501303280d59c844dfe2f2a157ca625e0761 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Mon, 15 Jun 2026 02:07:59 -0400 Subject: [PATCH] fix(client): tolerate invalid UTF-8 from server stdout in stdio_client Closes #2454. stdio_client decoded child stdout with encoding_error_handler="strict", so a server emitting malformed bytes mid-session raised UnicodeDecodeError inside the decode loop, escaping both except clauses and tearing down the transport task group (surfacing as an ExceptionGroup out of the context manager) instead of surfacing the bad line as an in-stream parse error. Default encoding_error_handler to "replace" so invalid bytes become U+FFFD; the malformed line then fails JSON validation and is delivered as an Exception via _parse_line, keeping the transport alive for subsequent valid messages. This mirrors the server-side stdin hardening (errors="replace") referenced in the issue (#2302). Verified: repro that crashed the task group on main now surfaces a parse error then reads the following valid message; new regression test fails (5s task-group hang) without the fix, passes with it. ruff + pyright clean, 238 client/stdio tests pass. --- src/mcp/client/stdio.py | 9 +++++++-- tests/client/test_stdio.py | 25 +++++++++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index baf7ad1ca..f5005bde2 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -106,8 +106,13 @@ class StdioServerParameters(BaseModel): encoding: str = "utf-8" """Text encoding for messages to and from the server.""" - encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict" - """Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers.""" + encoding_error_handler: Literal["strict", "ignore", "replace"] = "replace" + """Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers. + + Defaults to ``"replace"`` so malformed bytes from a buggy server become U+FFFD and surface + as an in-stream JSON parse error (kept alive for subsequent valid messages) instead of + crashing the transport task group. This mirrors the server-side stdin hardening. + """ @asynccontextmanager diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index f3cb88dc9..ff4913c26 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -307,6 +307,31 @@ async def test_invalid_json_from_the_server_surfaces_as_an_in_stream_exception( assert await _next_message(read_stream) == ping +@pytest.mark.anyio +async def test_invalid_utf8_mid_session_surfaces_as_an_in_stream_exception( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A line with non-UTF-8 bytes is surfaced as a parse error, not a transport crash. + + Regression test for a buggy server emitting malformed bytes mid-session: the default + ``encoding_error_handler="replace"`` turns the bad bytes into U+FFFD so the line fails + JSON validation and is delivered as an Exception, keeping the transport alive for the + valid messages that follow (instead of a UnicodeDecodeError tearing down the task group). + """ + ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + process = FakeProcess(on_stdin_close=lambda: process.exit(0)) + + install_fake_process(monkeypatch, process) + + with anyio.fail_after(5): + async with stdio_client(FAKE_PARAMS) as (read_stream, _): + await process.feed(b"\xff\xfe\n" + _line(ping)) + + error = await read_stream.receive() + assert isinstance(error, ValueError) + assert await _next_message(read_stream) == ping + + @pytest.mark.anyio async def test_a_server_that_dies_before_responding_fails_initialize_with_connection_closed( monkeypatch: pytest.MonkeyPatch,