From 97f5f869ae60ab5ef86d444f7d06c7dedd57cfd8 Mon Sep 17 00:00:00 2001 From: dltsum Date: Sun, 13 Sep 2026 21:16:49 +0800 Subject: [PATCH] fix(stdio): launch PowerShell (.ps1) servers via a PowerShell host on Windows `get_windows_executable_command()` resolves commands to `.ps1` paths, but `CreateProcess` cannot execute a PowerShell script directly, so the spawn failed with WinError 193. Rewrite the argv in `create_windows_process` to route `.ps1` commands through `pwsh` (or `powershell` as fallback) with `-NoProfile -NonInteractive -ExecutionPolicy Bypass -File`, raising a clear FileNotFoundError when no PowerShell host is on PATH. Fixes #3496 --- src/mcp/os/win32/utilities.py | 21 ++++++++++++++++++ tests/transports/stdio/test_windows.py | 30 ++++++++++++++++++++++++++ 2 files changed, 51 insertions(+) diff --git a/src/mcp/os/win32/utilities.py b/src/mcp/os/win32/utilities.py index 321fda8a66..85c06d3084 100644 --- a/src/mcp/os/win32/utilities.py +++ b/src/mcp/os/win32/utilities.py @@ -152,6 +152,27 @@ async def create_windows_process( Returns: Process | FallbackProcess: The spawned process with async stdin/stdout streams. """ + # CreateProcess cannot run a PowerShell script directly (WinError 193); + # route it through a PowerShell host, preferring pwsh over Windows PowerShell. + if command.lower().endswith(".ps1"): + shell = shutil.which("pwsh") or shutil.which("powershell") + if shell is None: + raise FileNotFoundError( + f"Cannot launch {command!r}: it is a PowerShell script but neither" + " 'pwsh' nor 'powershell' was found on PATH." + ) + command, args = ( + shell, + [ + "-NoProfile", + "-NonInteractive", + "-ExecutionPolicy", + "Bypass", + "-File", + command, + *args, + ], + ) try: process = await anyio.open_process( [command, *args], diff --git a/tests/transports/stdio/test_windows.py b/tests/transports/stdio/test_windows.py index 8eb12832da..3c5f2c3fc3 100644 --- a/tests/transports/stdio/test_windows.py +++ b/tests/transports/stdio/test_windows.py @@ -172,6 +172,36 @@ async def test_a_native_server_emitting_crlf_line_endings_round_trips_messages() assert received.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={}) +async def test_a_powershell_script_server_round_trips_messages(tmp_path: Path) -> None: # pragma: no cover + """A stdio server whose command resolves to a `.ps1` script starts and answers. + + Regression for #3496: `CreateProcess` cannot run a `.ps1` directly + (WinError 193), so the spawn must route through a PowerShell host. + """ + script = tmp_path / "echo_server.ps1" + script.write_text( + "$line = [Console]::In.ReadLine()\n" + "$req = $line | ConvertFrom-Json\n" + "$resp = @{jsonrpc='2.0'; id=$req.id; result=@{}} | ConvertTo-Json -Compress\n" + "[Console]::Out.WriteLine($resp)\n" + "[Console]::Out.Flush()\n" + # Keep the process alive until the client closes stdin, like a real server. + "while ([Console]::In.ReadLine() -ne $null) {}\n", + encoding="utf-8", + ) + server_params = StdioServerParameters(command=str(script), args=[]) + + ping = JSONRPCRequest(jsonrpc="2.0", id=1, method="ping") + + # Allow one cold PowerShell start on loaded CI. + with anyio.fail_after(20.0): + async with stdio_client(server_params) as (read_stream, write_stream): + await write_stream.send(SessionMessage(ping)) + received = await read_stream.receive() + assert isinstance(received, SessionMessage) + assert received.message == JSONRPCResponse(jsonrpc="2.0", id=1, result={}) + + async def test_a_tool_spawned_python_child_with_default_stdin_completes_promptly() -> None: # pragma: no cover """A tool that runs a Python subprocess without redirecting stdin returns promptly.