From 5f63104d9fa4c6bfda53d2c79e9f3311c7e4d9b8 Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:46:08 +0000 Subject: [PATCH 1/3] 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. --- src/mcp/server/stdio.py | 2 +- tests/server/test_stdio.py | 35 +++++++++++++++++++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index bcb9247ab..512175180 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -44,7 +44,7 @@ async def stdio_server( # python is platform-dependent (Windows is particularly problematic), so we # re-wrap the underlying binary stream to ensure UTF-8. if not stdin: - stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8")) + stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")) if not stdout: stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 13cdde3d6..0abb12f1b 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -1,4 +1,6 @@ import io +import sys +from io import TextIOWrapper import anyio import pytest @@ -59,3 +61,36 @@ async def test_stdio_server(): assert len(received_responses) == 2 assert received_responses[0] == JSONRPCMessage(root=JSONRPCRequest(jsonrpc="2.0", id=3, method="ping")) assert received_responses[1] == JSONRPCMessage(root=JSONRPCResponse(jsonrpc="2.0", id=4, result={})) + + +@pytest.mark.anyio +async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch): + """Non-UTF-8 bytes on stdin must not crash the server. + + Invalid bytes are replaced with U+FFFD, which then fails JSON parsing and + is delivered as an in-stream exception. Subsequent valid messages must + still be processed. + """ + # \xff\xfe are invalid UTF-8 start bytes. + valid = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + raw_stdin = io.BytesIO(b"\xff\xfe\n" + valid.model_dump_json(by_alias=True, exclude_none=True).encode() + b"\n") + + # Replace sys.stdin with a wrapper whose .buffer is our raw bytes, so that + # stdio_server()'s default path wraps it with errors='replace'. + monkeypatch.setattr(sys, "stdin", TextIOWrapper(raw_stdin, encoding="utf-8")) + monkeypatch.setattr(sys, "stdout", TextIOWrapper(io.BytesIO(), encoding="utf-8")) + + with anyio.fail_after(5): + async with stdio_server() as (read_stream, write_stream): + await write_stream.aclose() + async with read_stream: + it = read_stream.__aiter__() + + # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream + first = await it.__anext__() + assert isinstance(first, Exception) + + # Second line: valid message still comes through + second = await it.__anext__() + assert isinstance(second, SessionMessage) + assert second.message == JSONRPCMessage(root=valid) From 6139dffd3633d133802dc06475689cb83116ecec Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:06:18 +0000 Subject: [PATCH 2/3] chore: remove stale no-cover pragma now hit by invalid-UTF-8 test --- src/mcp/server/stdio.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 512175180..ec029f135 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -63,7 +63,7 @@ async def stdin_reader(): async for line in stdin: try: message = types.JSONRPCMessage.model_validate_json(line) - except Exception as exc: # pragma: no cover + except Exception as exc: await read_stream_writer.send(exc) continue From 85ef45a7dacc7acafaca76aa04357c997f02b19f Mon Sep 17 00:00:00 2001 From: Max Isbey <224885523+maxisbey@users.noreply.github.com> Date: Tue, 17 Mar 2026 17:18:07 +0000 Subject: [PATCH 3/3] test: use receive() and no-branch pragma for nested async with coverage.py misreports the ->exit arc on nested async with on Python 3.14 Windows. Also simplify from __aiter__/__anext__ to receive(). --- tests/server/test_stdio.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 0abb12f1b..79467e3f1 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -83,14 +83,12 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch): with anyio.fail_after(5): async with stdio_server() as (read_stream, write_stream): await write_stream.aclose() - async with read_stream: - it = read_stream.__aiter__() - + async with read_stream: # pragma: no branch # First line: \xff\xfe -> U+FFFD U+FFFD -> JSON parse fails -> exception in stream - first = await it.__anext__() + first = await read_stream.receive() assert isinstance(first, Exception) # Second line: valid message still comes through - second = await it.__anext__() + second = await read_stream.receive() assert isinstance(second, SessionMessage) assert second.message == JSONRPCMessage(root=valid)