From 83699ac8b8eee06adfad70527f576eabf597323c Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 17:31:04 +0530 Subject: [PATCH 1/7] Fix Windows subprocess compatibility for STDIO mode with async streams --- src/mcp/client/stdio/win32.py | 98 ++++++++++++++++++++++++++--------- 1 file changed, 74 insertions(+), 24 deletions(-) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index 825a0477d..8ca5df92f 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/client/stdio/win32.py @@ -10,6 +10,10 @@ import anyio from anyio.abc import Process +from anyio.streams.file import FileReadStream, FileWriteStream + +from typing import Optional, TextIO, Union +from pathlib import Path def get_windows_executable_command(command: str) -> str: @@ -43,49 +47,95 @@ def get_windows_executable_command(command: str) -> str: # (permissions, broken symlinks, etc.) 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): + self.popen = popen_obj + self.stdin_raw = popen_obj.stdin + self.stdout_raw = popen_obj.stdout + self.stderr = popen_obj.stderr + + # Wrap into async-compatible AnyIO streams + self.stdin = FileWriteStream(self.stdin_raw) + self.stdout = FileReadStream(self.stdout_raw) + + async def __aenter__(self): + """Support async context manager entry.""" + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb): + """Terminate and wait on process exit inside a thread.""" + self.popen.terminate() + await anyio.to_thread.run_sync(self.popen.wait) + + async def wait(self): + """Async wait for process completion.""" + return await anyio.to_thread.run_sync(self.popen.wait) + + def terminate(self): + """Terminate the subprocess immediately.""" + return self.popen.terminate() + +# ------------------------ +# Updated function +# ------------------------ async def create_windows_process( command: str, args: list[str], - env: dict[str, str] | None = None, - errlog: TextIO = sys.stderr, - cwd: Path | str | None = None, + env: Optional[dict[str, str]] = None, + errlog: Optional[TextIO] = sys.stderr, + cwd: Union[Path, str, None] = None, ): """ Creates a subprocess in a Windows-compatible way. - - Windows processes need special handling for console windows and - process creation flags. + + On Windows, asyncio.create_subprocess_exec has incomplete support + (NotImplementedError when trying to open subprocesses). + Therefore, we fallback to subprocess.Popen and wrap it for async usage. Args: - command: The command to execute - args: Command line arguments - env: Environment variables - errlog: Where to send stderr output - cwd: Working directory for the process + command (str): The executable to run + args (list[str]): List of command line arguments + env (dict[str, str] | None): Environment variables + errlog (TextIO | None): Where to send stderr output (defaults to sys.stderr) + cwd (Path | str | None): Working directory for the subprocess Returns: - A process handle + DummyProcess: Async-compatible subprocess with stdin and stdout streams """ try: - # Try with Windows-specific flags to hide console window - process = await anyio.open_process( + # Try launching with creationflags to avoid opening a new console window + popen_obj = subprocess.Popen( [command, *args], - env=env, - # Ensure we don't create console windows for each process - creationflags=subprocess.CREATE_NO_WINDOW # type: ignore - if hasattr(subprocess, "CREATE_NO_WINDOW") - else 0, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, stderr=errlog, + env=env, cwd=cwd, + bufsize=0, # Unbuffered output + creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, ) - return process + return DummyProcess(popen_obj) + except Exception: - # Don't raise, let's try to create the process without creation flags - process = await anyio.open_process( - [command, *args], env=env, stderr=errlog, cwd=cwd + # If creationflags failed, fallback without them + popen_obj = subprocess.Popen( + [command, *args], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=errlog, + env=env, + cwd=cwd, + bufsize=0, ) - return process + return DummyProcess(popen_obj) async def terminate_windows_process(process: Process): From f1bc421be39a2e5903344dc79e0e5c787ca39f78 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 18:47:21 +0530 Subject: [PATCH 2/7] Fix: Windows stdio subprocess compatibility with type hints and fallback to subprocess.Popen --- src/mcp/client/stdio/win32.py | 43 ++++++++++++++++++----------------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index 8ca5df92f..2b34181b5 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/client/stdio/win32.py @@ -6,16 +6,13 @@ import subprocess import sys from pathlib import Path -from typing import TextIO +from typing import IO, TextIO import anyio +from anyio import to_thread from anyio.abc import Process from anyio.streams.file import FileReadStream, FileWriteStream -from typing import Optional, TextIO, Union -from pathlib import Path - - def get_windows_executable_command(command: str) -> str: """ Get the correct executable command normalized for Windows. @@ -52,18 +49,18 @@ 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), + 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): - self.popen = popen_obj - self.stdin_raw = popen_obj.stdin - self.stdout_raw = popen_obj.stdout - self.stderr = popen_obj.stderr + def __init__(self, popen_obj: subprocess.Popen[bytes]): + self.popen: subprocess.Popen[bytes] = popen_obj + self.stdin_raw: IO[bytes] | None = popen_obj.stdin + self.stdout_raw: IO[bytes] | None = popen_obj.stdout + self.stderr: IO[bytes] | None = popen_obj.stderr - # Wrap into async-compatible AnyIO streams - self.stdin = FileWriteStream(self.stdin_raw) - self.stdout = FileReadStream(self.stdout_raw) + self.stdin = FileWriteStream(self.stdin_raw) if self.stdin_raw else None + self.stdout = FileReadStream(self.stdout_raw) if self.stdout_raw else None async def __aenter__(self): """Support async context manager entry.""" @@ -72,11 +69,11 @@ async def __aenter__(self): async def __aexit__(self, exc_type, exc_val, exc_tb): """Terminate and wait on process exit inside a thread.""" self.popen.terminate() - await anyio.to_thread.run_sync(self.popen.wait) + await to_thread.run_sync(self.popen.wait) async def wait(self): """Async wait for process completion.""" - return await anyio.to_thread.run_sync(self.popen.wait) + return await to_thread.run_sync(self.popen.wait) def terminate(self): """Terminate the subprocess immediately.""" @@ -89,10 +86,10 @@ def terminate(self): async def create_windows_process( command: str, args: list[str], - env: Optional[dict[str, str]] = None, - errlog: Optional[TextIO] = sys.stderr, - cwd: Union[Path, str, None] = None, -): + env: dict[str, str] | None = None, + errlog: TextIO | None = sys.stderr, + cwd: Path | str | None = None, +) -> DummyProcess: """ Creates a subprocess in a Windows-compatible way. @@ -120,7 +117,11 @@ async def create_windows_process( env=env, cwd=cwd, bufsize=0, # Unbuffered output - creationflags=subprocess.CREATE_NO_WINDOW if hasattr(subprocess, "CREATE_NO_WINDOW") else 0, + creationflags=( + subprocess.CREATE_NO_WINDOW + if hasattr(subprocess, "CREATE_NO_WINDOW") + else 0 + ), ) return DummyProcess(popen_obj) From a4c6500abde52a9857cdedb7008a19158796004b Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 19:00:49 +0530 Subject: [PATCH 3/7] style(win32): fix import sorting and formatting issues --- src/mcp/client/stdio/win32.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index 2b34181b5..56be0c3fc 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/client/stdio/win32.py @@ -13,6 +13,7 @@ from anyio.abc import Process from anyio.streams.file import FileReadStream, FileWriteStream + def get_windows_executable_command(command: str) -> str: """ Get the correct executable command normalized for Windows. @@ -44,15 +45,17 @@ def get_windows_executable_command(command: str) -> str: # (permissions, broken symlinks, etc.) return command + class DummyProcess: """ - A fallback process wrapper for Windows to handle async I/O + 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 + + 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: IO[bytes] | None = popen_obj.stdin @@ -79,10 +82,12 @@ def terminate(self): """Terminate the subprocess immediately.""" return self.popen.terminate() + # ------------------------ # Updated function # ------------------------ + async def create_windows_process( command: str, args: list[str], @@ -92,9 +97,9 @@ async def create_windows_process( ) -> DummyProcess: """ Creates a subprocess in a Windows-compatible way. - - On Windows, asyncio.create_subprocess_exec has incomplete support - (NotImplementedError when trying to open subprocesses). + + On Windows, asyncio.create_subprocess_exec has incomplete support + (NotImplementedError when trying to open subprocesses). Therefore, we fallback to subprocess.Popen and wrap it for async usage. Args: @@ -118,8 +123,8 @@ async def create_windows_process( cwd=cwd, bufsize=0, # Unbuffered output creationflags=( - subprocess.CREATE_NO_WINDOW - if hasattr(subprocess, "CREATE_NO_WINDOW") + subprocess.CREATE_NO_WINDOW + if hasattr(subprocess, "CREATE_NO_WINDOW") else 0 ), ) From 25596ab9cffee1025829f9f1e7252547f0a10fc3 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 19:25:52 +0530 Subject: [PATCH 4/7] style(stdio): format imports and wrap long lines for ruff compliance --- src/mcp/client/stdio/win32.py | 29 +++++++++++++++++------------ 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index 56be0c3fc..7b9c79e23 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/client/stdio/win32.py @@ -6,7 +6,7 @@ import subprocess import sys from pathlib import Path -from typing import IO, TextIO +from typing import BinaryIO, TextIO, cast import anyio from anyio import to_thread @@ -58,18 +58,27 @@ class DummyProcess: def __init__(self, popen_obj: subprocess.Popen[bytes]): self.popen: subprocess.Popen[bytes] = popen_obj - self.stdin_raw: IO[bytes] | None = popen_obj.stdin - self.stdout_raw: IO[bytes] | None = popen_obj.stdout - self.stderr: IO[bytes] | None = popen_obj.stderr + 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(self.stdin_raw) if self.stdin_raw else None - self.stdout = FileReadStream(self.stdout_raw) if self.stdout_raw else None + 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, exc_val, exc_tb): + 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) @@ -122,11 +131,7 @@ async def create_windows_process( env=env, cwd=cwd, bufsize=0, # Unbuffered output - creationflags=( - subprocess.CREATE_NO_WINDOW - if hasattr(subprocess, "CREATE_NO_WINDOW") - else 0 - ), + creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0), ) return DummyProcess(popen_obj) From fef614d8cfc2b48ffa302f54257ec8d09ab26fbf Mon Sep 17 00:00:00 2001 From: theailanguage Date: Thu, 29 May 2025 11:19:11 +0530 Subject: [PATCH 5/7] updated tests - ignored test_stdio_context_manager_exiting, test_stdio_client on windows due to tee command issues --- tests/client/test_stdio.py | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 1c6ffe000..850925d50 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -2,6 +2,8 @@ import pytest +import sys + from mcp.client.session import ClientSession from mcp.client.stdio import ( StdioServerParameters, @@ -14,16 +16,21 @@ tee: str = shutil.which("tee") # type: ignore python: str = shutil.which("python") # type: ignore - @pytest.mark.anyio -@pytest.mark.skipif(tee is None, reason="could not find tee command") +@pytest.mark.skipif( + tee is None or sys.platform.startswith("win"), + reason="tee command not available or platform is Windows" +) async def test_stdio_context_manager_exiting(): async with stdio_client(StdioServerParameters(command=tee)) as (_, _): pass @pytest.mark.anyio -@pytest.mark.skipif(tee is None, reason="could not find tee command") +@pytest.mark.skipif( + tee is None or sys.platform.startswith("win"), + reason="tee command not available or platform is Windows" +) async def test_stdio_client(): server_parameters = StdioServerParameters(command=tee) From 5db1b1029c4492ad5d2b484cde73d35e7f256ebb Mon Sep 17 00:00:00 2001 From: theailanguage Date: Thu, 29 May 2025 11:39:55 +0530 Subject: [PATCH 6/7] Revert "updated tests - ignored test_stdio_context_manager_exiting, test_stdio_client on windows due to tee command issues" This reverts commit fef614d8cfc2b48ffa302f54257ec8d09ab26fbf. --- tests/client/test_stdio.py | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 850925d50..1c6ffe000 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -2,8 +2,6 @@ import pytest -import sys - from mcp.client.session import ClientSession from mcp.client.stdio import ( StdioServerParameters, @@ -16,21 +14,16 @@ tee: str = shutil.which("tee") # type: ignore python: str = shutil.which("python") # type: ignore + @pytest.mark.anyio -@pytest.mark.skipif( - tee is None or sys.platform.startswith("win"), - reason="tee command not available or platform is Windows" -) +@pytest.mark.skipif(tee is None, reason="could not find tee command") async def test_stdio_context_manager_exiting(): async with stdio_client(StdioServerParameters(command=tee)) as (_, _): pass @pytest.mark.anyio -@pytest.mark.skipif( - tee is None or sys.platform.startswith("win"), - reason="tee command not available or platform is Windows" -) +@pytest.mark.skipif(tee is None, reason="could not find tee command") async def test_stdio_client(): server_parameters = StdioServerParameters(command=tee) From c8af6a10b321aaf7cfa4c3031121f4609a035cb8 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Thu, 29 May 2025 11:42:12 +0530 Subject: [PATCH 7/7] Revert "Merge branch 'main' into fix/windows_stdio_subprocess" This reverts commit d3e097502338040e972930bdcc00fb73ffc82f73, reversing changes made to 1c6c6fbdcfb191b7b5ee89a6762ad2337f61e4d0. --- README.md | 52 ++++++++++++-------------------- src/mcp/client/stdio/__init__.py | 50 +++++++++++------------------- tests/client/test_stdio.py | 50 ++---------------------------- 3 files changed, 40 insertions(+), 112 deletions(-) diff --git a/README.md b/README.md index d76d3d267..4c8bb90dc 100644 --- a/README.md +++ b/README.md @@ -315,42 +315,27 @@ async def long_task(files: list[str], ctx: Context) -> str: Authentication can be used by servers that want to expose tools accessing protected resources. `mcp.server.auth` implements an OAuth 2.0 server interface, which servers can use by -providing an implementation of the `OAuthAuthorizationServerProvider` protocol. +providing an implementation of the `OAuthServerProvider` protocol. -```python -from mcp import FastMCP -from mcp.server.auth.provider import OAuthAuthorizationServerProvider -from mcp.server.auth.settings import ( - AuthSettings, - ClientRegistrationOptions, - RevocationOptions, -) - - -class MyOAuthServerProvider(OAuthAuthorizationServerProvider): - # See an example on how to implement at `examples/servers/simple-auth` - ... - - -mcp = FastMCP( - "My App", - auth_server_provider=MyOAuthServerProvider(), - auth=AuthSettings( - issuer_url="https://myapp.com", - revocation_options=RevocationOptions( - enabled=True, - ), - client_registration_options=ClientRegistrationOptions( - enabled=True, - valid_scopes=["myscope", "myotherscope"], - default_scopes=["myscope"], +``` +mcp = FastMCP("My App", + auth_server_provider=MyOAuthServerProvider(), + auth=AuthSettings( + issuer_url="https://myapp.com", + revocation_options=RevocationOptions( + enabled=True, + ), + client_registration_options=ClientRegistrationOptions( + enabled=True, + valid_scopes=["myscope", "myotherscope"], + default_scopes=["myscope"], + ), + required_scopes=["myscope"], ), - required_scopes=["myscope"], - ), ) ``` -See [OAuthAuthorizationServerProvider](src/mcp/server/auth/provider.py) for more details. +See [OAuthServerProvider](src/mcp/server/auth/provider.py) for more details. ## Running Your Server @@ -477,12 +462,15 @@ For low level server with Streamable HTTP implementations, see: - Stateful server: [`examples/servers/simple-streamablehttp/`](examples/servers/simple-streamablehttp/) - Stateless server: [`examples/servers/simple-streamablehttp-stateless/`](examples/servers/simple-streamablehttp-stateless/) + + The streamable HTTP transport supports: - Stateful and stateless operation modes - Resumability with event stores -- JSON or SSE response formats +- JSON or SSE response formats - Better scalability for multi-node deployments + ### Mounting to an Existing ASGI Server > **Note**: SSE transport is being superseded by [Streamable HTTP transport](https://modelcontextprotocol.io/specification/2025-03-26/basic/transports#streamable-http). diff --git a/src/mcp/client/stdio/__init__.py b/src/mcp/client/stdio/__init__.py index fce605633..6d815b43a 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -108,28 +108,20 @@ async def stdio_client(server: StdioServerParameters, errlog: TextIO = sys.stder read_stream_writer, read_stream = anyio.create_memory_object_stream(0) write_stream, write_stream_reader = anyio.create_memory_object_stream(0) - try: - command = _get_executable_command(server.command) - - # Open process with stderr piped for capture - process = await _create_platform_compatible_process( - command=command, - args=server.args, - env=( - {**get_default_environment(), **server.env} - if server.env is not None - else get_default_environment() - ), - errlog=errlog, - cwd=server.cwd, - ) - except OSError: - # Clean up streams if process creation fails - await read_stream.aclose() - await write_stream.aclose() - await read_stream_writer.aclose() - await write_stream_reader.aclose() - raise + command = _get_executable_command(server.command) + + # Open process with stderr piped for capture + process = await _create_platform_compatible_process( + command=command, + args=server.args, + env=( + {**get_default_environment(), **server.env} + if server.env is not None + else get_default_environment() + ), + errlog=errlog, + cwd=server.cwd, + ) async def stdout_reader(): assert process.stdout, "Opened process is missing stdout" @@ -185,18 +177,12 @@ async def stdin_writer(): yield read_stream, write_stream finally: # Clean up process to prevent any dangling orphaned processes - try: - if sys.platform == "win32": - await terminate_windows_process(process) - else: - process.terminate() - except ProcessLookupError: - # Process already exited, which is fine - pass + if sys.platform == "win32": + await terminate_windows_process(process) + else: + process.terminate() await read_stream.aclose() await write_stream.aclose() - await read_stream_writer.aclose() - await write_stream_reader.aclose() def _get_executable_command(command: str) -> str: diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 1c6ffe000..33d90e769 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -2,17 +2,11 @@ import pytest -from mcp.client.session import ClientSession -from mcp.client.stdio import ( - StdioServerParameters, - stdio_client, -) -from mcp.shared.exceptions import McpError +from mcp.client.stdio import StdioServerParameters, stdio_client from mcp.shared.message import SessionMessage -from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse +from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse tee: str = shutil.which("tee") # type: ignore -python: str = shutil.which("python") # type: ignore @pytest.mark.anyio @@ -56,43 +50,3 @@ async def test_stdio_client(): assert read_messages[1] == JSONRPCMessage( root=JSONRPCResponse(jsonrpc="2.0", id=2, result={}) ) - - -@pytest.mark.anyio -async def test_stdio_client_bad_path(): - """Check that the connection doesn't hang if process errors.""" - server_params = StdioServerParameters( - command="python", args=["-c", "non-existent-file.py"] - ) - async with stdio_client(server_params) as (read_stream, write_stream): - async with ClientSession(read_stream, write_stream) as session: - # The session should raise an error when the connection closes - with pytest.raises(McpError) as exc_info: - await session.initialize() - - # Check that we got a connection closed error - assert exc_info.value.error.code == CONNECTION_CLOSED - assert "Connection closed" in exc_info.value.error.message - - -@pytest.mark.anyio -async def test_stdio_client_nonexistent_command(): - """Test that stdio_client raises an error for non-existent commands.""" - # Create a server with a non-existent command - server_params = StdioServerParameters( - command="/path/to/nonexistent/command", - args=["--help"], - ) - - # Should raise an error when trying to start the process - with pytest.raises(Exception) as exc_info: - async with stdio_client(server_params) as (_, _): - pass - - # The error should indicate the command was not found - error_message = str(exc_info.value) - assert ( - "nonexistent" in error_message - or "not found" in error_message.lower() - or "cannot find the file" in error_message.lower() # Windows error message - )