Skip to content

Commit 78799b9

Browse files
author
Maanik Garg
committed
fix: default encoding_error_handler to replace in stdio_client for UTF-8 resilience
The stdio_client transport previously defaulted to encoding_error_handler= strict, causing the transport to crash when the child process emits invalid UTF-8 bytes. This is asymmetric with the server-side fix in PR #2302, which already uses errors=replace for stdio_server. Changes: - Default StdioServerParameters.encoding_error_handler to replace - Invalid bytes are now substituted with U+FFFD and the resulting line fails JSON validation, surfacing as an in-stream Exception - The transport stays alive for subsequent valid messages - Changed logger.exception to logger.warning for parse failures (avoids noisy tracebacks for expected validation errors) - Removed pragma: no cover from the now-reachable exception handling path Add regression test that spawns a child emitting invalid UTF-8 followed by a valid JSON-RPC message, asserting both are delivered correctly. Fixes #2454
1 parent 3d7b311 commit 78799b9

2 files changed

Lines changed: 55 additions & 3 deletions

File tree

src/mcp/client/stdio.py

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -92,10 +92,15 @@ 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
99+
Defaults to "replace" so that malformed bytes from the child process are
100+
substituted with U+FFFD rather than crashing the transport. The invalid
101+
line will then fail JSON validation and be surfaced as an in-stream
102+
exception, keeping the transport alive for subsequent valid messages.
103+
99104
See https://docs.python.org/3/library/codecs.html#codec-base-classes for
100105
explanations of possible values.
101106
"""
@@ -151,8 +156,8 @@ async def stdout_reader():
151156
for line in lines:
152157
try:
153158
message = types.jsonrpc_message_adapter.validate_json(line, by_name=False)
154-
except Exception as exc: # pragma: no cover
155-
logger.exception("Failed to parse JSONRPC message from server")
159+
except Exception as exc:
160+
logger.warning("Failed to parse JSONRPC message from server: %s", exc)
156161
await read_stream_writer.send(exc)
157162
continue
158163

tests/client/test_stdio.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,53 @@ async def test_stdio_client_nonexistent_command():
103103
assert exc_info.value.errno == errno.ENOENT
104104

105105

106+
@pytest.mark.anyio
107+
async def test_stdio_client_invalid_utf8_resilience():
108+
"""Malformed UTF-8 from child stdout must not crash the transport.
109+
110+
With encoding_error_handler="replace" (now the default), invalid bytes
111+
are replaced with U+FFFD. The resulting line fails JSON validation and
112+
is surfaced as an in-stream Exception. Subsequent valid messages must
113+
still be delivered.
114+
"""
115+
# Child script writes one malformed line, then one valid JSON-RPC line
116+
child_script = textwrap.dedent(
117+
"""
118+
import sys
119+
# Write invalid UTF-8 bytes followed by newline
120+
sys.stdout.buffer.write(b"\\xff\\xfe\\n")
121+
sys.stdout.buffer.flush()
122+
# Write a valid JSON-RPC message
123+
sys.stdout.buffer.write(b'{"jsonrpc":"2.0","id":1,"method":"ping"}\\n')
124+
sys.stdout.buffer.flush()
125+
# Exit cleanly
126+
"""
127+
)
128+
129+
server_params = StdioServerParameters(
130+
command=sys.executable,
131+
args=["-c", child_script],
132+
)
133+
134+
async with stdio_client(server_params) as (read_stream, write_stream):
135+
received: list[SessionMessage | Exception] = []
136+
async with read_stream:
137+
async for item in read_stream:
138+
received.append(item)
139+
if len(received) == 2:
140+
break
141+
142+
# First item: the malformed line should surface as a parse exception
143+
assert isinstance(received[0], Exception), (
144+
f"Expected Exception for malformed UTF-8 line, got {type(received[0])}"
145+
)
146+
# Second item: the valid JSON-RPC message should come through
147+
assert isinstance(received[1], SessionMessage), (
148+
f"Expected SessionMessage for valid line, got {type(received[1])}"
149+
)
150+
assert received[1].message == JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
151+
152+
106153
@pytest.mark.anyio
107154
async def test_stdio_client_universal_cleanup():
108155
"""Test that stdio_client completes cleanup within reasonable time

0 commit comments

Comments
 (0)