fix(mcp): resolve bare uv/uvx under GUI-style PATHs (#37589)

macOS Desktop/launchd processes inherit the bare /usr/bin:/bin:/usr/sbin:/sbin
PATH, which carries none of uv's install locations, so an MCP server configured
as `command: uvx` fails with ENOENT at execvp from Desktop even though it works
from an interactive terminal. The stdio resolver already falls back to
well-known install dirs for bare npx/npm/node; extend the same treatment to
uv/uvx, probing managed <home>/bin, ~/.local/bin (uv's installer default),
/opt/homebrew/bin and /usr/local/bin (Homebrew AS/Intel).

Design salvaged from #37665, #67125 and #67178.

Co-authored-by: Morad37 <mohamed.origami@gmail.com>
Co-authored-by: Ignacio Rodriguez <ignacio@agenticolabs.io>
Co-authored-by: webtecnica <webtecnica@users.noreply.github.com>
This commit is contained in:
Hermes Agent
2026-09-25 11:37:53 -05:00
committed by brooklyn!
parent f84db42a32
commit 40347fd40d
2 changed files with 118 additions and 15 deletions

View File

@@ -108,6 +108,87 @@ def test_resolve_stdio_command_falls_back_to_usr_local_bin():
assert env["PATH"].split(os.pathsep)[0] == os.path.dirname(target)
# ---------------------------------------------------------------------------
# #37589: Desktop/launchd processes inherit a minimal PATH on macOS that does
# not include ~/.local/bin, /opt/homebrew/bin, or /usr/local/bin. The resolver
# must locate bare uv/uvx (the dominant Python MCP-server runtime) under those
# locations instead of failing with ENOENT at execvp.
# ---------------------------------------------------------------------------
def test_resolve_stdio_command_finds_uvx_in_user_local_bin(tmp_path, monkeypatch):
"""uv's official installer drops uv/uvx at ``~/.local/bin/uvx`` on macOS and
Linux. The resolver must pick it up when the GUI PATH doesn't include that
directory (#37589)."""
local_bin = tmp_path / ".local" / "bin"
local_bin.mkdir(parents=True)
uvx_path = local_bin / "uvx"
uvx_path.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
uvx_path.chmod(0o755)
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HOME", str(tmp_path))
with patch("tools.mcp_tool_config.shutil.which", return_value=None):
command, env = _resolve_stdio_command("uvx", {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"})
assert command == str(uvx_path)
# The resolver prepended the chosen bin so uvx's sibling `uv` and its
# shebang-resolved children resolve in the same directory.
assert env["PATH"].split(os.pathsep)[0] == str(local_bin)
def test_resolve_stdio_command_uv_fallback_order(tmp_path, monkeypatch):
"""Bare uv/uvx probe the well-known install dirs in uv's install order:
managed ``<HERMES_HOME>/bin`` first, then ``~/.local/bin``, then Homebrew
(Apple Silicon, then Intel/from-source)."""
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes"))
monkeypatch.setenv("HOME", str(tmp_path / "user"))
monkeypatch.setattr("tools.mcp_tool_config.os.path.expanduser", lambda p: p.replace("~", str(tmp_path / "user")) if p.startswith("~") else p)
candidates = [
os.path.join(str(tmp_path / "hermes"), "bin", "uvx"),
os.path.join(str(tmp_path / "user"), ".local", "bin", "uvx"),
os.path.join(os.sep, "opt", "homebrew", "bin", "uvx"),
os.path.join(os.sep, "usr", "local", "bin", "uvx"),
]
seen = []
def _fake_access(path, mode):
assert mode == os.X_OK
seen.append(path)
return path == candidates[-1] # only /usr/local/bin exists
with patch("tools.mcp_tool_config.shutil.which", return_value=None), \
patch("tools.mcp_tool_config.os.path.isfile", return_value=True), \
patch("tools.mcp_tool_config.os.access", side_effect=_fake_access):
command, _env = _resolve_stdio_command("uvx", {"PATH": "/usr/bin"})
assert seen == candidates # every dir probed, in install order
assert command == candidates[-1]
def test_resolve_stdio_command_uvx_unchanged_when_already_on_path():
"""A shutil.which hit still takes precedence — don't double-resolve a working
bare command on the child's own PATH into something else."""
resolved_path = "/some/custom/bin/uvx"
with patch("tools.mcp_tool_config.shutil.which", return_value=resolved_path):
command, _env = _resolve_stdio_command("uvx", {"PATH": "/usr/bin"})
assert command == resolved_path
def test_resolve_stdio_command_skips_unknown_commands():
"""Bare command names outside the npx/npm/node/uv/uvx launcher set must NOT
be matched against the fallback paths — that would rewrite ``command:
my-tool`` into a coincidentally-named file at /opt/homebrew/bin/my-tool."""
with patch("tools.mcp_tool_config.shutil.which", return_value=None), \
patch("tools.mcp_tool_config.os.path.isfile", return_value=True), \
patch("tools.mcp_tool_config.os.access", return_value=True):
command, _env = _resolve_stdio_command("my-tool", {"PATH": "/usr/bin:/bin"})
assert command == "my-tool"
def test_resolve_stdio_command_absent_path_is_a_miss(tmp_path, monkeypatch):
"""A server env without PATH must not resolve commands against the PARENT's PATH:
the child would be spawned without it and the lookup would pass on an env the

View File

@@ -162,31 +162,53 @@ def _which_with_config_pathext(command: str, path_arg, env: dict):
return None
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.
def _launcher_fallback(command: str, *, windows: Optional[bool] = None) -> str:
"""Well-known install locations for bare launcher commands; *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
One resolver for two launcher families. ``npx``/``npm``/``node``: 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``).
``uv``/``uvx``: GUI launches (the Electron desktop app, macOS LaunchAgents) inherit the bare
``/usr/bin:/bin:/usr/sbin:/sbin`` PATH, which carries none of uv's install locations, so a bare
``command: uvx`` MCP server fails with ENOENT at ``execvp`` from Desktop even though it works
from an interactive terminal (#37589). Probed in the order uv's own docs install it: the
Hermes-managed ``<home>/bin`` first, then the per-user installer's ``~/.local/bin``, then
Homebrew (Apple Silicon ``/opt``, Intel ``/usr/local``)."""
from hermes_constants import get_hermes_home
home = os.path.expanduser("~")
# /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.
directories = [*map(str, iter_hermes_node_dirs(get_hermes_home())), os.path.join(home, ".local", "bin"),
os.path.join(os.sep, "usr", "local", "bin")]
if command in {"uv", "uvx"}:
directories = [
os.path.join(str(get_hermes_home()), "bin"),
os.path.join(home, ".local", "bin"), # uv's official installer
os.path.join(os.sep, "opt", "homebrew", "bin"), # Apple Silicon Homebrew
os.path.join(os.sep, "usr", "local", "bin"), # Intel Homebrew / from-source
]
else:
from hermes_constants import iter_hermes_node_dirs
# /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.
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)
# Historical name (tests and external callers import _node_fallback).
_node_fallback = _launcher_fallback
def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
"""Resolve a stdio command against the exact subprocess env (bare ``npx``/``npm``/``node`` under a filtered PATH).
"""Resolve a stdio command against the exact subprocess env (bare launchers under a filtered PATH).
A ``PATH`` lookup only runs when the child env actually carries one: ``shutil.which`` with
``path=None`` silently falls back to the PARENT's ``os.environ["PATH"]``, letting a command
"resolve" against an env the child will never be spawned with. An absent child PATH is a
miss; an explicitly empty one keeps its cwd-only meaning (same distinction the child's
``execvp`` will see). Bare ``npx``/``npm``/``node`` still fall through to the explicit
well-known Node directories, everything else stays as-written for an honest spawn failure."""
``execvp`` will see). Bare ``npx``/``npm``/``node``/``uv``/``uvx`` still fall through to
their explicit well-known install directories, everything else stays as-written for an
honest spawn failure."""
resolved_command = os.path.expanduser(str(command).strip())
resolved_env = dict(env or {})
if os.sep not in resolved_command:
@@ -196,8 +218,8 @@ def _resolve_stdio_command(command: str, env: dict) -> tuple[str, dict]:
which_hit = _which_with_config_pathext(resolved_command, path_arg, resolved_env)
if which_hit:
resolved_command = which_hit
elif resolved_command in {"npx", "npm", "node"}:
resolved_command = _node_fallback(resolved_command)
elif resolved_command in {"npx", "npm", "node", "uv", "uvx"}:
resolved_command = _launcher_fallback(resolved_command)
command_dir = os.path.dirname(resolved_command)
if command_dir:
resolved_env = _prepend_path(resolved_env, command_dir)