diff --git a/src/mcp/client/stdio.py b/src/mcp/client/stdio/__init__.py similarity index 75% rename from src/mcp/client/stdio.py rename to src/mcp/client/stdio/__init__.py index df721bbc..83de57a2 100644 --- a/src/mcp/client/stdio.py +++ b/src/mcp/client/stdio/__init__.py @@ -12,6 +12,12 @@ import mcp.types as types +from .win32 import ( + create_windows_process, + get_windows_executable_command, + terminate_windows_process, +) + # Environment variables to inherit by default DEFAULT_INHERITED_ENV_VARS = ( [ @@ -101,14 +107,18 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - process = await anyio.open_process( - [server.command, *server.args], + command = _get_executable_command(server.command) + + # Open process with stderr piped for capture + process = await _create_platform_compatible_process( + command=command, + args=server.args, env=( {**get_default_environment(), **server.env} if server.env is not None else get_default_environment() ), - stderr=errlog, + errlog=errlog, cwd=server.cwd, ) @@ -159,4 +169,48 @@ async def stdin_writer(): ): tg.start_soon(stdout_reader) tg.start_soon(stdin_writer) - yield read_stream, write_stream + try: + yield read_stream, write_stream + finally: + # Clean up process to prevent any dangling orphaned processes + if sys.platform == "win32": + await terminate_windows_process(process) + else: + process.terminate() + + +def _get_executable_command(command: str) -> str: + """ + Get the correct executable command normalized for the current platform. + + Args: + command: Base command (e.g., 'uvx', 'npx') + + Returns: + str: Platform-appropriate command + """ + if sys.platform == "win32": + return get_windows_executable_command(command) + else: + return command + + +async def _create_platform_compatible_process( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO = sys.stderr, + cwd: Path | str | None = None, +): + """ + Creates a subprocess in a platform-compatible way. + Returns a process handle. + """ + if sys.platform == "win32": + process = await create_windows_process(command, args, env, errlog, cwd) + else: + process = await anyio.open_process( + [command, *args], env=env, stderr=errlog, cwd=cwd + ) + + return process diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py new file mode 100644 index 00000000..825a0477 --- /dev/null +++ b/src/mcp/client/stdio/win32.py @@ -0,0 +1,109 @@ +""" +Windows-specific functionality for stdio client operations. +""" + +import shutil +import subprocess +import sys +from pathlib import Path +from typing import TextIO + +import anyio +from anyio.abc import Process + + +def get_windows_executable_command(command: str) -> str: + """ + Get the correct executable command normalized for Windows. + + On Windows, commands might exist with specific extensions (.exe, .cmd, etc.) + that need to be located for proper execution. + + Args: + command: Base command (e.g., 'uvx', 'npx') + + Returns: + str: Windows-appropriate command path + """ + try: + # First check if command exists in PATH as-is + if command_path := shutil.which(command): + return command_path + + # Check for Windows-specific extensions + for ext in [".cmd", ".bat", ".exe", ".ps1"]: + ext_version = f"{command}{ext}" + if ext_path := shutil.which(ext_version): + return ext_path + + # For regular commands or if we couldn't find special versions + return command + except OSError: + # Handle file system errors during path resolution + # (permissions, broken symlinks, etc.) + return command + + +async def create_windows_process( + command: str, + args: list[str], + env: dict[str, str] | None = None, + errlog: TextIO = sys.stderr, + cwd: Path | str | None = None, +): + """ + Creates a subprocess in a Windows-compatible way. + + Windows processes need special handling for console windows and + process creation flags. + + Args: + command: The command to execute + args: Command line arguments + env: Environment variables + errlog: Where to send stderr output + cwd: Working directory for the process + + Returns: + A process handle + """ + try: + # Try with Windows-specific flags to hide console window + process = await anyio.open_process( + [command, *args], + env=env, + # Ensure we don't create console windows for each process + creationflags=subprocess.CREATE_NO_WINDOW # type: ignore + if hasattr(subprocess, "CREATE_NO_WINDOW") + else 0, + stderr=errlog, + cwd=cwd, + ) + return process + except Exception: + # Don't raise, let's try to create the process without creation flags + process = await anyio.open_process( + [command, *args], env=env, stderr=errlog, cwd=cwd + ) + return process + + +async def terminate_windows_process(process: Process): + """ + Terminate a Windows process. + + Note: On Windows, terminating a process with process.terminate() doesn't + always guarantee immediate process termination. + So we give it 2s to exit, or we call process.kill() + which sends a SIGKILL equivalent signal. + + Args: + process: The process to terminate + """ + try: + process.terminate() + with anyio.fail_after(2.0): + await process.wait() + except TimeoutError: + # Force kill if it doesn't terminate + process.kill() diff --git a/uv.lock b/uv.lock index 4e724040..424e2d48 100644 --- a/uv.lock +++ b/uv.lock @@ -1618,4 +1618,4 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155 }, { url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884 }, { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743 }, -] +] \ No newline at end of file