From 4b0e3e8942933ffb537df0fd052ee691e60a6ad1 Mon Sep 17 00:00:00 2001 From: roman groblicki Date: Thu, 7 May 2026 21:42:38 +1000 Subject: [PATCH] fix(client/stdio): fall back to os.devnull when sys.stderr is None MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Under pythonw.exe on Windows (desktop shortcuts, silent .bat launches, anything without a console attached), sys.stderr is None. The previous default `errlog: TextIO = sys.stderr` then propagated that None handle into the subprocess as its stderr. asyncio's Windows ProactorEventLoop subprocess transport mishandles a None stderr handle, corrupting IOCP routing on the read pump. The symptom is `ClosedResourceError` on the first real RPC after `initialize()` succeeds. Bursty servers like `@modelcontextprotocol/server-filesystem` (whose initialize → list_tools traffic happens within milliseconds) hit this consistently; quieter servers like Gmail clients usually slip through. Reproducing requires launching a stdio MCP client via pythonw.exe with no stderr redirect — running the same code from a console (python.exe) or with stderr redirected to a file works because sys.stderr is then a real handle. Fix: change `errlog: TextIO = sys.stderr` to `errlog: TextIO | None = None` and resolve at call time. Falls back to `sys.stderr` when it is a real handle, `os.devnull` otherwise. Backward compatible — callers passing an explicit `errlog` keep the same behavior. Also applied the same fallback in `_create_platform_compatible_process` as defense in depth, so any internal caller that omits `errlog` is safe regardless of whether `sys.stderr` is None. Verified by reproducing the bug under pythonw.exe (filesystem-server list_tools fails with ClosedResourceError) and confirming the fix restores it to working state without changing behavior under python.exe or with explicit errlog. --- src/mcp/client/stdio.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 902dc8576..5cd91104e 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -102,10 +102,24 @@ class StdioServerParameters(BaseModel): @asynccontextmanager -async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stderr): +async def stdio_client(server: StdioServerParameters, errlog: TextIO | None = None): """Client transport for stdio: this will connect to a server by spawning a process and communicating with it over stdin/stdout. + + ``errlog`` is the sink for the spawned subprocess's stderr. When omitted, + falls back to ``sys.stderr`` if it is a real handle, otherwise to + ``os.devnull``. The ``os.devnull`` fallback is required for callers + running under ``pythonw.exe`` on Windows (desktop shortcuts, silent + ``.bat`` launches, anything without a console attached): in that + environment ``sys.stderr`` is ``None``, and passing a ``None`` handle + as the subprocess's stderr corrupts asyncio's Windows ProactorEventLoop + subprocess transport, producing ``ClosedResourceError`` on the first + real RPC after ``initialize()``. Callers that want subprocess stderr + captured can still pass an explicit file handle. """ + if errlog is None: + errlog = sys.stderr if sys.stderr is not None else open(os.devnull, "w") + read_stream: MemoryObjectReceiveStream[SessionMessage | Exception] read_stream_writer: MemoryObjectSendStream[SessionMessage | Exception] @@ -230,14 +244,21 @@ async def _create_platform_compatible_process( command: str, args: list[str], env: dict[str, str] | None = None, - errlog: TextIO = sys.stderr, + errlog: TextIO | None = None, cwd: Path | str | None = None, ): """Creates a subprocess in a platform-compatible way. Unix: Creates process in a new session/process group for killpg support Windows: Creates process in a Job Object for reliable child termination + + ``errlog`` defaults to ``sys.stderr`` when available, ``os.devnull`` + when not. The ``os.devnull`` fallback prevents asyncio's Windows + ProactorEventLoop from receiving a ``None`` stderr handle under + ``pythonw.exe``, which would otherwise corrupt subprocess pipe setup. """ + if errlog is None: + errlog = sys.stderr if sys.stderr is not None else open(os.devnull, "w") if sys.platform == "win32": # pragma: no cover process = await create_windows_process(command, args, env, errlog, cwd) else: # pragma: lax no cover