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:
@@ -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``.
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user