Skip to content

Commit ecc9165

Browse files
Fix Windows subprocess NotImplementedError (STDIO clients) (#596)
Merging, rename for `DummyProcess` -> `FallbackProcess` coming in #1015
1 parent 679b229 commit ecc9165

File tree

1 file changed

+98
-23
lines changed

1 file changed

+98
-23
lines changed

src/mcp/client/stdio/win32.py

Lines changed: 98 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,12 @@
66
import subprocess
77
import sys
88
from pathlib import Path
9-
from typing import TextIO
9+
from typing import BinaryIO, TextIO, cast
1010

1111
import anyio
12+
from anyio import to_thread
1213
from anyio.abc import Process
14+
from anyio.streams.file import FileReadStream, FileWriteStream
1315

1416

1517
def get_windows_executable_command(command: str) -> str:
@@ -44,49 +46,122 @@ def get_windows_executable_command(command: str) -> str:
4446
return command
4547

4648

49+
class DummyProcess:
50+
"""
51+
A fallback process wrapper for Windows to handle async I/O
52+
when using subprocess.Popen, which provides sync-only FileIO objects.
53+
54+
This wraps stdin and stdout into async-compatible
55+
streams (FileReadStream, FileWriteStream),
56+
so that MCP clients expecting async streams can work properly.
57+
"""
58+
59+
def __init__(self, popen_obj: subprocess.Popen[bytes]):
60+
self.popen: subprocess.Popen[bytes] = popen_obj
61+
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
62+
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
63+
self.stderr = popen_obj.stderr # type: ignore[assignment]
64+
65+
self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
66+
self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
67+
68+
async def __aenter__(self):
69+
"""Support async context manager entry."""
70+
return self
71+
72+
async def __aexit__(
73+
self,
74+
exc_type: BaseException | None,
75+
exc_val: BaseException | None,
76+
exc_tb: object | None,
77+
) -> None:
78+
"""Terminate and wait on process exit inside a thread."""
79+
self.popen.terminate()
80+
await to_thread.run_sync(self.popen.wait)
81+
82+
# Close the file handles to prevent ResourceWarning
83+
if self.stdin:
84+
await self.stdin.aclose()
85+
if self.stdout:
86+
await self.stdout.aclose()
87+
if self.stdin_raw:
88+
self.stdin_raw.close()
89+
if self.stdout_raw:
90+
self.stdout_raw.close()
91+
if self.stderr:
92+
self.stderr.close()
93+
94+
async def wait(self):
95+
"""Async wait for process completion."""
96+
return await to_thread.run_sync(self.popen.wait)
97+
98+
def terminate(self):
99+
"""Terminate the subprocess immediately."""
100+
return self.popen.terminate()
101+
102+
def kill(self) -> None:
103+
"""Kill the subprocess immediately (alias for terminate)."""
104+
self.terminate()
105+
106+
107+
# ------------------------
108+
# Updated function
109+
# ------------------------
110+
111+
47112
async def create_windows_process(
48113
command: str,
49114
args: list[str],
50115
env: dict[str, str] | None = None,
51-
errlog: TextIO = sys.stderr,
116+
errlog: TextIO | None = sys.stderr,
52117
cwd: Path | str | None = None,
53-
):
118+
) -> DummyProcess:
54119
"""
55120
Creates a subprocess in a Windows-compatible way.
56121
57-
Windows processes need special handling for console windows and
58-
process creation flags.
122+
On Windows, asyncio.create_subprocess_exec has incomplete support
123+
(NotImplementedError when trying to open subprocesses).
124+
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
59125
60126
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
127+
command (str): The executable to run
128+
args (list[str]): List of command line arguments
129+
env (dict[str, str] | None): Environment variables
130+
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
131+
cwd (Path | str | None): Working directory for the subprocess
66132
67133
Returns:
68-
A process handle
134+
DummyProcess: Async-compatible subprocess with stdin and stdout streams
69135
"""
70136
try:
71-
# Try with Windows-specific flags to hide console window
72-
process = await anyio.open_process(
137+
# Try launching with creationflags to avoid opening a new console window
138+
popen_obj = subprocess.Popen(
73139
[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,
140+
stdin=subprocess.PIPE,
141+
stdout=subprocess.PIPE,
79142
stderr=errlog,
143+
env=env,
80144
cwd=cwd,
145+
bufsize=0, # Unbuffered output
146+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
81147
)
82-
return process
148+
return DummyProcess(popen_obj)
149+
83150
except Exception:
84-
# Don't raise, let's try to create the process without creation flags
85-
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd)
86-
return process
151+
# If creationflags failed, fallback without them
152+
popen_obj = subprocess.Popen(
153+
[command, *args],
154+
stdin=subprocess.PIPE,
155+
stdout=subprocess.PIPE,
156+
stderr=errlog,
157+
env=env,
158+
cwd=cwd,
159+
bufsize=0,
160+
)
161+
return DummyProcess(popen_obj)
87162

88163

89-
async def terminate_windows_process(process: Process):
164+
async def terminate_windows_process(process: Process | DummyProcess):
90165
"""
91166
Terminate a Windows process.
92167

0 commit comments

Comments
 (0)