fix(mcp): resolve bare npx/npm/node against /usr/local/bin#34186
Merged
Conversation
When the Hermes Docker image runs an stdio MCP server configured with an
explicit env.PATH that omits /usr/local/bin (a common pattern when users
hand-author PATH for sandboxing), the MCP env-filter passes that narrow
PATH straight through to the subprocess. _resolve_stdio_command's
fallback for bare 'npx' / 'npm' / 'node' commands only checked
$HERMES_HOME/node/bin/ and ~/.local/bin/, so execvp() failed with
'[Errno 2] No such file or directory: npx' on every Node-based stdio
MCP server (Railway, Anthropic, GitHub Copilot, etc.).
The naive workaround — symlink /usr/local/bin/npx into the user's PATH —
fails one layer deeper because npx's shebang re-execs /usr/bin/env node
and node also lives at /usr/local/bin/node.
Fix: add /usr/local/bin/<cmd> as a third candidate in the fallback list.
This is the canonical install location for Node on:
- Linux from-source builds
- the upstream node:bookworm-slim image, which the Hermes Docker
image copies node + npm + corepack from since #4977 (the Node 22 LTS
refactor that exposed this)
- macOS Homebrew on Intel
Because the resolver already calls _prepend_path(resolved_env, command_dir)
after locating the command, /usr/local/bin gets prepended to the env's
PATH automatically, which also fixes the second-layer shebang failure
(npx-cli.js can now find node).
Scope is intentionally narrow: the fix activates only when the bare
command isn't otherwise locatable through the user's PATH. Users who
explicitly narrowed PATH for a non-Node MCP server see no change in
behavior.
Tested:
- tests/tools/test_mcp_tool_issue_948.py: new test
test_resolve_stdio_command_falls_back_to_usr_local_bin (mirrors the
existing hermes-node-bin fallback test)
- Full MCP test suite: 254/254 pass across 7 test files
- E2E against a freshly-built Docker image: reproduced the original
failure mode (env.PATH=/opt/data/bin:/usr/bin:/bin), confirmed the
resolver returns /usr/local/bin/npx and prepends /usr/local/bin to
PATH; subprocess.run of the resolved command prints '10.9.8' and
exits 0 with empty stderr
- Negative E2E on the host (where Node is already on PATH via mise):
resolver still hits the mise install dir, /usr/local/bin candidate
is not consulted, PATH is unchanged
Contributor
🔎 Lint report:
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
When the Hermes Docker image runs an stdio MCP server configured with an explicit
env.PATHthat omits/usr/local/bin(a common pattern when users hand-author PATH for sandboxing), every Node-based stdio MCP server fails with:The MCP env-filter passes the narrow user PATH straight through to the subprocess.
_resolve_stdio_command's fallback for barenpx/npm/nodecommands only checked$HERMES_HOME/node/bin/and~/.local/bin/, soexecvp()failed before npx ever ran.The naive workaround — symlinking
/usr/local/bin/npxinto the user's PATH — fails one layer deeper because npx's shebang re-execs/usr/bin/env nodeand node also lives at/usr/local/bin/node.Fix
Add
/usr/local/bin/<cmd>as a third candidate in the fallback list (tools/mcp_tool.py, lines 422–435). This is the canonical install location for Node on:node:bookworm-slimimage, which the Hermes Docker image copiesnode + npm + corepackfrom since fix(docker): upgrade Node.js from 20 to 22 LTS #4977 (the Node 22 LTS refactor that exposed this)Because the resolver already calls
_prepend_path(resolved_env, command_dir)after locating the command,/usr/local/bingets prepended to the env's PATH automatically — which also fixes the second-layer shebang failure (npx-cli.js can now find node).Scope is intentionally narrow: the fix activates only when the bare command isn't otherwise locatable through the user's PATH. Users who explicitly narrowed PATH for a non-Node MCP server see no change in behavior.
Why not broaden the default filtered PATH?
The alternative — defaulting
_build_safe_env's baseline PATH to include/usr/local/bin— was considered and rejected because:env.update(user_env)on line 311 lets a user clobber PATH regardless, so it doesn't actually fix the "user explicitly set env.PATH" case.The resolver-side fix is surgical: it activates only when the bare command can't otherwise be located.
Test plan
tests/tools/test_mcp_tool_issue_948.py: new testtest_resolve_stdio_command_falls_back_to_usr_local_bin(mirrors the existing hermes-node-bin fallback test)./scripts/run_tests.sh tests/tools/test_mcp_*.py→ 254/254 pass across 7 filesenv.PATH=/opt/data/bin:/usr/bin:/bin), called_resolve_stdio_command("npx", env)directly, thensubprocess.runof the resolved command — prints10.9.8, exits 0, empty stderr./usr/local/bincandidate is not consulted, PATH is unchanged.Files touched