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
89 lines
4.4 KiB
Python
89 lines
4.4 KiB
Python
"""Stdio MCP servers whose native addon was built for a different Node.js (#124264).
|
|
|
|
Hermes runs every stdio MCP server on its own managed Node, never the user's. A server whose
|
|
``.node`` addon was compiled under another Node (typically through the ``~/.npm/_npx`` cache the
|
|
user's ``npx`` shares with ours) dies at startup with ``NODE_MODULE_VERSION`` /
|
|
``ERR_DLOPEN_FAILED`` and only its stderr says so. This module turns that stderr into an error that
|
|
names the remedy: rebuild the server under Hermes's Node.
|
|
"""
|
|
|
|
import os
|
|
import re
|
|
import shlex
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
_ABI_MARKERS = ("NODE_MODULE_VERSION", "ERR_DLOPEN_FAILED")
|
|
_MODULE_PATH = re.compile(r"'([^'\n]+?\.node)'|(\S+?\.node):")
|
|
_ABI_VERSIONS = re.compile(r"using\s+NODE_MODULE_VERSION (\d+)\..*?requires\s+NODE_MODULE_VERSION (\d+)", re.S)
|
|
|
|
|
|
class NodeAbiMismatchError(RuntimeError):
|
|
"""A stdio server's native addon does not load under Hermes's Node. Permanent: every retry
|
|
loads the same binary, so the server parks until the user rebuilds it."""
|
|
|
|
|
|
def _managed(name: str) -> tuple[Optional[Path], str]:
|
|
"""``(binary, version)`` of Hermes's own node/npm, or ``(None, "")`` when PM has none."""
|
|
try:
|
|
from pm import installed_package
|
|
installed = installed_package(name)
|
|
except Exception:
|
|
return None, ""
|
|
return (installed.binary, installed.version) if installed and installed.binary else (None, "")
|
|
|
|
|
|
def _package_of(module_path: str) -> tuple[Optional[Path], str]:
|
|
"""``(project root, package name)`` owning *module_path*: the directory above its innermost
|
|
``node_modules`` and the (possibly scoped) package under it."""
|
|
parts = Path(module_path).parts
|
|
marks = [i for i, part in enumerate(parts) if part == "node_modules"]
|
|
if not marks or marks[-1] + 1 >= len(parts):
|
|
return None, ""
|
|
at = marks[-1]
|
|
package = parts[at + 1]
|
|
if package.startswith("@") and at + 2 < len(parts):
|
|
package = f"{package}/{parts[at + 2]}"
|
|
return Path(*parts[:at]), package
|
|
|
|
|
|
def _remedy(root: Optional[Path], package: str, node: Path, npm: Path) -> str:
|
|
"""The fix in the host shell's syntax: delete an npx cache entry (Hermes's npx reinstalls it on the
|
|
next start) or rebuild with Hermes's npm, Hermes's Node first on PATH because npm's shebang and its
|
|
lifecycle scripts both run the first ``node`` there."""
|
|
windows = os.name == "nt"
|
|
q = (lambda p: "'" + str(p).replace("'", "''") + "'") if windows else (lambda p: shlex.quote(str(p)))
|
|
target = f" {q(package)} --prefix {q(root)}" if root is not None else ""
|
|
if windows:
|
|
rebuild = f"$env:Path = {q(str(node.parent) + ';')} + $env:Path; & {q(npm)} rebuild{target}"
|
|
else:
|
|
rebuild = f"PATH={q(node.parent)}:\"$PATH\" {q(npm)} rebuild{target}"
|
|
if root is None:
|
|
return f"in the server's package directory run {rebuild}"
|
|
if root.parent.name != "_npx":
|
|
return rebuild
|
|
delete = f"Remove-Item -Recurse -Force {q(root)}" if windows else f"rm -rf {q(root)}"
|
|
return f"{delete} (Hermes reinstalls it on the next start), or {rebuild}"
|
|
|
|
|
|
def node_abi_error(server_name: str, stderr_text: str) -> Optional[NodeAbiMismatchError]:
|
|
"""The remedy-naming error for a server whose stderr shows a native-addon load failure, else None."""
|
|
if not stderr_text or not any(marker in stderr_text for marker in _ABI_MARKERS):
|
|
return None
|
|
found = _MODULE_PATH.search(stderr_text)
|
|
module = (found.group(1) or found.group(2)) if found else ""
|
|
versions = _ABI_VERSIONS.search(stderr_text)
|
|
node, node_version = _managed("node")
|
|
npm, _ = _managed("npm")
|
|
ours = f"Hermes's Node {node_version}".strip() if node_version else "Hermes's Node"
|
|
built = (f"NODE_MODULE_VERSION {versions.group(1)}; {ours} needs {versions.group(2)}" if versions
|
|
else f"it does not load under {ours}")
|
|
# Diagnosis first: the startup banner keeps only the first ~120 characters.
|
|
head = (f"native addon built for a different Node.js ({built}): {module or 'see logs/mcp-stderr.log'}. "
|
|
"Hermes runs MCP servers on its own Node; rebuild the server under it")
|
|
if node is None or npm is None:
|
|
return NodeAbiMismatchError(f"{head}: install Hermes's Node with `hermes pm install npm`, then run "
|
|
f"`hermes mcp test {server_name}`")
|
|
root, package = _package_of(module) if module else (None, "")
|
|
return NodeAbiMismatchError(f"{head}: {_remedy(root, package, node, npm)}")
|