Skip to content

Commit 75d14a5

Browse files
Fix Windows subprocess NotImplementedError for STDIO clients
This is a clean rebase of PR #596 (#596) by @theailanguage with merge conflicts resolved and unrelated changes removed. THIS IS FOR REVIEW ONLY - PR #596 should be the one that gets merged. Original fix by @theailanguage implements a fallback mechanism for Windows where asyncio.create_subprocess_exec raises NotImplementedError. Uses subprocess.Popen directly with async stream wrappers to maintain compatibility. The DummyProcess class wraps the synchronous Popen object and provides the same interface as anyio.Process for seamless integration. Resolves subprocess creation issues on Windows, particularly in environments with different event loop configurations like Streamlit.
1 parent 679b229 commit 75d14a5

File tree

1 file changed

+93
-22
lines changed

1 file changed

+93
-22
lines changed

src/mcp/client/stdio/win32.py

Lines changed: 93 additions & 22 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,46 +46,115 @@ 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+
103+
# ------------------------
104+
# Updated function
105+
# ------------------------
106+
107+
47108
async def create_windows_process(
48109
command: str,
49110
args: list[str],
50111
env: dict[str, str] | None = None,
51-
errlog: TextIO = sys.stderr,
112+
errlog: TextIO | None = sys.stderr,
52113
cwd: Path | str | None = None,
53-
):
114+
) -> DummyProcess:
54115
"""
55116
Creates a subprocess in a Windows-compatible way.
56117
57-
Windows processes need special handling for console windows and
58-
process creation flags.
118+
On Windows, asyncio.create_subprocess_exec has incomplete support
119+
(NotImplementedError when trying to open subprocesses).
120+
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
59121
60122
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
123+
command (str): The executable to run
124+
args (list[str]): List of command line arguments
125+
env (dict[str, str] | None): Environment variables
126+
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
127+
cwd (Path | str | None): Working directory for the subprocess
66128
67129
Returns:
68-
A process handle
130+
DummyProcess: Async-compatible subprocess with stdin and stdout streams
69131
"""
70132
try:
71-
# Try with Windows-specific flags to hide console window
72-
process = await anyio.open_process(
133+
# Try launching with creationflags to avoid opening a new console window
134+
popen_obj = subprocess.Popen(
73135
[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,
136+
stdin=subprocess.PIPE,
137+
stdout=subprocess.PIPE,
79138
stderr=errlog,
139+
env=env,
80140
cwd=cwd,
141+
bufsize=0, # Unbuffered output
142+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
81143
)
82-
return process
144+
return DummyProcess(popen_obj)
145+
83146
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
147+
# If creationflags failed, fallback without them
148+
popen_obj = subprocess.Popen(
149+
[command, *args],
150+
stdin=subprocess.PIPE,
151+
stdout=subprocess.PIPE,
152+
stderr=errlog,
153+
env=env,
154+
cwd=cwd,
155+
bufsize=0,
156+
)
157+
return DummyProcess(popen_obj)
87158

88159

89160
async def terminate_windows_process(process: Process):

0 commit comments

Comments
 (0)