Files
hermes-agent/tests/tools/test_mcp_node_abi.py
teknium1 5b770c8bf5 fix(mcp): name the rebuild when a stdio server's native addon was built for another Node
Hermes runs stdio MCP servers on its own packaged Node only. A server whose
native addon was compiled by the user's Node (typically through the ~/.npm/_npx
cache the user's npx shares with ours) dies at startup with NODE_MODULE_VERSION /
ERR_DLOPEN_FAILED; the SDK only saw "Connection closed", Hermes retried three
times and parked the server, and the cause lived only in logs/mcp-stderr.log.

The stdio child's stderr now goes through a pipe that still copies every byte
into the shared log but keeps the last 16 KB readable. When the session fails
and that tail shows an addon load failure, the error becomes a
NodeAbiMismatchError naming the addon, both ABI versions and the remedy with
Hermes's own paths: delete the npx cache entry (Hermes's npx reinstalls it) or
`PATH=<managed node dir>:"$PATH" <managed npm> rebuild <pkg> --prefix <root>`.
It is classified permanent, so the server parks at once and self-probes back
after the rebuild. The message reaches every MCP status surface through
_format_connect_error / str(exc): startup banner, `hermes mcp test`, the TUI
and Desktop probes and the dashboard.

The hosts E2E cell that asserted the user's Node wins now asserts the ruling:
the managed Node runs the server and the ABI failure surfaces the remedy.

Refs #124264
2026-09-27 21:30:10 -07:00

80 lines
3.9 KiB
Python

"""A stdio MCP server whose native addon was built by another Node.js (#124264).
Hermes runs stdio servers on its own Node only. When such a server dies at startup with a
``NODE_MODULE_VERSION`` mismatch, the user must be told, with the rebuild under Hermes's Node,
instead of an opaque "Connection closed" and a silent park.
"""
import sys
from pathlib import Path
import pytest
# The remedy is rendered in the host shell's syntax; these pin the POSIX form.
pytestmark = pytest.mark.skipif(sys.platform == "win32", reason="POSIX shell remedy")
_DIES_ON_ABI = """
import pathlib, sys
pathlib.Path(sys.argv[1]).open("a").write("spawn\\n")
addon = sys.argv[2]
sys.stderr.write(
"node:internal/modules/cjs/loader:1921\\n return process.dlopen(module, path.toNamespacedPath(filename));\\n\\n"
f"Error: The module '{addon}'\\nwas compiled against a different Node.js version using\\n"
"NODE_MODULE_VERSION 127. This version of Node.js requires\\nNODE_MODULE_VERSION 147. Please try "
"re-compiling or re-installing\\n code: 'ERR_DLOPEN_FAILED'\\n}\\n\\nNode.js v26.7.0\\n")
sys.exit(1)
"""
@pytest.fixture
def managed_node(tmp_path, monkeypatch):
"""Hermes's PM-installed node/npm, as PM would report them."""
store = tmp_path / "tools"
node, npm = store / "node-26.7.0-linux-x64" / "bin" / "node", store / "npm-12.0.2-linux-x64" / "bin" / "npm"
from tools import mcp_tool_node_abi
monkeypatch.setattr(mcp_tool_node_abi, "_managed",
lambda name: {"node": (node, "26.7.0"), "npm": (npm, "12.0.2")}[name])
return node, npm
def test_stdio_server_dying_on_a_node_abi_mismatch_names_the_rebuild_under_hermes_node(tmp_path, monkeypatch,
managed_node):
from hermes_cli.mcp_config import _probe_failure_reason, _probe_single_server
monkeypatch.setenv("HERMES_HOME", str(tmp_path / "home"))
node, npm = managed_node
entry = tmp_path / ".npm" / "_npx" / "0e2eab1c"
addon = entry / "node_modules" / "better-sqlite3" / "build" / "Release" / "better_sqlite3.node"
spawns, server = tmp_path / "spawns.log", tmp_path / "server.py"
server.write_text(_DIES_ON_ABI, encoding="utf-8")
with pytest.raises(Exception) as caught:
_probe_single_server("sqlite", {"command": sys.executable, "args": [str(server), str(spawns), str(addon)],
"connect_timeout": 20})
reason = _probe_failure_reason(caught.value)
assert "NODE_MODULE_VERSION 127; Hermes's Node 26.7.0 needs 147" in reason
assert f"rm -rf {entry}" in reason
assert f"PATH={node.parent}:\"$PATH\" {npm} rebuild better-sqlite3 --prefix {entry}" in reason
# Every retry loads the same binary: parked at once, not walked through the retry ladder.
assert spawns.read_text().count("spawn") == 1
# The shared stderr log still receives the child's output.
assert "ERR_DLOPEN_FAILED" in (tmp_path / "home" / "logs" / "mcp-stderr.log").read_text()
def test_the_remedy_never_points_the_server_at_another_node(managed_node):
"""A module outside an npx cache gets the rebuild alone, still with Hermes's npm under Hermes's node,
and nothing suggests pinning ``command:`` to the user's Node."""
from tools.mcp_tool_node_abi import node_abi_error
node, npm = managed_node
pkg_root = Path("/usr/lib/node_modules/@acme/mcp-server")
stderr = (f"Error: The module '{pkg_root}/node_modules/sharp/build/Release/sharp.node'\nwas compiled against a "
"different Node.js version using\nNODE_MODULE_VERSION 127. This version of Node.js requires\n"
"NODE_MODULE_VERSION 147.\n code: 'ERR_DLOPEN_FAILED'\n")
message = str(node_abi_error("imgs", stderr))
assert f"{npm}" in message and f"{node.parent}" in message and " rebuild sharp --prefix " in message
assert str(pkg_root) in message and "rm -rf" not in message and "command:" not in message