From cbb1457606ca3070bfc730570b30f8cdda4f16ab Mon Sep 17 00:00:00 2001 From: liuhao1024 Date: Sun, 14 Jun 2026 19:20:30 +0800 Subject: [PATCH] fix(mcp): use encoding_error_handler='replace' for stdio transport On Windows, pipe I/O can deliver non-UTF-8 bytes at chunk boundaries, causing `UnicodeDecodeError` when the MCP SDK's `TextReceiveStream` uses `errors="strict"`. Set `encoding_error_handler="replace"` on `StdioServerParameters` so undecodable bytes become U+FFFD instead of crashing. --- .../tools/test_mcp_stdio_encoding_handler.py | 103 ++++++++++++++++++ tools/mcp_tool.py | 4 + 2 files changed, 107 insertions(+) create mode 100644 tests/tools/test_mcp_stdio_encoding_handler.py diff --git a/tests/tools/test_mcp_stdio_encoding_handler.py b/tests/tools/test_mcp_stdio_encoding_handler.py new file mode 100644 index 000000000000..3a84ea92a776 --- /dev/null +++ b/tests/tools/test_mcp_stdio_encoding_handler.py @@ -0,0 +1,103 @@ +"""Tests for MCP stdio encoding error handler fix (issue #46099). + +On Windows, pipe I/O can produce non-UTF-8 bytes at chunk boundaries, +causing UnicodeDecodeError when the MCP SDK's TextReceiveStream uses +errors="strict". This test verifies that StdioServerParameters is created +with encoding_error_handler="replace". +""" + +import asyncio +from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from tools.mcp_tool import MCPServerTask, _MCP_AVAILABLE + +pytestmark = pytest.mark.skipif(not _MCP_AVAILABLE, reason="MCP SDK not installed") + + +class TestStdioEncodingErrorHandler: + """Verify that _run_stdio passes encoding_error_handler='replace'.""" + + def test_stdio_server_params_uses_replace_encoding_handler(self): + """StdioServerParameters must use encoding_error_handler='replace'. + + On Windows, pipe chunk boundaries can split multi-byte UTF-8 sequences, + producing bytes that fail with errors='strict'. Using 'replace' ensures + undecodable bytes become U+FFFD instead of crashing. + """ + mock_session = MagicMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock(return_value=SimpleNamespace(tools=[])) + + mock_stdio_cm = MagicMock() + mock_stdio_cm.__aenter__ = AsyncMock(return_value=(object(), object())) + mock_stdio_cm.__aexit__ = AsyncMock(return_value=False) + + mock_session_cm = MagicMock() + mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_cm.__aexit__ = AsyncMock(return_value=False) + + async def _test(): + with ( + patch("tools.mcp_tool.StdioServerParameters") as mock_params, + patch("tools.mcp_tool.stdio_client", return_value=mock_stdio_cm), + patch("tools.mcp_tool.ClientSession", return_value=mock_session_cm), + patch("tools.mcp_tool._snapshot_child_pids", return_value=set()), + patch("tools.mcp_tool._write_stderr_log_header"), + patch("tools.mcp_tool._get_mcp_stderr_log", return_value=None), + ): + server = MCPServerTask("test-encoding") + await server.start({ + "command": "echo", + "args": ["hello"], + }) + + call_kwargs = mock_params.call_args.kwargs + assert call_kwargs["encoding_error_handler"] == "replace", ( + f"Expected encoding_error_handler='replace', " + f"got '{call_kwargs.get('encoding_error_handler')}'" + ) + + await server.shutdown() + + asyncio.run(_test()) + + def test_stdio_server_params_defaults_encoding_utf8(self): + """Verify that the default encoding (utf-8) is not overridden.""" + mock_session = MagicMock() + mock_session.initialize = AsyncMock() + mock_session.list_tools = AsyncMock(return_value=SimpleNamespace(tools=[])) + + mock_stdio_cm = MagicMock() + mock_stdio_cm.__aenter__ = AsyncMock(return_value=(object(), object())) + mock_stdio_cm.__aexit__ = AsyncMock(return_value=False) + + mock_session_cm = MagicMock() + mock_session_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_session_cm.__aexit__ = AsyncMock(return_value=False) + + async def _test(): + with ( + patch("tools.mcp_tool.StdioServerParameters") as mock_params, + patch("tools.mcp_tool.stdio_client", return_value=mock_stdio_cm), + patch("tools.mcp_tool.ClientSession", return_value=mock_session_cm), + patch("tools.mcp_tool._snapshot_child_pids", return_value=set()), + patch("tools.mcp_tool._write_stderr_log_header"), + patch("tools.mcp_tool._get_mcp_stderr_log", return_value=None), + ): + server = MCPServerTask("test-encoding") + await server.start({ + "command": "echo", + "args": ["hello"], + }) + + call_kwargs = mock_params.call_args.kwargs + # encoding is not explicitly set — StdioServerParameters defaults to utf-8 + # We just verify we don't override it + assert "encoding" not in call_kwargs or call_kwargs.get("encoding") == "utf-8" + + await server.shutdown() + + asyncio.run(_test()) diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 35382471a143..3a1964b0cf08 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -2411,6 +2411,10 @@ class MCPServerTask: command=command, args=args, env=safe_env if safe_env else None, + # On Windows, pipe I/O can deliver non-UTF-8 bytes at chunk + # boundaries. Use "replace" to substitute undecodable bytes + # with U+FFFD instead of crashing with UnicodeDecodeError. + encoding_error_handler="replace", ) sampling_kwargs = self._sampling.session_kwargs() if self._sampling else {}