Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Fixes to stdio_client to support Windows more robustly #372

Merged
merged 9 commits into from
Mar 27, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 58 additions & 4 deletions src/mcp/client/stdio.py → src/mcp/client/stdio/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = (
[
Expand Down Expand Up @@ -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,
)

Expand Down Expand Up @@ -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
109 changes: 109 additions & 0 deletions src/mcp/client/stdio/win32.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion uv.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.