From f474aa7e52ad2a081f7accd657b8cf2873233b8c Mon Sep 17 00:00:00 2001 From: JunghwanNA <70629228+shaun0927@users.noreply.github.com> Date: Fri, 17 Apr 2026 00:38:58 +0900 Subject: [PATCH 1/2] Prevent malformed child stdout from crashing stdio_client The stdio server path already degrades invalid UTF-8 into parse errors instead of killing the transport, but the client side still defaulted to strict decoding and could crash the whole task group on a single bad line from a child process. This changes the default decode strategy to replacement, broadens shutdown-time exception handling for abrupt child exits, and adds a regression test that proves malformed output becomes an in-stream error while the next valid JSON-RPC message still arrives. Constraint: Must preserve stdio_client cleanup behavior when subprocesses exit early or reset pipes Rejected: Add a new configuration flag only for the regression test | leaves the default transport behavior asymmetric and crash-prone Confidence: high Scope-risk: narrow Reversibility: clean Directive: Keep client and server stdio malformed-UTF-8 handling symmetric unless the protocol deliberately diverges Tested: uv run --frozen pytest tests/client/test_stdio.py; uv run --frozen ruff check src/mcp/client/stdio.py tests/client/test_stdio.py; uv run --frozen pyright src/mcp/client/stdio.py Not-tested: Full multi-platform matrix outside local macOS / Python 3.10 run --- src/mcp/client/stdio.py | 6 +++--- tests/client/test_stdio.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 902dc8576..576451c02 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -92,7 +92,7 @@ class StdioServerParameters(BaseModel): Defaults to utf-8. """ - encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict" + encoding_error_handler: Literal["strict", "ignore", "replace"] = "replace" """ The text encoding error handler. @@ -158,7 +158,7 @@ async def stdout_reader(): session_message = SessionMessage(message) await read_stream_writer.send(session_message) - except anyio.ClosedResourceError: # pragma: lax no cover + except (anyio.ClosedResourceError, anyio.BrokenResourceError, ConnectionResetError): # pragma: lax no cover await anyio.lowlevel.checkpoint() async def stdin_writer(): @@ -174,7 +174,7 @@ async def stdin_writer(): errors=server.encoding_error_handler, ) ) - except anyio.ClosedResourceError: # pragma: no cover + except (anyio.ClosedResourceError, anyio.BrokenResourceError, ConnectionResetError): # pragma: no cover await anyio.lowlevel.checkpoint() async with anyio.create_task_group() as tg, process: diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 06e2cba4b..3a6fe8b8e 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -4,6 +4,7 @@ import textwrap import time from contextlib import AsyncExitStack, suppress +from pathlib import Path import anyio import anyio.abc @@ -70,6 +71,42 @@ async def test_stdio_client(): assert read_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={}) +@pytest.mark.anyio +async def test_stdio_client_invalid_utf8_from_server_does_not_crash(tmp_path: Path): + """A buggy child server should surface malformed UTF-8 as an in-stream error. + + The client should continue reading subsequent valid JSON-RPC lines instead of + crashing the whole transport task group during decoding. + """ + script = tmp_path / "bad_stdout_server.py" + valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + script.write_text( + textwrap.dedent( + f"""\ + import sys + import time + + sys.stdout.buffer.write(b"\\xff\\xfe\\n") + sys.stdout.buffer.write({valid.model_dump_json(by_alias=True, exclude_none=True)!r}.encode() + b"\\n") + sys.stdout.buffer.flush() + time.sleep(0.2) + """ + ) + ) + + server_params = StdioServerParameters(command=sys.executable, args=[str(script)]) + + with anyio.fail_after(5): + async with stdio_client(server_params) as (read_stream, write_stream): + await write_stream.aclose() + first = await read_stream.receive() + assert isinstance(first, Exception) + + second = await read_stream.receive() + assert isinstance(second, SessionMessage) + assert second.message == valid + + @pytest.mark.anyio async def test_stdio_client_bad_path(): """Check that the connection doesn't hang if process errors.""" From 91ae0ad4c79f97f8ef0c7ceb3ea201c7c6c0cd72 Mon Sep 17 00:00:00 2001 From: JunghwanNA <70629228+shaun0927@users.noreply.github.com> Date: Fri, 17 Apr 2026 01:00:23 +0900 Subject: [PATCH 2/2] Keep stdio_client strict-no-cover in sync with exercised behavior The malformed-UTF8 regression test now executes the JSON-parse failure branch in stdio_client, so the old pragma no longer reflects reality and breaks CI's strict-no-cover gate. This follow-up removes the stale pragma without changing behavior. Constraint: Must not widen the PR scope beyond the already-proposed stdio robustness fix Rejected: Suppress strict-no-cover or weaken the regression test | hides real coverage drift instead of correcting it Confidence: high Scope-risk: narrow Reversibility: clean Directive: When adding malformed-input regression tests, re-audit nearby no-cover pragmas immediately Tested: coverage run of tests/client/test_stdio.py plus strict-no-cover locally up to the updated branch execution path Not-tested: Full upstream CI rerun after push --- src/mcp/client/stdio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 576451c02..4ba892e9f 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -151,7 +151,7 @@ async def stdout_reader(): for line in lines: try: message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) - except Exception as exc: # pragma: no cover + except Exception as exc: logger.exception("Failed to parse JSONRPC message from server") await read_stream_writer.send(exc) continue