Skip to content

Commit f474aa7

Browse files
committed
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
1 parent 3d7b311 commit f474aa7

File tree

2 files changed

+40
-3
lines changed

2 files changed

+40
-3
lines changed

src/mcp/client/stdio.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,7 @@ class StdioServerParameters(BaseModel):
9292
Defaults to utf-8.
9393
"""
9494

95-
encoding_error_handler: Literal["strict", "ignore", "replace"] = "strict"
95+
encoding_error_handler: Literal["strict", "ignore", "replace"] = "replace"
9696
"""
9797
The text encoding error handler.
9898
@@ -158,7 +158,7 @@ async def stdout_reader():
158158

159159
session_message = SessionMessage(message)
160160
await read_stream_writer.send(session_message)
161-
except anyio.ClosedResourceError: # pragma: lax no cover
161+
except (anyio.ClosedResourceError, anyio.BrokenResourceError, ConnectionResetError): # pragma: lax no cover
162162
await anyio.lowlevel.checkpoint()
163163

164164
async def stdin_writer():
@@ -174,7 +174,7 @@ async def stdin_writer():
174174
errors=server.encoding_error_handler,
175175
)
176176
)
177-
except anyio.ClosedResourceError: # pragma: no cover
177+
except (anyio.ClosedResourceError, anyio.BrokenResourceError, ConnectionResetError): # pragma: no cover
178178
await anyio.lowlevel.checkpoint()
179179

180180
async with anyio.create_task_group() as tg, process:

tests/client/test_stdio.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import textwrap
55
import time
66
from contextlib import AsyncExitStack, suppress
7+
from pathlib import Path
78

89
import anyio
910
import anyio.abc
@@ -70,6 +71,42 @@ async def test_stdio_client():
7071
assert read_messages[1] == JSONRPCResponse(jsonrpc="2.0", id=2, result={})
7172

7273

74+
@pytest.mark.anyio
75+
async def test_stdio_client_invalid_utf8_from_server_does_not_crash(tmp_path: Path):
76+
"""A buggy child server should surface malformed UTF-8 as an in-stream error.
77+
78+
The client should continue reading subsequent valid JSON-RPC lines instead of
79+
crashing the whole transport task group during decoding.
80+
"""
81+
script = tmp_path / "bad_stdout_server.py"
82+
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
83+
script.write_text(
84+
textwrap.dedent(
85+
f"""\
86+
import sys
87+
import time
88+
89+
sys.stdout.buffer.write(b"\\xff\\xfe\\n")
90+
sys.stdout.buffer.write({valid.model_dump_json(by_alias=True, exclude_none=True)!r}.encode() + b"\\n")
91+
sys.stdout.buffer.flush()
92+
time.sleep(0.2)
93+
"""
94+
)
95+
)
96+
97+
server_params = StdioServerParameters(command=sys.executable, args=[str(script)])
98+
99+
with anyio.fail_after(5):
100+
async with stdio_client(server_params) as (read_stream, write_stream):
101+
await write_stream.aclose()
102+
first = await read_stream.receive()
103+
assert isinstance(first, Exception)
104+
105+
second = await read_stream.receive()
106+
assert isinstance(second, SessionMessage)
107+
assert second.message == valid
108+
109+
73110
@pytest.mark.anyio
74111
async def test_stdio_client_bad_path():
75112
"""Check that the connection doesn't hang if process errors."""

0 commit comments

Comments
 (0)