fix(mcp): resolve managed Windows Node launchers (npx.cmd/npm.cmd) for stdio MCP servers

On Windows a stdio MCP server configured with `command: npx|npm|node` failed
with WinError 2 whenever the desktop/gateway PATH lacked the managed Node dir:
`_node_fallback` probed only the POSIX shape `<HERMES_HOME>/node/bin/<cmd>`
with no extension, while `scripts/install.ps1` unpacks Node directly into
`<HERMES_HOME>\node` as `npx.cmd`/`npm.cmd`/`node.exe`. It also derived the
home from raw `os.getenv("HERMES_HOME")`, so a context-local profile home
(multiplexed gateway) was ignored.

Reuse the platform-aware helpers instead of a second hand-rolled layout:
`hermes_constants.iter_hermes_node_dirs(get_hermes_home())` supplies both
managed shapes in the right order, and the module's own `_npx_bin_candidates`
supplies the `.cmd` -> `.exe` precedence (same injectable `windows=` seam the
npx-cache shortcut already uses, so the branch is testable on Linux CI).
POSIX candidates (`node/bin`, `~/.local/bin`, `/usr/local/bin`) are unchanged.

Slimmer redo of #111941 by @KoNit-K, which re-derived the Windows shape
in-place and kept the raw env read.

Fixes #111937

Co-authored-by: KoNit-K <124019182+KoNit-K@users.noreply.github.com>
This commit is contained in:
teknium1
2026-09-15 11:49:50 -07:00
committed by Teknium
parent a2837ec088
commit 8e11666726
2 changed files with 50 additions and 6 deletions

View File

@@ -7,7 +7,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
from tools.mcp_tool import MCPServerTask, _MCP_AVAILABLE
from tools.mcp_tool_errors import _format_connect_error
from tools.mcp_tool_config import _resolve_stdio_command
from tools.mcp_tool_config import _node_fallback, _resolve_stdio_command
# Ensure the mcp module symbols exist for patching even when the SDK isn't installed
if not _MCP_AVAILABLE:
@@ -35,6 +35,45 @@ def test_resolve_stdio_command_falls_back_to_hermes_node_bin(tmp_path):
assert env["PATH"].split(os.pathsep)[0] == str(node_bin)
def test_windows_managed_node_root_prefers_cmd_launchers(tmp_path):
"""Managed Windows Node lives directly in ``<HERMES_HOME>/node`` (no ``bin``) as ``npx.cmd`` /
``npm.cmd`` / ``node.exe``; a bare ``command: npx`` must resolve to those launchers (#111937).
The extensionless POSIX sibling is a shell script Windows cannot spawn, so it must never win."""
node_root = tmp_path / "node"
node_root.mkdir()
for name in ("npx", "npm", "npx.cmd", "npm.cmd", "node.exe"):
launcher = node_root / name
launcher.write_text("@echo off\r\n", encoding="utf-8")
launcher.chmod(0o755)
with patch.dict("os.environ", {"HERMES_HOME": str(tmp_path)}, clear=False):
assert _node_fallback("npx", windows=True) == str(node_root / "npx.cmd")
assert _node_fallback("npm", windows=True) == str(node_root / "npm.cmd")
assert _node_fallback("node", windows=True) == str(node_root / "node.exe")
def test_node_fallback_uses_active_profile_home(tmp_path, monkeypatch):
"""The managed-Node lookup follows ``get_hermes_home()`` (context override), not raw ``HERMES_HOME``:
a multiplexed profile whose home differs from the launch env must find ITS managed Node."""
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
profile_home = tmp_path / "profile"
npx_path = profile_home / "node" / "bin" / "npx"
npx_path.parent.mkdir(parents=True)
npx_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
npx_path.chmod(0o755)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "launch-home"))
monkeypatch.setenv("HOME", str(tmp_path / "user")) # keep a real ~/.local/bin/npx out of the picture
token = set_hermes_home_override(profile_home)
try:
with patch("tools.mcp_tool_config.shutil.which", return_value=None):
command, _env = _resolve_stdio_command("npx", {"PATH": "/usr/bin"})
finally:
reset_hermes_home_override(token)
assert command == str(npx_path)
def test_resolve_stdio_command_falls_back_to_usr_local_bin():
"""When ``npx`` isn't on the filtered PATH and isn't under ``$HERMES_HOME/node/bin``
or ``~/.local/bin``, the resolver should still locate it at ``/usr/local/bin/npx``.

View File

@@ -149,14 +149,19 @@ def _which_with_config_pathext(command: str, path_arg, env: dict):
os.environ["PATHEXT"] = saved
def _node_fallback(command: str) -> str:
"""Well-known Node install locations for bare ``npx``/``npm``/``node``; *command* unchanged when none exists."""
def _node_fallback(command: str, *, windows: Optional[bool] = None) -> str:
"""Well-known Node install locations for bare ``npx``/``npm``/``node``; *command* unchanged when none exists.
The managed tree comes from ``iter_hermes_node_dirs`` (Windows unpacks into ``<home>\\node``, POSIX into
``<home>/node/bin``) under the active profile's ``get_hermes_home()``; on Windows the real files are
``npx.cmd``/``node.exe`` (``windows`` injectable, as for ``_npx_bin_candidates``)."""
from hermes_constants import get_hermes_home, iter_hermes_node_dirs
home = os.path.expanduser("~")
hermes_home = os.path.expanduser(os.getenv("HERMES_HOME", os.path.join(home, ".hermes")))
# /usr/local/bin: canonical Node location (from-source Linux, Hermes Docker image, Intel Homebrew),
# needed when a hand-authored env.PATH omits it — npx's shebang re-execs /usr/bin/env node.
candidates = (os.path.join(hermes_home, "node", "bin", command), os.path.join(home, ".local", "bin", command),
os.path.join(os.sep, "usr", "local", "bin", command))
directories = [*map(str, iter_hermes_node_dirs(get_hermes_home())), os.path.join(home, ".local", "bin"),
os.path.join(os.sep, "usr", "local", "bin")]
candidates = (c for d in directories for c in _npx_bin_candidates(d, command, windows=windows))
return next((c for c in candidates if os.path.isfile(c) and os.access(c, os.X_OK)), command)