Skip to content

Commit 0a87fe9

Browse files
committed
fix: handle non-UTF-8 bytes in stdio server stdin
TextIOWrapper defaults to errors='strict', which raises UnicodeDecodeError when stdin contains bytes that are not valid UTF-8. This exception occurs during 'async for line in stdin' iteration, which is outside the JSON-parsing try/except block, so it propagates through the task group and terminates the server process. With errors='replace', invalid bytes are converted to U+FFFD. The resulting line then fails JSON validation, which is already caught and sent into the read stream as an exception for the session layer to handle - the same path as any other malformed input.
1 parent 75a80b6 commit 0a87fe9

File tree

2 files changed

+36
-1
lines changed

2 files changed

+36
-1
lines changed

src/mcp/server/stdio.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
3939
# python is platform-dependent (Windows is particularly problematic), so we
4040
# re-wrap the underlying binary stream to ensure UTF-8.
4141
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8"))
42+
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
4343
if not stdout:
4444
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
4545

tests/server/test_stdio.py

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import io
2+
import sys
3+
from io import TextIOWrapper
24

35
import anyio
46
import pytest
@@ -59,3 +61,36 @@ async def test_stdio_server():
5961
assert len(received_responses) == 2
6062
assert received_responses[0] == JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")
6163
assert received_responses[1] == JSONRPCResponse(jsonrpc="2.0", id=4, result={})
64+
65+
66+
@pytest.mark.anyio
67+
async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
68+
"""Non-UTF-8 bytes on stdin must not crash the server.
69+
70+
Invalid bytes are replaced with U+FFFD, which then fails JSON parsing and
71+
is delivered as an in-stream exception. Subsequent valid messages must
72+
still be processed.
73+
"""
74+
# \xff\xfe are invalid UTF-8 start bytes.
75+
valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping")
76+
raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n")
77+
78+
# Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that
79+
# stdio_server()'s default path wraps it with errors='replace'.
80+
monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8"))
81+
monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
82+
83+
with anyio.fail_after(5):
84+
async with stdio_server() as (read_stream, write_stream):
85+
await write_stream.aclose()
86+
async with read_stream:
87+
it = read_stream.__aiter__()
88+
89+
# First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream
90+
first = await it.__anext__()
91+
assert isinstance(first, Exception)
92+
93+
# Second line: valid message still comes through
94+
second = await it.__anext__()
95+
assert isinstance(second, SessionMessage)
96+
assert second.message == valid

0 commit comments

Comments
 (0)