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,