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>".
251 lines
11 KiB
Python
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
|