From 4a3cbe8898d342d232c0e782a7a64fbbc9b896c0 Mon Sep 17 00:00:00 2001 From: Pat Date: Sun, 19 Jul 2026 16:53:40 -0500 Subject: [PATCH] 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 --- src/mcp/server/stdio.py | 5 ++++- tests/server/test_stdio.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 876d256dd..781263aa2 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -41,7 +41,10 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. if not stdin: 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")) + # newline="" disables newline translation: without it, writing "\n" on + # Windows emits "\r\n", which breaks the newline-delimited JSON framing. + # stdin keeps universal-newline reading so CRLF from clients is tolerated. + stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8", newline="")) read_stream_writer, read_stream = create_context_streams[SessionMessage | Exception](0) write_stream, write_stream_reader = create_context_streams[SessionMessage](0) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 218e34d5a..56a0fb245 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -105,6 +105,37 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch) -> Non assert second.message == valid +@pytest.mark.anyio +async def test_stdio_server_default_stdout_writes_bare_lf(monkeypatch: pytest.MonkeyPatch) -> None: + """The default-wrapped stdout frames messages with bare "\\n", never "\\r\\n". + + Regression lock for issue #2433: without newline="" the stdout wrapper + translates "\\n" to os.linesep on write, so Windows emitted "\\r\\n" and + corrupted the newline-delimited JSON framing. Asserted on the raw bytes so + the platform's newline translation, not the parsed result, is under test. + """ + + class _NonClosingBytesIO(io.BytesIO): + """Survives the wrapper's close-on-gc so the bytes stay inspectable.""" + + def close(self) -> None: + pass + + raw_stdout = _NonClosingBytesIO() + monkeypatch.setattr(sys, "stdin", TextIOWrapper(io.BytesIO(), encoding="utf-8")) + monkeypatch.setattr(sys, "stdout", TextIOWrapper(raw_stdout, encoding="utf-8")) + + with anyio.fail_after(5): + async with stdio_server() as (read_stream, write_stream): + await write_stream.send(SessionMessage(JSONRPCResponse(jsonrpc="2.0", id=1, result={}))) + await write_stream.aclose() + await read_stream.aclose() + + data = raw_stdout.getvalue() + assert data.endswith(b"}\n"), f"expected a bare-LF-terminated frame, got {data!r}" + assert b"\r" not in data, f"CR byte in stdout frame (Windows newline translation): {data!r}" + + class _GatedStdin(io.RawIOBase): """Raw stdin double: serves its frames, then blocks until released before EOF.