From 9f5eecd8b373d268ee4eafc2b432dcc3a7803b48 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 17:31:04 +0530 Subject: [PATCH 01/14] Fix Windows subprocess compatibility for STDIO mode with async streams --- src/mcp/client/stdio/win32.py | 99 ++++++++++++++++++++++++++--------- 1 file changed, 75 insertions(+), 24 deletions(-) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index e4f252dc9..4853fddbe 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,48 +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 - 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) - return process + return DummyProcess(popen_obj) + except Exception: + # 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 DummyProcess(popen_obj) async def terminate_windows_process(process: Process): """ From 306b28dcf75b4bf380050781fd0828a65991b015 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 18:47:21 +0530 Subject: [PATCH 02/14] 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 4853fddbe..b1cb23dc6 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 2f26efd414aa04e07d051cd71b2415942f546586 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 19:00:49 +0530 Subject: [PATCH 03/14] 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 b1cb23dc6..5261322e6 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 5b98439b61b9643d849c004848c3e2af2c6ddbfc Mon Sep 17 00:00:00 2001 From: theailanguage Date: Mon, 28 Apr 2025 19:25:52 +0530 Subject: [PATCH 04/14] 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 5261322e6..97418056d 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 02b3d3c3802bb4b7145c7f7a85bce1da1f56c06e Mon Sep 17 00:00:00 2001 From: theailanguage Date: Thu, 29 May 2025 11:19:11 +0530 Subject: [PATCH 05/14] 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 c66a16ab9..7a147b9ab 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 a550b021a9cc5dc020fb702aa33a5a28a78c8158 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Thu, 29 May 2025 11:39:55 +0530 Subject: [PATCH 06/14] 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 7a147b9ab..c66a16ab9 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 5815e6c026c888c29c465aacd101cc2939be02f7 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Thu, 29 May 2025 11:42:12 +0530 Subject: [PATCH 07/14] fix: rebase on main and resolve merge conflicts in stdio subprocess logic Revert "Merge branch 'main' into fix/windows_stdio_subprocess" This reverts commit d3e097502338040e972930bdcc00fb73ffc82f73, reversing changes made to 1c6c6fbdcfb191b7b5ee89a6762ad2337f61e4d0. --- README.md | 5 ++++- src/mcp/client/stdio/__init__.py | 17 +++++------------ tests/client/test_stdio.py | 10 ++-------- 3 files changed, 11 insertions(+), 21 deletions(-) diff --git a/README.md b/README.md index 8a009108b..713d83e92 100644 --- a/README.md +++ b/README.md @@ -585,12 +585,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 a75cfd764..2faa6d44d 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -108,8 +108,7 @@ 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) + command = _get_executable_command(server.command) # Open process with stderr piped for capture process = await _create_platform_compatible_process( @@ -179,18 +178,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 c66a16ab9..1b77a1c79 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 From dddff2b7d5e0252841965e68f7fb1e27092e712c Mon Sep 17 00:00:00 2001 From: theailanguage Date: Tue, 24 Jun 2025 14:49:48 +0530 Subject: [PATCH 08/14] Fix Windows compatibility for STDIO with async DummyProcess --- src/mcp/client/stdio/__init__.py | 3 ++- src/mcp/client/stdio/win32.py | 17 +++++++++-------- tests/client/test_stdio.py | 10 ++++++++-- 3 files changed, 19 insertions(+), 11 deletions(-) diff --git a/src/mcp/client/stdio/__init__.py b/src/mcp/client/stdio/__init__.py index 2faa6d44d..2ba2ee52a 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -108,7 +108,8 @@ 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) - command = _get_executable_command(server.command) + try: + command = _get_executable_command(server.command) # Open process with stderr piped for capture process = await _create_platform_compatible_process( diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index 97418056d..3b3f51731 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/client/stdio/win32.py @@ -62,12 +62,8 @@ def __init__(self, popen_obj: subprocess.Popen[bytes]): 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 - ) + 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.""" @@ -91,6 +87,10 @@ 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 @@ -131,7 +131,7 @@ async def create_windows_process( env=env, cwd=cwd, bufsize=0, # Unbuffered output - creationflags = getattr(subprocess, "CREATE_NO_WINDOW", 0), + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), ) return DummyProcess(popen_obj) @@ -148,7 +148,8 @@ async def create_windows_process( ) return DummyProcess(popen_obj) -async def terminate_windows_process(process: Process): + +async def terminate_windows_process(process: Process | DummyProcess): """ Terminate a Windows process. diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index 1b77a1c79..c66a16ab9 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -2,11 +2,17 @@ import pytest -from mcp.client.stdio import StdioServerParameters, stdio_client +from mcp.client.session import ClientSession +from mcp.client.stdio import ( + StdioServerParameters, + stdio_client, +) +from mcp.shared.exceptions import McpError from mcp.shared.message import SessionMessage -from mcp.types import JSONRPCMessage, JSONRPCRequest, JSONRPCResponse +from mcp.types import CONNECTION_CLOSED, JSONRPCMessage, JSONRPCRequest, JSONRPCResponse tee: str = shutil.which("tee") # type: ignore +python: str = shutil.which("python") # type: ignore @pytest.mark.anyio From 9e38ab5d250c75f194b77329f69c85cff4acde66 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Tue, 24 Jun 2025 14:56:16 +0530 Subject: [PATCH 09/14] added 6a703a4 closing file handles in __aexit__ in DummyProcess - Fix ResourceWarning by properly closing file handles in DummyProcess --- src/mcp/client/stdio/win32.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index 3b3f51731..050780ece 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/client/stdio/win32.py @@ -78,6 +78,18 @@ async def __aexit__( """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.""" From 249d81e2c4a5265da54e4589d8a134fd7db2b76e Mon Sep 17 00:00:00 2001 From: theailanguage Date: Tue, 24 Jun 2025 14:58:22 +0530 Subject: [PATCH 10/14] pre_commit run issues fixed --- src/mcp/client/stdio/win32.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/mcp/client/stdio/win32.py b/src/mcp/client/stdio/win32.py index 050780ece..1c9b9b94c 100644 --- a/src/mcp/client/stdio/win32.py +++ b/src/mcp/client/stdio/win32.py @@ -78,7 +78,7 @@ async def __aexit__( """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() From d5e46c6f5e7a382346e633fbbd9e4906c6dae08e Mon Sep 17 00:00:00 2001 From: theailanguage Date: Tue, 24 Jun 2025 16:01:08 +0530 Subject: [PATCH 11/14] Removed -c to simulate a real file not found error instead of executing a bad inline script. This ensures the subprocess fails to launch, which better matches the test's intent. --- tests/client/test_stdio.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/tests/client/test_stdio.py b/tests/client/test_stdio.py index c66a16ab9..f451a21b1 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -57,7 +57,12 @@ async def test_stdio_client(): @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"]) + + # Removed `-c` to simulate a real "file not found" error instead of + # executing a bad inline script. + # This ensures the subprocess fails to launch, which better matches + # the test's intent. + server_params = StdioServerParameters(command="python", args=["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 From 0629a3fb3146d08ebbc18b294fb904e7df84fd6a Mon Sep 17 00:00:00 2001 From: theailanguage Date: Tue, 24 Jun 2025 16:09:57 +0530 Subject: [PATCH 12/14] On Linux, terminating a process that has already exited raises ProcessLookupError. This can happen if the command failed to launch properly (as in this test case). --- src/mcp/client/stdio/__init__.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/mcp/client/stdio/__init__.py b/src/mcp/client/stdio/__init__.py index 2ba2ee52a..f152db4a8 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -182,7 +182,13 @@ async def stdin_writer(): if sys.platform == "win32": await terminate_windows_process(process) else: - process.terminate() + # On Linux, terminating a process that has already exited raises ProcessLookupError. + # This can happen if the command failed to launch properly (as in this test case). + try: + process.terminate() + except ProcessLookupError: + # Process already exited — safe to ignore + pass await read_stream.aclose() await write_stream.aclose() From c8eaebaf593fc4bdf0f8f6e8e9870ceba1dddca8 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Tue, 24 Jun 2025 17:49:30 +0530 Subject: [PATCH 13/14] Revert README, stdio/__init__.py, and test_stdio.py to match upstream main --- README.md | 5 +---- src/mcp/client/stdio/__init__.py | 18 +++++++++--------- tests/client/test_stdio.py | 7 +------ 3 files changed, 11 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 713d83e92..8a009108b 100644 --- a/README.md +++ b/README.md @@ -585,15 +585,12 @@ 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 f152db4a8..a75cfd764 100644 --- a/src/mcp/client/stdio/__init__.py +++ b/src/mcp/client/stdio/__init__.py @@ -179,18 +179,18 @@ async def stdin_writer(): yield read_stream, write_stream finally: # Clean up process to prevent any dangling orphaned processes - if sys.platform == "win32": - await terminate_windows_process(process) - else: - # On Linux, terminating a process that has already exited raises ProcessLookupError. - # This can happen if the command failed to launch properly (as in this test case). - try: + try: + if sys.platform == "win32": + await terminate_windows_process(process) + else: process.terminate() - except ProcessLookupError: - # Process already exited — safe to ignore - pass + except ProcessLookupError: + # Process already exited, which is fine + pass 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 f451a21b1..c66a16ab9 100644 --- a/tests/client/test_stdio.py +++ b/tests/client/test_stdio.py @@ -57,12 +57,7 @@ async def test_stdio_client(): @pytest.mark.anyio async def test_stdio_client_bad_path(): """Check that the connection doesn't hang if process errors.""" - - # Removed `-c` to simulate a real "file not found" error instead of - # executing a bad inline script. - # This ensures the subprocess fails to launch, which better matches - # the test's intent. - server_params = StdioServerParameters(command="python", args=["non-existent-file.py"]) + 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 From d9863c31a0e1084e33ddd59c8e716f7b55715a73 Mon Sep 17 00:00:00 2001 From: theailanguage Date: Tue, 24 Jun 2025 18:07:21 +0530 Subject: [PATCH 14/14] emtpy commit to retrigger checks