Skip to content

Commit 416f501

Browse files
committed
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.
1 parent cf110e3 commit 416f501

2 files changed

Lines changed: 32 additions & 2 deletions

File tree

src/mcp/client/stdio.py

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,8 +106,13 @@ class StdioServerParameters(BaseModel):
106106
encoding: str = "utf-8"
107107
"""Text encoding for messages to and from the server."""
108108

109-
encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
110-
"""Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers."""
109+
encoding_error_handler: Literal["strict", "ignore", "replace"] = "replace"
110+
"""Encoding error handler; see https://docs.python.org/3/library/codecs.html#error-handlers.
111+
112+
Defaults to ``"replace"`` so malformed bytes from a buggy server become U+FFFD and surface
113+
as an in-stream JSON parse error (kept alive for subsequent valid messages) instead of
114+
crashing the transport task group. This mirrors the server-side stdin hardening.
115+
"""
111116

112117

113118
@asynccontextmanager

tests/client/test_stdio.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -307,6 +307,31 @@ async def test_invalid_json_from_the_server_surfaces_as_an_in_stream_exception(
307307
assert await _next_message(read_stream) == ping
308308

309309

310+
@pytest.mark.anyio
311+
async def test_invalid_utf8_mid_session_surfaces_as_an_in_stream_exception(
312+
monkeypatch: pytest.MonkeyPatch,
313+
) -> None:
314+
"""A line with non-UTF-8 bytes is surfaced as a parse error, not a transport crash.
315+
316+
Regression test for a buggy server emitting malformed bytes mid-session: the default
317+
``encoding_error_handler="replace"`` turns the bad bytes into U+FFFD so the line fails
318+
JSON validation and is delivered as an Exception, keeping the transport alive for the
319+
valid messages that follow (instead of a UnicodeDecodeError tearing down the task group).
320+
"""
321+
ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
322+
process = FakeProcess(on_stdin_close=lambda: process.exit(0))
323+
324+
install_fake_process(monkeypatch, process)
325+
326+
with anyio.fail_after(5):
327+
async with stdio_client(FAKE_PARAMS) as (read_stream, _):
328+
await process.feed(b"\xff\xfe\n" + _line(ping))
329+
330+
error = await read_stream.receive()
331+
assert isinstance(error, ValueError)
332+
assert await _next_message(read_stream) == ping
333+
334+
310335
@pytest.mark.anyio
311336
async def test_a_server_that_dies_before_responding_fails_initialize_with_connection_closed(
312337
monkeypatch: pytest.MonkeyPatch,

0 commit comments

Comments
 (0)