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/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/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={}) diff --git a/tests/server/test_stdio.py b/tests/server/test_stdio.py index 677a99356..60e1875c5 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[str, object]] = [] + real_textiowrapper = TextIOWrapper + + def capturing_textiowrapper(*args: object, **kwargs: object) -> TextIOWrapper: + 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): + 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=''"