Skip to content

Commit a78d7fc

Browse files
committed
Fixes to stdio_client to support Windows more robustly
1 parent dfbe56d commit a78d7fc

File tree

4 files changed

+104
-14
lines changed

4 files changed

+104
-14
lines changed

src/mcp/client/stdio.py

Lines changed: 99 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import os
2+
import shutil
3+
import subprocess
24
import sys
35
from contextlib import asynccontextmanager
46
from pathlib import Path
@@ -101,15 +103,19 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder
101103
read_stream_writer, read_stream = anyio.create_memory_object_stream(0)
102104
write_stream, write_stream_reader = anyio.create_memory_object_stream(0)
103105

104-
process = await anyio.open_process(
105-
[server.command, *server.args],
106+
command = _get_executable_command(server.command)
107+
108+
# Open process with stderr piped for capture
109+
process = await _create_platform_compatible_process(
110+
command=command,
111+
args=server.args,
106112
env=(
107113
{**get_default_environment(), **server.env}
108114
if server.env is not None
109115
else get_default_environment()
110116
),
111-
stderr=errlog,
112-
cwd=server.cwd,
117+
errlog=errlog,
118+
cwd=server.cwd
113119
)
114120

115121
async def stdout_reader():
@@ -159,4 +165,92 @@ async def stdin_writer():
159165
):
160166
tg.start_soon(stdout_reader)
161167
tg.start_soon(stdin_writer)
162-
yield read_stream, write_stream
168+
try:
169+
yield read_stream, write_stream
170+
finally:
171+
# Clean up process
172+
try:
173+
process.terminate()
174+
if sys.platform == "win32":
175+
try:
176+
with anyio.fail_after(2.0):
177+
await process.wait()
178+
except TimeoutError:
179+
# Force kill if it doesn't terminate
180+
process.kill()
181+
except Exception:
182+
pass
183+
184+
185+
def _get_executable_command(command: str) -> str:
186+
"""
187+
Get the correct executable command normalized for the current platform.
188+
189+
Args:
190+
command: Base command (e.g., 'uvx', 'npx')
191+
192+
Returns:
193+
List[str]: Platform-appropriate command
194+
"""
195+
196+
try:
197+
if sys.platform != "win32":
198+
return command
199+
else:
200+
# For Windows, we need more sophisticated path resolution
201+
# First check if command exists in PATH as-is
202+
command_path = shutil.which(command)
203+
if command_path:
204+
return command_path
205+
206+
# Check for Windows-specific extensions
207+
for ext in [".cmd", ".bat", ".exe", ".ps1"]:
208+
ext_version = f"{command}{ext}"
209+
ext_path = shutil.which(ext_version)
210+
if ext_path:
211+
return ext_path
212+
213+
# For regular commands or if we couldn't find special versions
214+
return command
215+
except Exception:
216+
return command
217+
218+
219+
async def _create_platform_compatible_process(
220+
command: str,
221+
args: list[str],
222+
env: dict[str, str] | None = None,
223+
errlog: int | TextIO = subprocess.PIPE,
224+
cwd: Path | str | None = None,
225+
):
226+
"""
227+
Creates a subprocess in a platform-compatible way.
228+
Returns a process handle.
229+
"""
230+
231+
process = None
232+
233+
if sys.platform == "win32":
234+
try:
235+
process = await anyio.open_process(
236+
[command, *args],
237+
env=env,
238+
# Ensure we don't create console windows for each process
239+
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
240+
if hasattr(subprocess, "CREATE_NO_WINDOW")
241+
else 0,
242+
stderr=errlog,
243+
cwd=cwd,
244+
)
245+
246+
return process
247+
except Exception:
248+
# Don't raise, let's try to create the process using the default method
249+
process = None
250+
251+
# Default method for creating the process
252+
process = await anyio.open_process(
253+
[command, *args], env=env, stderr=errlog, cwd=cwd
254+
)
255+
256+
return process

src/mcp/server/fastmcp/server.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -652,9 +652,9 @@ async def read_resource(self, uri: str | AnyUrl) -> Iterable[ReadResourceContent
652652
Returns:
653653
The resource content as either text or bytes
654654
"""
655-
assert self._fastmcp is not None, (
656-
"Context is not available outside of a request"
657-
)
655+
assert (
656+
self._fastmcp is not None
657+
), "Context is not available outside of a request"
658658
return await self._fastmcp.read_resource(uri)
659659

660660
async def log(

tests/shared/test_sse.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,9 +138,7 @@ def server(server_port: int) -> Generator[None, None, None]:
138138
time.sleep(0.1)
139139
attempt += 1
140140
else:
141-
raise RuntimeError(
142-
f"Server failed to start after {max_attempts} attempts"
143-
)
141+
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
144142

145143
yield
146144

tests/shared/test_ws.py

Lines changed: 1 addition & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -134,9 +134,7 @@ def server(server_port: int) -> Generator[None, None, None]:
134134
time.sleep(0.1)
135135
attempt += 1
136136
else:
137-
raise RuntimeError(
138-
f"Server failed to start after {max_attempts} attempts"
139-
)
137+
raise RuntimeError(f"Server failed to start after {max_attempts} attempts")
140138

141139
yield
142140

0 commit comments

Comments
 (0)