Merge pull request #3379 from lahuman/fix/3324-windows-mcp-stdio

fix(mcp): avoid WinError 193 for Windows stdio launchers

Co-authored-by: lahuman <6156679+lahuman@users.noreply.github.com>
This commit is contained in:
Xubin Ren 2026-04-22 08:09:46 +00:00
commit 7c21349828
2 changed files with 187 additions and 3 deletions

View File

@ -1,6 +1,8 @@
"""MCP client: connects to MCP servers and wraps their tools as native nanobot tools.""" """MCP client: connects to MCP servers and wraps their tools as native nanobot tools."""
import asyncio import asyncio
import os
import shutil
from contextlib import AsyncExitStack from contextlib import AsyncExitStack
from typing import Any from typing import Any
@ -24,12 +26,50 @@ _TRANSIENT_EXC_NAMES: frozenset[str] = frozenset((
"ConnectionError", "ConnectionError",
)) ))
_WINDOWS_SHELL_LAUNCHERS: frozenset[str] = frozenset(("npx", "npm", "pnpm", "yarn", "bunx"))
def _is_transient(exc: BaseException) -> bool: def _is_transient(exc: BaseException) -> bool:
"""Check if an exception looks like a transient connection error.""" """Check if an exception looks like a transient connection error."""
return type(exc).__name__ in _TRANSIENT_EXC_NAMES return type(exc).__name__ in _TRANSIENT_EXC_NAMES
def _windows_command_basename(command: str) -> str:
"""Return the lowercase basename for a Windows command or path."""
return command.replace("\\", "/").rsplit("/", maxsplit=1)[-1].lower()
def _normalize_windows_stdio_command(
command: str,
args: list[str] | None,
env: dict[str, str] | None,
) -> tuple[str, list[str], dict[str, str] | None]:
"""Wrap Windows shell launchers so MCP stdio servers start reliably."""
normalized_args = list(args or [])
if os.name != "nt":
return command, normalized_args, env
basename = _windows_command_basename(command)
if basename in {"cmd", "cmd.exe", "powershell", "powershell.exe", "pwsh", "pwsh.exe"}:
return command, normalized_args, env
if basename.endswith((".exe", ".com")):
return command, normalized_args, env
resolved = shutil.which(command, path=(env or {}).get("PATH")) or command
resolved_basename = _windows_command_basename(resolved)
should_wrap = (
basename in _WINDOWS_SHELL_LAUNCHERS
or basename.endswith((".cmd", ".bat"))
or resolved_basename.endswith((".cmd", ".bat"))
)
if not should_wrap:
return command, normalized_args, env
comspec = (env or {}).get("COMSPEC") or os.environ.get("COMSPEC") or "cmd.exe"
return comspec, ["/d", "/c", command, *normalized_args], env
def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None: def _extract_nullable_branch(options: Any) -> tuple[dict[str, Any], bool] | None:
"""Return the single non-null branch for nullable unions.""" """Return the single non-null branch for nullable unions."""
if not isinstance(options, list): if not isinstance(options, list):
@ -416,8 +456,15 @@ async def connect_mcp_servers(
return name, None return name, None
if transport_type == "stdio": if transport_type == "stdio":
command, args, env = _normalize_windows_stdio_command(
cfg.command,
cfg.args,
cfg.env or None,
)
params = StdioServerParameters( params = StdioServerParameters(
command=cfg.command, args=cfg.args, env=cfg.env or None command=command,
args=args,
env=env,
) )
read, write = await server_stack.enter_async_context(stdio_client(params)) read, write = await server_stack.enter_async_context(stdio_client(params))
elif transport_type == "sse": elif transport_type == "sse":

View File

@ -1,16 +1,18 @@
from __future__ import annotations from __future__ import annotations
import asyncio import asyncio
from contextlib import AsyncExitStack, asynccontextmanager
import sys import sys
from contextlib import asynccontextmanager
from types import ModuleType, SimpleNamespace from types import ModuleType, SimpleNamespace
import pytest import pytest
import nanobot.agent.tools.mcp as mcp_mod
from nanobot.agent.tools.mcp import ( from nanobot.agent.tools.mcp import (
MCPResourceWrapper,
MCPPromptWrapper, MCPPromptWrapper,
MCPResourceWrapper,
MCPToolWrapper, MCPToolWrapper,
_normalize_windows_stdio_command,
connect_mcp_servers, connect_mcp_servers,
) )
from nanobot.agent.tools.registry import ToolRegistry from nanobot.agent.tools.registry import ToolRegistry
@ -178,6 +180,99 @@ def test_wrapper_normalizes_nullable_property_anyof() -> None:
} }
def test_normalize_windows_stdio_command_is_noop_off_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(mcp_mod.os, "name", "posix", raising=False)
command, args, env = _normalize_windows_stdio_command(
"npx",
["-y", "chrome-devtools-mcp@latest"],
{"FOO": "bar"},
)
assert command == "npx"
assert args == ["-y", "chrome-devtools-mcp@latest"]
assert env == {"FOO": "bar"}
def test_normalize_windows_stdio_command_wraps_npx_on_windows(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(mcp_mod.os, "name", "nt", raising=False)
monkeypatch.setattr(
mcp_mod.shutil,
"which",
lambda command, path=None: r"C:\Program Files\nodejs\npx.cmd",
)
monkeypatch.setenv("COMSPEC", r"C:\Windows\System32\cmd.exe")
command, args, env = _normalize_windows_stdio_command(
"npx",
["-y", "chrome-devtools-mcp@latest"],
None,
)
assert command == r"C:\Windows\System32\cmd.exe"
assert args == ["/d", "/c", "npx", "-y", "chrome-devtools-mcp@latest"]
assert env is None
def test_normalize_windows_stdio_command_wraps_resolved_cmd_launcher(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(mcp_mod.os, "name", "nt", raising=False)
def _fake_which(command: str, path: str | None = None) -> str:
assert command == "custom-launcher"
assert path == r"C:\Tools"
return r"C:\Tools\custom-launcher.cmd"
monkeypatch.setattr(mcp_mod.shutil, "which", _fake_which)
monkeypatch.setenv("COMSPEC", r"C:\Windows\System32\cmd.exe")
command, args, _env = _normalize_windows_stdio_command(
"custom-launcher",
["serve"],
{"PATH": r"C:\Tools"},
)
assert command == r"C:\Windows\System32\cmd.exe"
assert args == ["/d", "/c", "custom-launcher", "serve"]
def test_normalize_windows_stdio_command_keeps_real_executables_unchanged(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(mcp_mod.os, "name", "nt", raising=False)
command, args, env = _normalize_windows_stdio_command(
"python.exe",
["-m", "http.server"],
{"FOO": "bar"},
)
assert command == "python.exe"
assert args == ["-m", "http.server"]
assert env == {"FOO": "bar"}
def test_normalize_windows_stdio_command_skips_existing_shells(
monkeypatch: pytest.MonkeyPatch,
) -> None:
monkeypatch.setattr(mcp_mod.os, "name", "nt", raising=False)
command, args, env = _normalize_windows_stdio_command(
"cmd.exe",
["/c", "echo", "hello"],
None,
)
assert command == "cmd.exe"
assert args == ["/c", "echo", "hello"]
assert env is None
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_execute_returns_text_blocks() -> None: async def test_execute_returns_text_blocks() -> None:
async def call_tool(_name: str, arguments: dict) -> object: async def call_tool(_name: str, arguments: dict) -> object:
@ -423,6 +518,48 @@ async def test_connect_mcp_servers_one_failure_does_not_block_others(
assert set(stacks) == {"good"} assert set(stacks) == {"good"}
@pytest.mark.asyncio
async def test_connect_mcp_servers_wraps_windows_stdio_launchers(
fake_mcp_runtime: dict[str, object | None],
monkeypatch: pytest.MonkeyPatch,
) -> None:
fake_mcp_runtime["session"] = _make_fake_session(["demo"])
captured: dict[str, object] = {}
@asynccontextmanager
async def _capturing_stdio_client(params: object):
captured["command"] = params.command
captured["args"] = params.args
captured["env"] = params.env
yield object(), object()
monkeypatch.setattr(mcp_mod.os, "name", "nt", raising=False)
monkeypatch.setattr(
mcp_mod.shutil,
"which",
lambda command, path=None: r"C:\Program Files\nodejs\npx.cmd",
)
monkeypatch.setenv("COMSPEC", r"C:\Windows\System32\cmd.exe")
monkeypatch.setattr(sys.modules["mcp.client.stdio"], "stdio_client", _capturing_stdio_client)
registry = ToolRegistry()
stacks = await connect_mcp_servers(
{
"test": MCPServerConfig(
command="npx",
args=["-y", "chrome-devtools-mcp@latest"],
)
},
registry,
)
for stack in stacks.values():
await stack.aclose()
assert captured["command"] == r"C:\Windows\System32\cmd.exe"
assert captured["args"] == ["/d", "/c", "npx", "-y", "chrome-devtools-mcp@latest"]
assert captured["env"] is None
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# MCPResourceWrapper tests # MCPResourceWrapper tests
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------