Skip to content

fix(mcp): resolve bare npx/npm/node against /usr/local/bin#34186

Merged
benbarclay merged 1 commit into
mainfrom
docker-npx
May 29, 2026
Merged

fix(mcp): resolve bare npx/npm/node against /usr/local/bin#34186
benbarclay merged 1 commit into
mainfrom
docker-npx

Conversation

@benbarclay

Copy link
Copy Markdown
Collaborator

Summary

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), every Node-based stdio MCP server fails with:

[Errno 2] No such file or directory: 'npx'

The MCP env-filter passes the narrow user 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 before npx ever ran.

The naive workaround — symlinking /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 (tools/mcp_tool.py, lines 422–435). This is the canonical install location for Node on:

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.

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:

  1. 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.
  2. It silently re-broadens the env for users who narrowed PATH intentionally.

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 test test_resolve_stdio_command_falls_back_to_usr_local_bin (mirrors the existing hermes-node-bin fallback test)
  • Full MCP test suite: ./scripts/run_tests.sh tests/tools/test_mcp_*.py → 254/254 pass across 7 files
  • E2E against a freshly-built Docker image: reproduced the original failure mode (env.PATH=/opt/data/bin:/usr/bin:/bin), called _resolve_stdio_command("npx", env) directly, then subprocess.run of the resolved command — prints 10.9.8, exits 0, empty stderr.
  • Negative E2E on the host (where Node is already on PATH via mise): resolver still hits the mise install dir, the /usr/local/bin candidate is not consulted, PATH is unchanged.

Files touched

tools/mcp_tool.py                          | +11 lines  (1 new candidate + inline rationale)
tests/tools/test_mcp_tool_issue_948.py     | +33 lines  (1 new test)

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
@benbarclay benbarclay requested a review from teknium1 May 28, 2026 23:42
@github-actions

Copy link
Copy Markdown
Contributor

🔎 Lint report: docker-npx vs origin/main

ruff

Total: 0 on HEAD, 0 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 0 pre-existing issues carried over.

ty (type checker)

Total: 9577 on HEAD, 9577 on base (➖ 0)

🆕 New issues: none

✅ Fixed issues: none

Unchanged: 5045 pre-existing issues carried over.

Diagnostics are surfaced as warnings — this check never fails the build.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant