From 7591a78a553a50084e4aa02daf64f5f1444597aa Mon Sep 17 00:00:00 2001 From: EvanYao826 <2869018789@qq.com> Date: Mon, 11 May 2026 20:37:25 +0800 Subject: [PATCH 1/3] fix: set newline='' on TextIOWrapper in stdio_server to prevent Windows CRLF corruption On Windows, TextIOWrapper defaults to platform-native line endings (\r\n), which corrupts newline-delimited JSON-RPC messages over stdio. Add newline='' to both the stdin and stdout TextIOWrapper constructors in stdio_server(), matching the pattern from PR #2302. Fixes #2433 --- src/mcp/server/stdio.py | 4 ++-- tests/server/test_stdio.py | 31 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/src/mcp/server/stdio.py b/src/mcp/server/stdio.py index 5c1459dff..b66c0a522 100644 --- a/src/mcp/server/stdio.py +++ b/src/mcp/server/stdio.py @@ -39,9 +39,9 @@ async def stdio_server(stdin: anyio.AsyncFile[str] | None = None, stdout: anyio. # 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", errors="replace")) + stdin = anyio.wrap_file(TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace", newline="")) if not stdout: - stdout = anyio.wrap_file(TextIOWrapper(sys.stdout.buffer, encoding="utf-8")) + 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 677a99356..704ea8e4c 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -1,5 +1,6 @@ import io import sys +import unittest.mock from io import TextIOWrapper import anyio @@ -92,3 +93,33 @@ async def test_stdio_server_invalid_utf8(monkeypatch: pytest.MonkeyPatch): second = await read_stream.receive() assert isinstance(second, SessionMessage) assert second.message == valid + + +@pytest.mark.anyio +async def test_stdio_server_default_textiowrapper_newline(monkeypatch: pytest.MonkeyPatch): + """Default TextIOWrapper must use newline='' to prevent CRLF corruption on Windows. + + On Windows, TextIOWrapper defaults to platform-native line endings (\r\n), + which corrupts newline-delimited JSON-RPC messages over stdio. Verify that + stdio_server() passes newline='' when creating the wrappers. + """ + mock_stdin = io.BytesIO() + mock_stdout = io.BytesIO() + monkeypatch.setattr(sys, "stdin", type("S", (), {"buffer": mock_stdin})()) + monkeypatch.setattr(sys, "stdout", type("S", (), {"buffer": mock_stdout})()) + + created_kwargs: list[dict] = [] + real_textiowrapper = TextIOWrapper + + def capturing_textiowrapper(*args: object, **kwargs: object) -> TextIOWrapper: + created_kwargs.append(kwargs) + return real_textiowrapper(*args, **kwargs) + + with unittest.mock.patch("mcp.server.stdio.TextIOWrapper", side_effect=capturing_textiowrapper): + async with stdio_server() as (read_stream, write_stream): + await write_stream.aclose() + await read_stream.aclose() + + assert len(created_kwargs) == 2 + assert created_kwargs[0].get("newline") == "", "stdin TextIOWrapper must set newline=''" + assert created_kwargs[1].get("newline") == "", "stdout TextIOWrapper must set newline=''" From b8829215d3a12c0aec4cd7a8d0d12257c3274ca4 Mon Sep 17 00:00:00 2001 From: EvanYao826 <2869018789@qq.com> Date: Mon, 11 May 2026 21:28:01 +0800 Subject: [PATCH 2/3] fix: type annotations for test capturing_textiowrapper kwargs --- tests/server/test_stdio.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 704ea8e4c..60e1875c5 100644 --- a/tests/server/test_stdio.py +++ b/tests/server/test_stdio.py @@ -108,12 +108,12 @@ async def test_stdio_server_default_textiowrapper_newline(monkeypatch: pytest.Mo monkeypatch.setattr(sys, "stdin", type("S", (), {"buffer": mock_stdin})()) monkeypatch.setattr(sys, "stdout", type("S", (), {"buffer": mock_stdout})()) - created_kwargs: list[dict] = [] + created_kwargs: list[dict[str, object]] = [] real_textiowrapper = TextIOWrapper def capturing_textiowrapper(*args: object, **kwargs: object) -> TextIOWrapper: - created_kwargs.append(kwargs) - return real_textiowrapper(*args, **kwargs) + created_kwargs.append(kwargs) # type: ignore[arg-type] + return real_textiowrapper(*args, **kwargs) # type: ignore[arg-type] with unittest.mock.patch("mcp.server.stdio.TextIOWrapper", side_effect=capturing_textiowrapper): async with stdio_server() as (read_stream, write_stream): From 07e65d964f8e527f1615351f034b3c2393facf45 Mon Sep 17 00:00:00 2001 From: EvanYao826 <2869018789@qq.com> Date: Mon, 11 May 2026 22:24:30 +0800 Subject: [PATCH 3/3] fix: strip trailing \r from lines in client stdio_reader to handle CRLF When a Windows MCP server emits CRLF (\r\n) line endings, the client's stdout_reader splits on \n alone, leaving a trailing \r on each line. This causes JSON parsing to fail since "{...}\r" is not valid JSON. Fix by stripping trailing \r from each line before JSON parsing in the client's stdio_reader. Also adds a test that verifies CRLF-terminated messages from a server are correctly parsed. Fixes #2433 --- src/mcp/client/stdio.py | 4 ++++ tests/client/test_stdio.py | 34 ++++++++++++++++++++++++++++++++++ 2 files changed, 38 insertions(+) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 902dc8576..cccf5cf6c 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -149,6 +149,10 @@ async def stdout_reader(): buffer = lines.pop() for line in lines: + # Strip any trailing \r left by CRLF line endings. + # A Windows MCP server may emit \r\n, and splitting on + # \n alone leaves a \r suffix that corrupts JSON parsing. + line = line.rstrip("\r") try: message = types.jsonrpc_message_adapter.validate_json(line, by_name=False) except Exception as exc: # pragma: no cover diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 06e2cba4b..9c037ae56 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -558,3 +558,37 @@ def sigterm_handler(signum, frame): f"stdio_client cleanup took {elapsed:.1f} seconds for stdin-ignoring process. " f"Expected between 2-4 seconds (2s stdin timeout + termination time)." ) + + +@pytest.mark.anyio +async def test_stdio_client_handles_crlf_line_endings(): + """Test that the client correctly parses JSON-RPC messages from a server + that outputs CRLF (\\r\\n) line endings, as is common on Windows. + + A Windows MCP server may write JSON messages terminated by \\r\\n. The + client's stdout_reader splits on \\n, so each line retains a trailing \\r + which would corrupt JSON parsing. This test verifies the CRLF is handled. + """ + # Python script that writes a JSON-RPC message with CRLF line endings + server_script = textwrap.dedent( + """\ + import sys + msg = '{"jsonrpc":"2.0","id":1,"result":{}}' + sys.stdout.buffer.write((msg + '\\r\\n').encode('utf-8')) + sys.stdout.buffer.flush() + # Exit after sending one message + """ + ) + + server_params = StdioServerParameters( + command=sys.executable, + args=["-c", server_script], + ) + + with anyio.fail_after(5.0): + async with stdio_client(server_params) as (read_stream, write_stream): + async with read_stream: + message = await read_stream.receive() + assert not isinstance(message, Exception), f"Expected message, got exception: {message}" + assert isinstance(message, SessionMessage) + assert message.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={})