Skip to content

Commit 64dc77c

Browse files
author
Maanik Garg
committed
fix: use newline=" 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. Add 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 3d7b311 commit 64dc77c

2 files changed

Lines changed: 60 additions & 2 deletions

File tree

src/mcp/server/stdio.py

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,18 @@ 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(
47+
TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace", newline="")
48+
)
4349
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
50+
stdout = anyio.wrap_file(
51+
TextIOWrapper(sys.stdout.buffer, encoding="utf-8", newline="")
52+
)
4553

4654
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
4755
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
@@ -92,3 +92,53 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch):
9292
second = await read_stream.receive()
9393
assert isinstance(second, SessionMessage)
9494
assert second.message == valid
95+
96+
97+
@pytest.mark.anyio
98+
async def test_stdio_server_no_crlf_on_windows(monkeypatch: pytest.MonkeyPatch):
99+
"""Verify stdout uses bare LF (\\n) line endings, not CRLF (\\r\\n).
100+
101+
The MCP protocol uses newline-delimited JSON with \\n as the delimiter.
102+
On Windows, TextIOWrapper without newline="" translates \\n -> \\r\\n,
103+
corrupting the wire format. This test ensures the fix is effective on
104+
all platforms by going through the default sys.stdout.buffer path.
105+
"""
106+
107+
class NonClosingBytesIO(io.BytesIO):
108+
"""BytesIO subclass that ignores close() so we can inspect data after
109+
the owning TextIOWrapper is closed."""
110+
111+
def close(self) -> None:
112+
pass # Keep the buffer open for inspection
113+
114+
raw_stdin_buf = io.BytesIO(b"")
115+
raw_stdout_buf = NonClosingBytesIO()
116+
117+
# Create a fake sys.stdin / sys.stdout that expose .buffer attributes
118+
# pointing to our BytesIO objects. This exercises the real code path in
119+
# stdio_server() which accesses sys.stdin.buffer / sys.stdout.buffer.
120+
fake_stdin = TextIOWrapper(raw_stdin_buf, encoding="utf-8")
121+
fake_stdout = TextIOWrapper(raw_stdout_buf, encoding="utf-8")
122+
monkeypatch.setattr(sys, "stdin", fake_stdin)
123+
monkeypatch.setattr(sys, "stdout", fake_stdout)
124+
125+
with anyio.fail_after(5):
126+
async with stdio_server() as (read_stream, write_stream):
127+
# Send a message through the server's write stream
128+
response = JSONRPCResponse(jsonrpc="2.0", id=1, result={})
129+
session_message = SessionMessage(response)
130+
await write_stream.send(session_message)
131+
await write_stream.aclose()
132+
# Drain the read stream so the stdin_reader task can exit cleanly
133+
await read_stream.aclose()
134+
135+
# The stdio_server wraps sys.stdout.buffer (= raw_stdout_buf) with its own
136+
# TextIOWrapper(newline=""). After the context manager exits, all data
137+
# should be flushed to raw_stdout_buf.
138+
raw_bytes = raw_stdout_buf.getvalue()
139+
assert raw_bytes, "Expected output bytes but got empty buffer"
140+
# Must end with bare \n, not \r\n
141+
assert raw_bytes.endswith(b"\n"), f"Output must end with LF: {raw_bytes!r}"
142+
assert not raw_bytes.endswith(b"\r\n"), f"Output must NOT contain CRLF: {raw_bytes!r}"
143+
# No \r anywhere in the output
144+
assert b"\r" not in raw_bytes, f"Output contains CR byte: {raw_bytes!r}"

0 commit comments

Comments
 (0)