Skip to content

Commit 4a3cbe8

Browse files
bmdhodlclaude
andcommitted
fix: stop newline translation corrupting stdio framing on Windows
stdio_server() wrapped sys.stdout.buffer in a TextIOWrapper with default newline handling, which translates "\n" to os.linesep on write. On Windows every outgoing frame was terminated with "\r\n", breaking the newline-delimited JSON framing required by the spec. Fix: newline="" on the stdout wrapper only. Outgoing frames must be spec-exact; stdin keeps universal-newline reading so CRLF from clients stays tolerated. The regression test asserts on the raw bytes written to stdout, so the platform's newline translation itself is under test: it fails on main on Windows (b'...}\r\n') and passes with the fix. Runs on the windows-latest CI cell. Fixes #2433 Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 3a6f299 commit 4a3cbe8

2 files changed

Lines changed: 35 additions & 1 deletion

File tree

src/mcp/server/stdio.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,10 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio.
4141
if not stdin:
4242
stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace"))
4343
if not stdout:
44-
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8"))
44+
# newline="" disables newline translation: without it, writing "\n" on
45+
# Windows emits "\r\n", which breaks the newline-delimited JSON framing.
46+
# stdin keeps universal-newline reading so CRLF from clients is tolerated.
47+
stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8", newline=""))
4548

4649
read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0)
4750
write_stream, write_stream_reader = create_context_streams[SessionMessage](0)

tests/server/test_stdio.py

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,37 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non
105105
assert second.message == valid
106106

107107

108+
@pytest.mark.anyio
109+
async def test_stdio_server_default_stdout_writes_bare_lf(monkeypatch: pytest.MonkeyPatch) -> None:
110+
"""The default-wrapped stdout frames messages with bare "\\n", never "\\r\\n".
111+
112+
Regression lock for issue #2433: without newline="" the stdout wrapper
113+
translates "\\n" to os.linesep on write, so Windows emitted "\\r\\n" and
114+
corrupted the newline-delimited JSON framing. Asserted on the raw bytes so
115+
the platform's newline translation, not the parsed result, is under test.
116+
"""
117+
118+
class _NonClosingBytesIO(io.BytesIO):
119+
"""Survives the wrapper's close-on-gc so the bytes stay inspectable."""
120+
121+
def close(self) -> None:
122+
pass
123+
124+
raw_stdout = _NonClosingBytesIO()
125+
monkeypatch.setattr(sys, "stdin", TextIOWrapper(io.BytesIO(), encoding="utf-8"))
126+
monkeypatch.setattr(sys, "stdout", TextIOWrapper(raw_stdout, encoding="utf-8"))
127+
128+
with anyio.fail_after(5):
129+
async with stdio_server() as (read_stream, write_stream):
130+
await write_stream.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={})))
131+
await write_stream.aclose()
132+
await read_stream.aclose()
133+
134+
data = raw_stdout.getvalue()
135+
assert data.endswith(b"}\n"), f"expected a bare-LF-terminated frame, got {data!r}"
136+
assert b"\r" not in data, f"CR byte in stdout frame (Windows newline translation): {data!r}"
137+
138+
108139
class _GatedStdin(io.RawIOBase):
109140
"""Raw stdin double: serves its frames, then blocks until released before EOF.
110141

0 commit comments

Comments
 (0)