Fix Windows subprocess NotImplementedError (STDIO clients) (#596)

Merging, rename for `DummyProcess` -> `FallbackProcess` coming in https://github.com/modelcontextprotocol/python-sdk/pull/1015
This commit is contained in:
The AI Language 2025-06-24 19:35:23 +05:30 • committed by GitHub
commit ecc916516d
No known key found for this signature in database
GPG key ID: B5690EEEBB952194

View file

@ -6,10 +6,12 @@ import shutil
import subprocess import subprocess
import sys import sys
from pathlib import Path from pathlib import Path
from typing import TextIO from typing import BinaryIO, TextIO, cast
import anyio import anyio
from anyio import to_thread
from anyio.abc import Process from anyio.abc import Process
from anyio.streams.file import FileReadStream, FileWriteStream
def get_windows_executable_command(command: str) -> str: def get_windows_executable_command(command: str) -> str:
@ -44,49 +46,122 @@ def get_windows_executable_command(command: str) -> str:
return command return command
class DummyProcess:
"""
A fallback process wrapper for Windows to handle async I/O
when using subprocess.Popen, which provides sync-only FileIO objects.
This wraps stdin and stdout into async-compatible
streams (FileReadStream, FileWriteStream),
so that MCP clients expecting async streams can work properly.
"""
def __init__(self, popen_obj: subprocess.Popen[bytes]):
self.popen: subprocess.Popen[bytes] = popen_obj
self.stdin_raw = popen_obj.stdin # type: ignore[assignment]
self.stdout_raw = popen_obj.stdout # type: ignore[assignment]
self.stderr = popen_obj.stderr # type: ignore[assignment]
self.stdin = FileWriteStream(cast(BinaryIO, self.stdin_raw)) if self.stdin_raw else None
self.stdout = FileReadStream(cast(BinaryIO, self.stdout_raw)) if self.stdout_raw else None
async def __aenter__(self):
"""Support async context manager entry."""
return self
async def __aexit__(
self,
exc_type: BaseException | None,
exc_val: BaseException | None,
exc_tb: object | None,
) -> None:
"""Terminate and wait on process exit inside a thread."""
self.popen.terminate()
await to_thread.run_sync(self.popen.wait)
# Close the file handles to prevent ResourceWarning
if self.stdin:
await self.stdin.aclose()
if self.stdout:
await self.stdout.aclose()
if self.stdin_raw:
self.stdin_raw.close()
if self.stdout_raw:
self.stdout_raw.close()
if self.stderr:
self.stderr.close()
async def wait(self):
"""Async wait for process completion."""
return await to_thread.run_sync(self.popen.wait)
def terminate(self):
"""Terminate the subprocess immediately."""
return self.popen.terminate()
def kill(self) -> None:
"""Kill the subprocess immediately (alias for terminate)."""
self.terminate()
# ------------------------
# Updated function
# ------------------------
async def create_windows_process( async def create_windows_process(
command: str, command: str,
args: list[str], args: list[str],
env: dict[str, str] | None = None, env: dict[str, str] | None = None,
errlog: TextIO = sys.stderr, errlog: TextIO | None = sys.stderr,
cwd: Path | str | None = None, cwd: Path | str | None = None,
): ) -> DummyProcess:
""" """
Creates a subprocess in a Windows-compatible way. Creates a subprocess in a Windows-compatible way.
Windows processes need special handling for console windows and On Windows, asyncio.create_subprocess_exec has incomplete support
process creation flags. (NotImplementedError when trying to open subprocesses).
Therefore, we fallback to subprocess.Popen and wrap it for async usage.
Args: Args:
command: The command to execute command (str): The executable to run
args: Command line arguments args (list[str]): List of command line arguments
env: Environment variables env (dict[str, str] | None): Environment variables
errlog: Where to send stderr output errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr)
cwd: Working directory for the process cwd (Path | str | None): Working directory for the subprocess
Returns: Returns:
A process handle DummyProcess: Async-compatible subprocess with stdin and stdout streams
""" """
try: try:
# Try with Windows-specific flags to hide console window # Try launching with creationflags to avoid opening a new console window
process = await anyio.open_process( popen_obj = subprocess.Popen(
[command, *args], [command, *args],
env=env, stdin=subprocess.PIPE,
# Ensure we don't create console windows for each process stdout=subprocess.PIPE,
creationflags=subprocess.CREATE_NO_WINDOW # type: ignore
if hasattr(subprocess, "CREATE_NO_WINDOW")
else 0,
stderr=errlog, stderr=errlog,
env=env,
cwd=cwd, cwd=cwd,
bufsize=0, # Unbuffered output
creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0),
) )
return process return DummyProcess(popen_obj)
except Exception: except Exception:
# Don't raise, let's try to create the process without creation flags # If creationflags failed, fallback without them
process = await anyio.open_process([command, *args], env=env, stderr=errlog, cwd=cwd) popen_obj = subprocess.Popen(
return process [command, *args],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=errlog,
env=env,
cwd=cwd,
bufsize=0,
)
return DummyProcess(popen_obj)
async def terminate_windows_process(process: Process): async def terminate_windows_process(process: Process | DummyProcess):
""" """
Terminate a Windows process. Terminate a Windows process.