Skip to content

Commit d613377

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 d613377

File tree

1 file changed

+81
-22
lines changed

1 file changed

+81
-22
lines changed

src/mcp/client/stdio/win32.py

Lines changed: 81 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,103 @@ 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+
async def wait(self):
83+
"""Async wait for process completion."""
84+
return await to_thread.run_sync(self.popen.wait)
85+
86+
def terminate(self):
87+
"""Terminate the subprocess immediately."""
88+
return self.popen.terminate()
89+
90+
91+
# ------------------------
92+
# Updated function
93+
# ------------------------
94+
95+
4796
async def create_windows_process(
4897
command: str,
4998
args: list[str],
5099
env: dict[str, str] | None = None,
51-
errlog: TextIO = sys.stderr,
100+
errlog: TextIO | None = sys.stderr,
52101
cwd: Path | str | None = None,
53-
):
102+
) -> DummyProcess:
54103
"""
55104
Creates a subprocess in a Windows-compatible way.
56105
57-
Windows processes need special handling for console windows and
58-
process creation flags.
106+
On Windows, asyncio.create_subprocess_exec has incomplete support
107+
(NotImplementedError when trying to open subprocesses).
108+
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
59109
60110
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
111+
command (str): The executable to run
112+
args (list[str]): List of command line arguments
113+
env (dict[str, str] | None): Environment variables
114+
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
115+
cwd (Path | str | None): Working directory for the subprocess
66116
67117
Returns:
68-
A process handle
118+
DummyProcess: Async-compatible subprocess with stdin and stdout streams
69119
"""
70120
try:
71-
# Try with Windows-specific flags to hide console window
72-
process = await anyio.open_process(
121+
# Try launching with creationflags to avoid opening a new console window
122+
popen_obj = subprocess.Popen(
73123
[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,
124+
stdin=subprocess.PIPE,
125+
stdout=subprocess.PIPE,
79126
stderr=errlog,
127+
env=env,
80128
cwd=cwd,
129+
bufsize=0, # Unbuffered output
130+
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
81131
)
82-
return process
132+
return DummyProcess(popen_obj)
133+
83134
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
135+
# If creationflags failed, fallback without them
136+
popen_obj = subprocess.Popen(
137+
[command, *args],
138+
stdin=subprocess.PIPE,
139+
stdout=subprocess.PIPE,
140+
stderr=errlog,
141+
env=env,
142+
cwd=cwd,
143+
bufsize=0,
144+
)
145+
return DummyProcess(popen_obj)
87146

88147

89148
async def terminate_windows_process(process: Process):

0 commit comments

Comments
 (0)