diff --git a/README.v2.md b/README.v2.md index 55d867586..2ebe35753 100644 --- a/README.v2.md +++ b/README.v2.md @@ -124,6 +124,13 @@ Alternatively, for projects using pip for dependencies: pip install "mcp[cli]" ``` +On Windows, add the `stdio` extra if you want `pywin32`-backed Job Object cleanup for subprocesses spawned through `mcp.client.stdio`: + +```bash +uv add "mcp[cli,stdio]" +pip install "mcp[cli,stdio]" +``` + ### Running the standalone MCP development tools To run the mcp command with uv: diff --git a/docs/installation.md b/docs/installation.md index f39846235..7701c4339 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -24,8 +24,10 @@ The following dependencies are automatically installed: - [`pydantic-settings`](https://pypi.org/project/pydantic-settings/): Settings management used in MCPServer. - [`uvicorn`](https://pypi.org/project/uvicorn/): ASGI server used to run the HTTP transport endpoints. - [`jsonschema`](https://pypi.org/project/jsonschema/): JSON schema validation. -- [`pywin32`](https://pypi.org/project/pywin32/): Windows specific dependencies for the CLI tools. This package has the following optional groups: - `cli`: Installs `typer` and `python-dotenv` for the MCP CLI tools. +- `stdio`: Installs [`pywin32`](https://pypi.org/project/pywin32/) on Windows for Job Object based subprocess cleanup in `mcp.client.stdio`. + +Server-only Windows installs can use the base `mcp` package (or `mcp[cli]`) without pulling in `pywin32`. Add the `stdio` extra only if you want the additional Windows stdio cleanup integration. diff --git a/pyproject.toml b/pyproject.toml index e1b19e3c9..591ea86fe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,6 @@ dependencies = [ "pydantic-settings>=2.5.2", "uvicorn>=0.31.1; sys_platform != 'emscripten'", "jsonschema>=4.20.0", - "pywin32>=311; sys_platform == 'win32'", "pyjwt[crypto]>=2.10.1", "typing-extensions>=4.13.0", "typing-inspection>=0.4.1", @@ -45,6 +44,7 @@ dependencies = [ [project.optional-dependencies] rich = ["rich>=13.9.4"] cli = ["typer>=0.16.0", "python-dotenv>=1.0.0"] +stdio = ["pywin32>=311; sys_platform == 'win32'"] ws = ["websockets>=15.0.1"] [project.scripts] @@ -56,8 +56,8 @@ required-version = ">=0.9.5" [dependency-groups] dev = [ - # We add mcp[cli,ws] so `uv sync` considers the extras. - "mcp[cli,ws]", + # We add mcp[cli,stdio,ws] so `uv sync` considers the extras. + "mcp[cli,stdio,ws]", "pyright>=1.1.400", "pytest>=8.3.4", "ruff>=0.8.5", diff --git a/src/mcp/__init__.py b/src/mcp/__init__.py index 4b5caa9cc..b0b7f2d6a 100644 --- a/src/mcp/__init__.py +++ b/src/mcp/__init__.py @@ -1,7 +1,8 @@ +from importlib import import_module +from typing import TYPE_CHECKING, Any + from .client.client import Client from .client.session import ClientSession -from .client.session_group import ClientSessionGroup -from .client.stdio import StdioServerParameters, stdio_client from .server.session import ServerSession from .server.stdio import stdio_server from .shared.exceptions import MCPError, UrlElicitationRequiredError @@ -65,6 +66,10 @@ ) from .types import Role as SamplingRole +if TYPE_CHECKING: + from .client.session_group import ClientSessionGroup + from .client.stdio import StdioServerParameters, stdio_client + __all__ = [ "CallToolRequest", "Client", @@ -133,3 +138,19 @@ "stdio_client", "stdio_server", ] + +_LAZY_EXPORTS = { + "ClientSessionGroup": (".client.session_group", "ClientSessionGroup"), + "StdioServerParameters": (".client.stdio", "StdioServerParameters"), + "stdio_client": (".client.stdio", "stdio_client"), +} + + +def __getattr__(name: str) -> Any: + if name not in _LAZY_EXPORTS: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + module_name, export_name = _LAZY_EXPORTS[name] + value = getattr(import_module(module_name, __name__), export_name) + globals()[name] = value + return value diff --git a/src/mcp/os/win32/utilities.py b/src/mcp/os/win32/utilities.py index 6f68405f7..b67baa30b 100644 --- a/src/mcp/os/win32/utilities.py +++ b/src/mcp/os/win32/utilities.py @@ -17,10 +17,16 @@ # Windows-specific imports for Job Objects if sys.platform == "win32": - import pywintypes - import win32api - import win32con - import win32job + try: + import pywintypes + import win32api + import win32con + import win32job + except ImportError: + win32api = None + win32con = None + win32job = None + pywintypes = None else: # Type stubs for non-Windows platforms win32api = None diff --git a/tests/issues/test_2233_windows_stdio_imports.py b/tests/issues/test_2233_windows_stdio_imports.py new file mode 100644 index 000000000..cdb054962 --- /dev/null +++ b/tests/issues/test_2233_windows_stdio_imports.py @@ -0,0 +1,64 @@ +"""Regression tests for issue #2233: stdio imports should stay optional on Windows.""" + +import os +import subprocess +import sys +import textwrap +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parents[2] + + +def _run_repo_python(script: str) -> subprocess.CompletedProcess[str]: + env = os.environ.copy() + src_path = str(REPO_ROOT / "src") + env["PYTHONPATH"] = src_path if "PYTHONPATH" not in env else os.pathsep.join([src_path, env["PYTHONPATH"]]) + return subprocess.run( + [sys.executable, "-c", script], + cwd=REPO_ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + +def test_server_stdio_import_does_not_load_client_stdio(): + script = textwrap.dedent(""" + import importlib + import sys + + importlib.import_module("mcp.server.stdio") + assert "mcp.client.stdio" not in sys.modules + """) + + result = _run_repo_python(script) + + assert result.returncode == 0, result.stderr + + +def test_root_stdio_exports_handle_missing_pywin32(): + script = textwrap.dedent(""" + import builtins + + real_import = builtins.__import__ + blocked_modules = {"pywintypes", "win32api", "win32con", "win32job"} + + def guarded_import(name, globals=None, locals=None, fromlist=(), level=0): + if name.split(".", 1)[0] in blocked_modules: + raise ImportError(f"blocked import: {name}") + return real_import(name, globals, locals, fromlist, level) + + builtins.__import__ = guarded_import + try: + from mcp import StdioServerParameters, stdio_client + + assert StdioServerParameters.__name__ == "StdioServerParameters" + assert callable(stdio_client) + finally: + builtins.__import__ = real_import + """) + + result = _run_repo_python(script) + + assert result.returncode == 0, result.stderr diff --git a/uv.lock b/uv.lock index 8f9a5396a..e87804ae2 100644 --- a/uv.lock +++ b/uv.lock @@ -801,7 +801,6 @@ dependencies = [ { name = "pydantic-settings" }, { name = "pyjwt", extra = ["crypto"] }, { name = "python-multipart" }, - { name = "pywin32", marker = "sys_platform == 'win32'" }, { name = "sse-starlette" }, { name = "starlette" }, { name = "typing-extensions" }, @@ -817,6 +816,9 @@ cli = [ rich = [ { name = "rich" }, ] +stdio = [ + { name = "pywin32", marker = "sys_platform == 'win32'" }, +] ws = [ { name = "websockets" }, ] @@ -826,7 +828,7 @@ dev = [ { name = "coverage", extra = ["toml"] }, { name = "dirty-equals" }, { name = "inline-snapshot" }, - { name = "mcp", extra = ["cli", "ws"] }, + { name = "mcp", extra = ["cli", "stdio", "ws"] }, { name = "pillow" }, { name = "pyright" }, { name = "pytest" }, @@ -858,7 +860,7 @@ requires-dist = [ { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10.1" }, { name = "python-dotenv", marker = "extra == 'cli'", specifier = ">=1.0.0" }, { name = "python-multipart", specifier = ">=0.0.9" }, - { name = "pywin32", marker = "sys_platform == 'win32'", specifier = ">=311" }, + { name = "pywin32", marker = "sys_platform == 'win32' and extra == 'stdio'", specifier = ">=311" }, { name = "rich", marker = "extra == 'rich'", specifier = ">=13.9.4" }, { name = "sse-starlette", specifier = ">=3.0.0" }, { name = "starlette", marker = "python_full_version < '3.14'", specifier = ">=0.27" }, @@ -869,14 +871,14 @@ requires-dist = [ { name = "uvicorn", marker = "sys_platform != 'emscripten'", specifier = ">=0.31.1" }, { name = "websockets", marker = "extra == 'ws'", specifier = ">=15.0.1" }, ] -provides-extras = ["cli", "rich", "ws"] +provides-extras = ["cli", "rich", "stdio", "ws"] [package.metadata.requires-dev] dev = [ { name = "coverage", extras = ["toml"], specifier = ">=7.10.7,<=7.13" }, { name = "dirty-equals", specifier = ">=0.9.0" }, { name = "inline-snapshot", specifier = ">=0.23.0" }, - { name = "mcp", extras = ["cli", "ws"], editable = "." }, + { name = "mcp", extras = ["cli", "stdio", "ws"], editable = "." }, { name = "pillow", specifier = ">=12.0" }, { name = "pyright", specifier = ">=1.1.400" }, { name = "pytest", specifier = ">=8.3.4" },