Skip to content

Commit 01d073d

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 01d073d

File tree

1 file changed

+85
-22
lines changed

1 file changed

+85
-22
lines changed

src/mcp/client/stdio/win32.py

Lines changed: 85 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,107 @@ 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 = (
66+
FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
67+
)
68+
self.stdout = (
69+
FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
70+
)
71+
72+
async def __aenter__(self):
73+
"""Support async context manager entry."""
74+
return self
75+
76+
async def __aexit__(
77+
self,
78+
exc_type: BaseException | None,
79+
exc_val: BaseException | None,
80+
exc_tb: object | None,
81+
) -> None:
82+
"""Terminate and wait on process exit inside a thread."""
83+
self.popen.terminate()
84+
await to_thread.run_sync(self.popen.wait)
85+
86+
async def wait(self):
87+
"""Async wait for process completion."""
88+
return await to_thread.run_sync(self.popen.wait)
89+
90+
def terminate(self):
91+
"""Terminate the subprocess immediately."""
92+
return self.popen.terminate()
93+
94+
95+
# ------------------------
96+
# Updated function
97+
# ------------------------
98+
99+
47100
async def create_windows_process(
48101
command: str,
49102
args: list[str],
50103
env: dict[str, str] | None = None,
51-
errlog: TextIO = sys.stderr,
104+
errlog: TextIO | None = sys.stderr,
52105
cwd: Path | str | None = None,
53-
):
106+
) -> DummyProcess:
54107
"""
55108
Creates a subprocess in a Windows-compatible way.
56109
57-
Windows processes need special handling for console windows and
58-
process creation flags.
110+
On Windows, asyncio.create_subprocess_exec has incomplete support
111+
(NotImplementedError when trying to open subprocesses).
112+
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
59113
60114
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
115+
command (str): The executable to run
116+
args (list[str]): List of command line arguments
117+
env (dict[str, str] | None): Environment variables
118+
errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
119+
cwd (Path | str | None): Working directory for the subprocess
66120
67121
Returns:
68-
A process handle
122+
DummyProcess: Async-compatible subprocess with stdin and stdout streams
69123
"""
70124
try:
71-
# Try with Windows-specific flags to hide console window
72-
process = await anyio.open_process(
125+
# Try launching with creationflags to avoid opening a new console window
126+
popen_obj = subprocess.Popen(
73127
[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,
128+
stdin=subprocess.PIPE,
129+
stdout=subprocess.PIPE,
79130
stderr=errlog,
131+
env=env,
80132
cwd=cwd,
133+
bufsize=0, # Unbuffered output
134+
creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0),
81135
)
82-
return process
136+
return DummyProcess(popen_obj)
137+
83138
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
139+
# If creationflags failed, fallback without them
140+
popen_obj = subprocess.Popen(
141+
[command, *args],
142+
stdin=subprocess.PIPE,
143+
stdout=subprocess.PIPE,
144+
stderr=errlog,
145+
env=env,
146+
cwd=cwd,
147+
bufsize=0,
148+
)
149+
return DummyProcess(popen_obj)
87150

88151

89152
async def terminate_windows_process(process: Process):

0 commit comments

Comments
 (0)