Skip to content

Commit e5ee279

Browse files
authored
Fixes to stdio_client to support Windows more robustly (#372)
1 parent d3bf9cd commit e5ee279

File tree

3 files changed

+168
-5
lines changed

3 files changed

+168
-5
lines changed

src/mcp/client/stdio.py src/mcp/client/stdio/__init__.py

+58-4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,12 @@
1212

1313
import mcp.types as types
1414

15+
from .win32 import (
16+
create_windows_process,
17+
get_windows_executable_command,
18+
terminate_windows_process,
19+
)
20+
1521
# Environment variables to inherit by default
1622
DEFAULT_INHERITED_ENV_VARS = (
1723
[
@@ -101,14 +107,18 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
101107
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
102108
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
103109

104-
process = await anyio.open_process(
105-
[server.command, *server.args],
110+
command = _get_executable_command(server.command)
111+
112+
# Open process with stderr piped for capture
113+
process = await _create_platform_compatible_process(
114+
command=command,
115+
args=server.args,
106116
env=(
107117
{**get_default_environment(), **server.env}
108118
if server.env is not None
109119
else get_default_environment()
110120
),
111-
stderr=errlog,
121+
errlog=errlog,
112122
cwd=server.cwd,
113123
)
114124

@@ -159,4 +169,48 @@ async def stdin_writer():
159169
):
160170
tg.start_soon(stdout_reader)
161171
tg.start_soon(stdin_writer)
162-
yield read_stream, write_stream
172+
try:
173+
yield read_stream, write_stream
174+
finally:
175+
# Clean up process to prevent any dangling orphaned processes
176+
if sys.platform == "win32":
177+
await terminate_windows_process(process)
178+
else:
179+
process.terminate()
180+
181+
182+
def _get_executable_command(command: str) -> str:
183+
"""
184+
Get the correct executable command normalized for the current platform.
185+
186+
Args:
187+
command: Base command (e.g., 'uvx', 'npx')
188+
189+
Returns:
190+
str: Platform-appropriate command
191+
"""
192+
if sys.platform == "win32":
193+
return get_windows_executable_command(command)
194+
else:
195+
return command
196+
197+
198+
async def _create_platform_compatible_process(
199+
command: str,
200+
args: list[str],
201+
env: dict[str, str] | None = None,
202+
errlog: TextIO = sys.stderr,
203+
cwd: Path | str | None = None,
204+
):
205+
"""
206+
Creates a subprocess in a platform-compatible way.
207+
Returns a process handle.
208+
"""
209+
if sys.platform == "win32":
210+
process = await create_windows_process(command, args, env, errlog, cwd)
211+
else:
212+
process = await anyio.open_process(
213+
[command, *args], env=env, stderr=errlog, cwd=cwd
214+
)
215+
216+
return process

src/mcp/client/stdio/win32.py

+109
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""
2+
Windows-specific functionality for stdio client operations.
3+
"""
4+
5+
import shutil
6+
import subprocess
7+
import sys
8+
from pathlib import Path
9+
from typing import TextIO
10+
11+
import anyio
12+
from anyio.abc import Process
13+
14+
15+
def get_windows_executable_command(command: str) -> str:
16+
"""
17+
Get the correct executable command normalized for Windows.
18+
19+
On Windows, commands might exist with specific extensions (.exe, .cmd, etc.)
20+
that need to be located for proper execution.
21+
22+
Args:
23+
command: Base command (e.g., 'uvx', 'npx')
24+
25+
Returns:
26+
str: Windows-appropriate command path
27+
"""
28+
try:
29+
# First check if command exists in PATH as-is
30+
if command_path := shutil.which(command):
31+
return command_path
32+
33+
# Check for Windows-specific extensions
34+
for ext in [".cmd", ".bat", ".exe", ".ps1"]:
35+
ext_version = f"{command}{ext}"
36+
if ext_path := shutil.which(ext_version):
37+
return ext_path
38+
39+
# For regular commands or if we couldn't find special versions
40+
return command
41+
except OSError:
42+
# Handle file system errors during path resolution
43+
# (permissions, broken symlinks, etc.)
44+
return command
45+
46+
47+
async def create_windows_process(
48+
command: str,
49+
args: list[str],
50+
env: dict[str, str] | None = None,
51+
errlog: TextIO = sys.stderr,
52+
cwd: Path | str | None = None,
53+
):
54+
"""
55+
Creates a subprocess in a Windows-compatible way.
56+
57+
Windows processes need special handling for console windows and
58+
process creation flags.
59+
60+
Args:
61+
command: The command to execute
62+
args: Command line arguments
63+
env: Environment variables
64+
errlog: Where to send stderr output
65+
cwd: Working directory for the process
66+
67+
Returns:
68+
A process handle
69+
"""
70+
try:
71+
# Try with Windows-specific flags to hide console window
72+
process = await anyio.open_process(
73+
[command, *args],
74+
env=env,
75+
# Ensure we don't create console windows for each process
76+
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
77+
if hasattr(subprocess, "CREATE_NO_WINDOW")
78+
else 0,
79+
stderr=errlog,
80+
cwd=cwd,
81+
)
82+
return process
83+
except Exception:
84+
# Don't raise, let's try to create the process without creation flags
85+
process = await anyio.open_process(
86+
[command, *args], env=env, stderr=errlog, cwd=cwd
87+
)
88+
return process
89+
90+
91+
async def terminate_windows_process(process: Process):
92+
"""
93+
Terminate a Windows process.
94+
95+
Note: On Windows, terminating a process with process.terminate() doesn't
96+
always guarantee immediate process termination.
97+
So we give it 2s to exit, or we call process.kill()
98+
which sends a SIGKILL equivalent signal.
99+
100+
Args:
101+
process: The process to terminate
102+
"""
103+
try:
104+
process.terminate()
105+
with anyio.fail_after(2.0):
106+
await process.wait()
107+
except TimeoutError:
108+
# Force kill if it doesn't terminate
109+
process.kill()

uv.lock

+1-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)