From 40347fd40d732bfad9b403cc224b2054becd22d5 Mon Sep 17 00:00:00 2001 From: Hermes Agent Date: Fri, 25 Sep 2026 11:37:53 -0500 Subject: [PATCH] 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 /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 Co-authored-by: Ignacio Rodriguez Co-authored-by: webtecnica --- tests/tools/test_mcp_tool_issue_948.py | 81 ++++++++++++++++++++++++++ tools/mcp_tool_config.py | 52 ++++++++++++----- 2 files changed, 118 insertions(+), 15 deletions(-) diff --git a/tests/tools/test_mcp_tool_issue_948.py b/tests/tools/test_mcp_tool_issue_948.py index e6a49c0664..ced0dda4e5 100644 --- a/tests/tools/test_mcp_tool_issue_948.py +++ b/tests/tools/test_mcp_tool_issue_948.py @@ -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 ``/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 diff --git a/tools/mcp_tool_config.py b/tools/mcp_tool_config.py index 781e1a6de1..9fe3e11cce 100644 --- a/tools/mcp_tool_config.py +++ b/tools/mcp_tool_config.py @@ -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 ``\\node``, POSIX into - ``/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 ``\\node``, POSIX into ``/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 ``/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)