Skip to content

Commit 5a16bf0

Browse files
committed
fix: disable newline translation in stdio TextIOWrapper to prevent CRLF on Windows
On Windows, TextIOWrapper without newline="" performs universal newline translation (\n -> \r\n on write), corrupting the newline-delimited JSON wire format used by MCP's stdio transport. Pass newline="" to both the stdin and stdout TextIOWrapper calls in stdio_server() to disable translation on all platforms. Add a regression test that inspects the raw bytes written to the output buffer and asserts no \r bytes are present. Fixes #2433
1 parent 3a6f299 commit 5a16bf0

2 files changed

Lines changed: 56 additions & 2 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,14 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
3838
# standard process handles. Encoding of stdin/stdout as text streams on
3939
# python is platform-dependent (Windows is particularly problematic), so we
4040
# re-wrap the underlying binary stream to ensure UTF-8.
41+
#
42+
# newline="" disables universal newline translation, which is critical on
43+
# Windows: without it, TextIOWrapper translates \n -> \r\n on write and
44+
# \r\n -> \n on read, corrupting the newline-delimited JSON wire format.
4145
if not stdin:
42-
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
46+
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace", newline=""))
4347
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
48+
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8", newline=""))
4549

4650
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
4751
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

tests/server/test_stdio.py

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,3 +274,53 @@ def test_mcpserver_run_stdio_serves_a_modern_connection(monkeypatch: pytest.Monk
274274
# request was served at the discovered version, not the handshake era.
275275
assert responses[1].result["tools"] == []
276276
assert responses[1].result["resultType"] == "complete"
277+
278+
279+
@pytest.mark.anyio
280+
async def test_stdio_server_no_crlf_on_windows(monkeypatch: pytest.MonkeyPatch) -> None:
281+
"""Verify stdout uses bare LF (\\n) line endings, not CRLF (\\r\\n).
282+
283+
The MCP protocol uses newline-delimited JSON with \\n as the delimiter.
284+
On Windows, TextIOWrapper without newline="" translates \\n -> \\r\\n,
285+
corrupting the wire format. This test ensures the fix is effective on
286+
all platforms by going through the default sys.stdout.buffer path.
287+
"""
288+
289+
class NonClosingBytesIO(io.BytesIO):
290+
"""BytesIO subclass that ignores close() so we can inspect data after
291+
the owning TextIOWrapper is closed."""
292+
293+
def close(self) -> None:
294+
pass # Keep the buffer open for inspection
295+
296+
raw_stdin_buf = io.BytesIO(b"")
297+
raw_stdout_buf = NonClosingBytesIO()
298+
299+
# Create a fake sys.stdin / sys.stdout that expose .buffer attributes
300+
# pointing to our BytesIO objects. This exercises the real code path in
301+
# stdio_server() which accesses sys.stdin.buffer / sys.stdout.buffer.
302+
fake_stdin = TextIOWrapper(raw_stdin_buf, encoding="utf-8")
303+
fake_stdout = TextIOWrapper(raw_stdout_buf, encoding="utf-8")
304+
monkeypatch.setattr(sys, "stdin", fake_stdin)
305+
monkeypatch.setattr(sys, "stdout", fake_stdout)
306+
307+
with anyio.fail_after(5):
308+
async with stdio_server() as (read_stream, write_stream):
309+
# Send a message through the server's write stream
310+
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
311+
session_message = SessionMessage(response)
312+
await write_stream.send(session_message)
313+
await write_stream.aclose()
314+
# Drain the read stream so the stdin_reader task can exit cleanly
315+
await read_stream.aclose()
316+
317+
# The stdio_server wraps sys.stdout.buffer (= raw_stdout_buf) with its own
318+
# TextIOWrapper(newline=""). After the context manager exits, all data
319+
# should be flushed to raw_stdout_buf.
320+
raw_bytes = raw_stdout_buf.getvalue()
321+
assert raw_bytes, "Expected output bytes but got empty buffer"
322+
# Must end with bare \n, not \r\n
323+
assert raw_bytes.endswith(b"\n"), f"Output must end with LF: {raw_bytes!r}"
324+
assert not raw_bytes.endswith(b"\r\n"), f"Output must NOT contain CRLF: {raw_bytes!r}"
325+
# No \r anywhere in the output
326+
assert b"\r" not in raw_bytes, f"Output contains CR byte: {raw_bytes!r}"

0 commit comments

Comments
 (0)