diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio.py index 3e03eef9ef..be4f068791 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio.py @@ -8,8 +8,10 @@ process nor hang on one. """ +import io import logging import os +import subprocess import sys from collections.abc import AsyncGenerator from contextlib import asynccontextmanager, suppress @@ -122,11 +124,19 @@ async def stdio_client( """ command = _get_executable_command(server.command) + stderr = errlog + pipe_stderr = False + try: + errlog.fileno() + except (AttributeError, io.UnsupportedOperation, OSError): + stderr = subprocess.PIPE + pipe_stderr = True + process = await _create_platform_compatible_process( command=command, args=server.args, env=get_default_environment() | (server.env or {}), - errlog=errlog, + errlog=stderr, cwd=server.cwd, ) @@ -181,6 +191,16 @@ async def stdin_writer() -> None: finally: writer_done.set() + async def stderr_reader() -> None: + process_stderr = getattr(process, "stderr", None) + if process_stderr is None or not pipe_stderr: + return + with suppress(anyio.EndOfStream, anyio.ClosedResourceError, anyio.BrokenResourceError, OSError): + while True: + chunk = await process_stderr.receive() + errlog.write(chunk.decode(server.encoding, errors=server.encoding_error_handler)) + errlog.flush() + async def shutdown() -> None: """Winds the transport down: stop traffic, flush, stop the server, release the streams.""" # Unblock the reader into its drain: a server stuck writing stdout cannot @@ -200,6 +220,7 @@ async def shutdown() -> None: async with anyio.create_task_group() as tg: tg.start_soon(stdout_reader) tg.start_soon(stdin_writer) + tg.start_soon(stderr_reader) try: yield read_stream, write_stream finally: @@ -264,6 +285,9 @@ async def _stop_server_process(process: ServerProcess) -> None: close_process_job(process) # A kill survivor can hold the stdout pipe open; poison the reader anyway. await _close_pipe(process.stdout) + process_stderr = getattr(process, "stderr", None) + if process_stderr is not None: + await _close_pipe(process_stderr) _close_subprocess_transport(process) @@ -329,7 +353,7 @@ async def _create_platform_compatible_process( command: str, args: list[str], env: dict[str, str] | None = None, - errlog: TextIO = sys.stderr, + errlog: TextIO | int = sys.stderr, cwd: Path | str | None = None, ) -> ServerProcess: """Spawns the server in its own kill scope. diff --git a/src/mcp/os/win32/utilities.py b/src/mcp/os/win32/utilities.py index 321fda8a66..91727cc24f 100644 --- a/src/mcp/os/win32/utilities.py +++ b/src/mcp/os/win32/utilities.py @@ -137,7 +137,7 @@ async def create_windows_process( command: str, args: list[str], env: dict[str, str] | None = None, - errlog: TextIO | None = sys.stderr, + errlog: TextIO | int | None = sys.stderr, cwd: Path | str | None = None, ) -> Process | FallbackProcess: """Creates a subprocess with Job Object support for tree termination. @@ -177,7 +177,7 @@ async def _create_windows_fallback_process( command: str, args: list[str], env: dict[str, str] | None = None, - errlog: TextIO | None = sys.stderr, + errlog: TextIO | int | None = sys.stderr, cwd: Path | str | None = None, ) -> FallbackProcess: """Spawns via subprocess.Popen and wraps it in FallbackProcess.""" diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 91f829ff98..14febf79b4 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -9,10 +9,12 @@ import errno import gc +import io import logging import math import os import signal +import subprocess import sys from collections.abc import Callable from contextlib import AsyncExitStack, suppress @@ -222,6 +224,30 @@ async def fake_terminate_tree(proc: FakeProcess) -> None: FAKE_PARAMS = StdioServerParameters(command="fake-server") +@pytest.mark.anyio +async def test_stdio_client_pipes_stderr_for_non_file_errlog(monkeypatch: pytest.MonkeyPatch) -> None: + process = FakeProcess(on_stdin_close=lambda: process.exit(0)) + seen_errlog: list[object] = [] + + async def fake_spawn( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO = sys.stderr, + cwd: Path | str | None = None, + ) -> FakeProcess: + seen_errlog.append(errlog) + return process + + monkeypatch.setattr(stdio, "_create_platform_compatible_process", fake_spawn) + monkeypatch.setattr(stdio, "_terminate_process_tree", lambda proc: proc.exit(-15)) + + async with stdio_client(FAKE_PARAMS, errlog=io.StringIO()): + pass + + assert seen_errlog == [subprocess.PIPE] + + def _line(message: JSONRPCMessage) -> bytes: """The wire form of `message`: one JSON document on its own line.""" return (message.model_dump_json(by_alias=True, exclude_unset=True) + "\n").encode()