Files
hermes-agent/tests/tools/test_mcp_liveness.py
alt-glitch f1c79255fe feat(plugins): requires.gpu gates a plugin server on the host's GPU vendor
A portable plugin can now declare `requires: {gpu: nvidia}` for an MCP
server. On a host without an NVIDIA GPU, Hermes then refuses the
install, hides the server's tools, and the catalog card says "needs an
NVIDIA GPU". The check is the existing
`hermes_platform.host.facts.gpu_class()` read.

The requirement is independent of `app:`. The RTX Remix Toolkit is the
first consumer: it is a package the user unpacks anywhere, so there is
no install location to check, but it needs an NVIDIA GPU.

An unreadable GPU (`unknown`) passes, so a failed registry or sysfs
read does not lock out a machine that has the GPU. Only `nvidia` is
accepted: gpu_class() reports the highest-priority vendor, which
answers the question exactly for NVIDIA and not for AMD or Intel.

A registered server on such a host reports the new `unsupported_gpu`
state, carried through the gateway contract and the Plugins tab, with
a sentence that names the GPU instead of "Install <app>".
2026-10-03 10:26:12 -07:00

251 lines
11 KiB
Python

from __future__ import annotations
import json
import logging
import os
import sys
import pytest
from hermes_platform import declaration
from tools.mcp_liveness import parse_liveness
def _decl(tmp_path, *, min_version=None):
executable = tmp_path / "example-app"
executable.write_text("fixture", encoding="utf-8")
raw = {sys.platform: {"presence": "executable", "location": str(executable)}}
requires = {"app": True}
if min_version is not None:
raw[sys.platform]["version"] = {"kind": "plist" if sys.platform == "darwin" else "none"}
requires["min_version"] = min_version
return declaration.parse_declaration("Example App", raw, requires, where="test")
def test_parse_liveness_contract():
assert parse_liveness({"kind": "static"}).kind == "static"
assert parse_liveness({"kind": "interactive_session"}).kind == "interactive_session"
live = parse_liveness({
"kind": "server_json",
"path": "/tmp/example.json",
"fields": {"url": "endpoint", "token": "secret", "pid": "process"},
})
assert (live.kind, live.path, live.url_field, live.token_field, live.pid_field) == (
"server_json", "/tmp/example.json", "endpoint", "secret", "process"
)
with pytest.raises(ValueError):
parse_liveness({"kind": "unknown"})
defaulted = parse_liveness({"kind": "server_json", "path": "/tmp/example.json"})
assert (defaulted.url_field, defaulted.token_field, defaulted.pid_field) == ("http", "token", "pid")
partial = parse_liveness({"kind": "server_json", "path": "/tmp/example.json", "fields": {"url": "endpoint"}})
assert (partial.url_field, partial.token_field, partial.pid_field) == ("endpoint", "token", "pid")
with pytest.raises(ValueError):
parse_liveness({"kind": "server_json", "path": "/tmp/example.json", "fields": {"port": "p"}})
def test_invalid_registered_liveness_degrades_to_static(monkeypatch):
import hermes_cli.agent_plugins as agent_plugins
from tools.mcp_liveness import liveness_for
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: {"kind": "server_json"}, raising=False)
assert liveness_for("example-server").kind == "static"
def test_live_endpoint_reloads_file_and_registers_token_before_use(tmp_path, monkeypatch, caplog):
import hermes_cli.agent_plugins as agent_plugins
from agent import redact
from tools.mcp_tool_transport import _live_endpoint
runtime = tmp_path / "server.json"
decl = _decl(tmp_path)
declaration.register("example-server", decl)
raw = {
"kind": "server_json",
"path": str(runtime),
"fields": {"url": "http", "token": "token", "pid": "pid"},
}
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: raw, raising=False)
calls = []
monkeypatch.setattr(redact, "register_vault_redaction_value", calls.append)
caplog.set_level(logging.DEBUG)
try:
runtime.write_text(json.dumps({"http": "http://127.0.0.1:1111", "token": "first-secret", "pid": os.getpid()}))
first = _live_endpoint("example-server")
runtime.write_text(json.dumps({"http": "http://127.0.0.1:2222", "token": "second-secret", "pid": os.getpid()}))
second = _live_endpoint("example-server")
finally:
declaration.unregister("example-server")
assert first == ("http://127.0.0.1:1111/mcp", {"Authorization": "Bearer first-secret"})
assert second == ("http://127.0.0.1:2222/mcp", {"Authorization": "Bearer second-secret"})
assert calls == ["first-secret", "second-secret"]
assert all(secret not in record.getMessage() for record in caplog.records for secret in calls)
def test_runtime_file_without_token_connects_without_authorization(tmp_path, monkeypatch):
import hermes_cli.agent_plugins as agent_plugins
from agent import redact
from tools.mcp_tool_transport import _live_endpoint
runtime = tmp_path / "server.json"
declaration.register("example-server", _decl(tmp_path))
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: {
"kind": "server_json",
"path": str(runtime),
"fields": {"url": "http", "token": "token", "pid": "pid"},
}, raising=False)
calls = []
monkeypatch.setattr(redact, "register_vault_redaction_value", calls.append)
try:
runtime.write_text(json.dumps({"http": "http://127.0.0.1:3333", "pid": os.getpid()}))
result = _live_endpoint("example-server")
finally:
declaration.unregister("example-server")
assert result is not None
url, headers = result
assert url == "http://127.0.0.1:3333/mcp"
assert "Authorization" not in headers
assert calls == []
def test_missing_runtime_file_never_falls_back(tmp_path, monkeypatch):
import hermes_cli.agent_plugins as agent_plugins
from tools.mcp_tool_transport import LiveEndpointUnavailable, _live_endpoint
decl = _decl(tmp_path)
declaration.register("example-server", decl)
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: {
"kind": "server_json",
"path": str(tmp_path / "missing.json"),
"fields": {"url": "http", "token": "token", "pid": "pid"},
}, raising=False)
try:
with pytest.raises(LiveEndpointUnavailable):
_live_endpoint("example-server")
finally:
declaration.unregister("example-server")
def test_hydrated_error_shape_for_registered_declaration(tmp_path, monkeypatch):
import hermes_cli.agent_plugins as agent_plugins
from tools import mcp_tool, mcp_tool_discovery, mcp_tool_handlers
decl = _decl(tmp_path)
declaration.register("example-server", decl)
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: {"kind": "static"}, raising=False)
monkeypatch.setattr(mcp_tool_discovery, "_get_connected_server_for_call", lambda name: None)
monkeypatch.setattr(mcp_tool, "_bump_server_error", lambda name, **kwargs: None)
try:
server, error = mcp_tool_handlers._acquire_call_server("example-server", 0)
finally:
declaration.unregister("example-server")
payload = json.loads(error)
assert server is None
assert payload["server"] == "example-server"
# Static liveness cannot observe the app, so it must not claim the app is not
# running: the honest state is the missing MCP connection (#119975).
assert payload["state"] == "hermes_not_connected"
assert payload["app"]["name"] == "Example App"
assert payload["user_action"]
assert payload["retry"] == "after_user_action"
def test_connected_interactive_session_server_is_offerable_from_a_service_session(tmp_path, monkeypatch):
import hermes_cli.agent_plugins as agent_plugins
from hermes_platform.host import facts
from tools import mcp_tool_handlers
declaration.register("example-server", _decl(tmp_path))
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: {"kind": "interactive_session"}, raising=False)
monkeypatch.setattr(facts, "interactive_session", lambda: False)
try:
assert mcp_tool_handlers._declared_app_offerable("example-server") is True
finally:
declaration.unregister("example-server")
def test_running_app_with_live_endpoint_reports_the_missing_connection(tmp_path, monkeypatch):
"""The #119975 report: the app runs and its endpoint answers, only Hermes' MCP connection
is missing. That must read as a missing connection with a reconnect action — not as
\"<slug> is not running. Start <slug>\" for an app that IS running."""
import hermes_cli.agent_plugins as agent_plugins
from hermes_platform.resolver.core import CheckState
from tools import mcp_liveness
class _RunningApp:
def __init__(self, _definition):
pass
def locate(self, _ctx=None):
return object()
def probe(self, _resolution, effort=None):
return type("Probe", (), {
"running": type("Value", (), {"value": True})(),
"endpoint": type("Endpoint", (), {"state": CheckState.PRESENT})(),
})()
monkeypatch.setattr(mcp_liveness, "AppResolver", _RunningApp)
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: {
"kind": "server_json", "path": str(tmp_path / "runtime.json"),
}, raising=False)
decl = _decl(tmp_path)
declaration.register("example-server", decl)
try:
current = mcp_liveness.status("example-server")
finally:
declaration.unregister("example-server")
assert current is not None
assert current.state == "hermes_not_connected"
sentence = mcp_liveness.describe(decl, current.availability, current.state)
assert "MCP connection is missing" in sentence
assert "is not running" not in sentence
assert current.user_action.startswith("Reconnect")
def test_static_liveness_cannot_claim_the_app_is_not_running(tmp_path, monkeypatch):
"""Static/unknown liveness kinds have no app probe, so their honest state is the missing
connection — not a verdict that an app they cannot see is stopped (#119975)."""
import hermes_cli.agent_plugins as agent_plugins
from tools import mcp_liveness
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: {"kind": "static"}, raising=False)
decl = _decl(tmp_path)
declaration.register("example-server", decl)
try:
current = mcp_liveness.status("example-server")
finally:
declaration.unregister("example-server")
assert current is not None
assert current.state == "hermes_not_connected"
sentence = mcp_liveness.describe(decl, current.availability, current.state)
assert "MCP connection is missing" in sentence
assert "is not running" not in sentence
def test_gpu_requirement_hides_a_connected_server_and_says_which_gpu(monkeypatch):
"""A connected server whose `requires.gpu` the host misses is not offered, and its status is a
state the Plugins tab can carry, with a sentence that names the GPU instead of "Install <app>"."""
import hermes_cli.agent_plugins as agent_plugins
from hermes_platform.host import facts
from tools import mcp_liveness, mcp_tool_handlers
from tui_gateway.contracts.tools_mcp_plugins import PluginServerState
monkeypatch.setattr(facts, "gpu_class", lambda: "intel")
monkeypatch.setattr(agent_plugins, "liveness_for", lambda name: None, raising=False)
decl = declaration.parse_declaration("RTX Tool", None, {"gpu": "nvidia"}, where="test")
declaration.register("example-server", decl)
try:
offerable = mcp_tool_handlers._declared_app_offerable("example-server")
current = mcp_liveness.status("example-server")
finally:
declaration.unregister("example-server")
assert offerable is False
assert current is not None and current.state in {s.value for s in PluginServerState}
assert current.retry == "never_here"
sentence = mcp_liveness.describe(decl, current.availability, current.state)
assert "an NVIDIA GPU" in sentence and "Install" not in sentence
def test_describe_prefers_the_plugin_title_over_the_server_slug(tmp_path):
"""The Plugins tab knows the plugin's catalog title; the sentence should name the app by
it instead of the declaration's slug (#119975)."""
from tools import mcp_liveness
decl = _decl(tmp_path)
sentence = mcp_liveness.describe(decl, None, "hermes_not_connected", display_name="Example Tools")
assert sentence.startswith("Example Tools")
assert "Example App" not in sentence
assert "MCP connection is missing" in sentence