Files
aiturk-hermes-ide/tests/tui_gateway/test_mcp_profile_rpcs.py

249 lines
8.1 KiB
Python

"""E2E tests for the per-profile MCP lifecycle RPCs (mcp.servers.*).
These drive the real registered gateway handlers against a real temp
``HERMES_HOME`` with named profile dirs — no mocks of the config/mcp layer — and
assert that every write lands in the RIGHT profile's ``config.yaml`` / ``.env``
and NEVER leaks into the launch (default) profile.
Covered: add + list + set_api_key + remove, profile isolation, and the
duplicate/not-found error envelopes.
"""
from __future__ import annotations
import os
from pathlib import Path
import pytest
import tui_gateway.server as server
@pytest.fixture
def hermes_root(tmp_path, monkeypatch):
"""A temp HERMES_HOME root with two named profiles: 'work' and 'other'.
Pointing HERMES_HOME at a dir outside ~/.hermes makes it the profile ROOT
(get_default_hermes_root's Docker/custom branch), so named profiles live at
``<root>/profiles/<name>/`` and the launch/default profile is ``<root>``.
"""
root = tmp_path / "hermes_home"
(root / "profiles" / "work").mkdir(parents=True)
(root / "profiles" / "other").mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(root))
# Make sure no stale process-wide home override leaks in from another test.
from hermes_constants import get_hermes_home_override
assert get_hermes_home_override() is None
return root
def _call(method, params=None):
handler = server._methods[method]
return handler(1, params or {})
def _result(resp):
assert "error" not in resp, resp.get("error")
return resp["result"]
def _read_yaml(path: Path) -> dict:
"""Read a config.yaml directly for assertions (test-side, not the guarded loader)."""
import yaml
if not path.is_file():
return {}
with open(path, "r", encoding="utf-8") as f:
return yaml.safe_load(f) or {}
def test_add_lands_in_named_profile_only(hermes_root):
root = hermes_root
resp = _call(
"mcp.servers.add",
{
"profile": "work",
"name": "weather",
"config": {"url": "https://mcp.example.com/weather"},
},
)
result = _result(resp)
assert result["ok"] is True
assert result["server"]["transport"] == "http"
assert result["server"]["url"] == "https://mcp.example.com/weather"
work_cfg = _read_yaml(root / "profiles" / "work" / "config.yaml")
assert "weather" in work_cfg.get("mcp_servers", {})
assert work_cfg["mcp_servers"]["weather"]["url"] == "https://mcp.example.com/weather"
# The launch/default profile and the sibling profile stay untouched.
default_cfg = _read_yaml(root / "config.yaml")
assert "weather" not in default_cfg.get("mcp_servers", {})
other_cfg = _read_yaml(root / "profiles" / "other" / "config.yaml")
assert "weather" not in other_cfg.get("mcp_servers", {})
def test_list_reflects_the_scoped_profile(hermes_root):
_result(
_call(
"mcp.servers.add",
{"profile": "work", "name": "svc-a", "config": {"command": "svc-a-bin"}},
)
)
_result(
_call(
"mcp.servers.add",
{"profile": "other", "name": "svc-b", "config": {"command": "svc-b-bin"}},
)
)
work_names = [s["name"] for s in _result(_call("mcp.servers.list", {"profile": "work"}))["servers"]]
other_names = [s["name"] for s in _result(_call("mcp.servers.list", {"profile": "other"}))["servers"]]
assert work_names == ["svc-a"]
assert other_names == ["svc-b"]
# stdio transport surfaced correctly.
work_server = _result(_call("mcp.servers.list", {"profile": "work"}))["servers"][0]
assert work_server["transport"] == "stdio"
assert work_server["command"] == "svc-a-bin"
def test_set_api_key_writes_env_and_header_to_right_profile(hermes_root):
root = hermes_root
_result(
_call(
"mcp.servers.add",
{
"profile": "work",
"name": "gizmo",
"config": {"url": "https://mcp.example.com/gizmo"},
},
)
)
resp = _result(
_call(
"mcp.servers.set_api_key",
{"profile": "work", "name": "gizmo", "value": "sk-secret-123"},
)
)
assert resp["ok"] is True
env_var = resp["env_var"]
assert env_var == "MCP_GIZMO_API_KEY"
# The secret is in the work profile's .env — and NOT the default profile's.
work_env = (root / "profiles" / "work" / ".env").read_text(encoding="utf-8")
assert "MCP_GIZMO_API_KEY=sk-secret-123" in work_env
assert not (root / ".env").exists() or "sk-secret-123" not in (root / ".env").read_text(
encoding="utf-8"
)
# config.yaml stores only the interpolation template, never the raw secret.
work_cfg = _read_yaml(root / "profiles" / "work" / "config.yaml")
headers = work_cfg["mcp_servers"]["gizmo"]["headers"]
assert headers["Authorization"] == "Bearer ${MCP_GIZMO_API_KEY}"
assert "sk-secret-123" not in str(work_cfg)
def test_set_api_key_stdio_references_env_block(hermes_root):
root = hermes_root
_result(
_call(
"mcp.servers.add",
{"profile": "work", "name": "localtool", "config": {"command": "localtool-bin"}},
)
)
resp = _result(
_call(
"mcp.servers.set_api_key",
{
"profile": "work",
"name": "localtool",
"env_var": "LOCALTOOL_TOKEN",
"value": "tok-xyz",
},
)
)
assert resp["env_var"] == "LOCALTOOL_TOKEN"
work_cfg = _read_yaml(root / "profiles" / "work" / "config.yaml")
env_block = work_cfg["mcp_servers"]["localtool"]["env"]
assert env_block["LOCALTOOL_TOKEN"] == "${LOCALTOOL_TOKEN}"
work_env = (root / "profiles" / "work" / ".env").read_text(encoding="utf-8")
assert "LOCALTOOL_TOKEN=tok-xyz" in work_env
def test_remove_scoped_to_profile(hermes_root):
root = hermes_root
_result(
_call(
"mcp.servers.add",
{"profile": "work", "name": "temp", "config": {"command": "temp-bin"}},
)
)
# Same-named server in a different profile must be unaffected by the remove.
_result(
_call(
"mcp.servers.add",
{"profile": "other", "name": "temp", "config": {"command": "temp-bin"}},
)
)
resp = _result(_call("mcp.servers.remove", {"profile": "work", "name": "temp"}))
assert resp["removed"] is True
assert "temp" not in _read_yaml(root / "profiles" / "work" / "config.yaml").get("mcp_servers", {})
# The 'other' profile still has its server.
assert "temp" in _read_yaml(root / "profiles" / "other" / "config.yaml").get("mcp_servers", {})
def test_add_duplicate_and_missing_errors(hermes_root):
_result(
_call(
"mcp.servers.add",
{"profile": "work", "name": "dup", "config": {"command": "dup-bin"}},
)
)
dup = _call(
"mcp.servers.add",
{"profile": "work", "name": "dup", "config": {"command": "dup-bin"}},
)
assert "error" in dup
assert dup["error"]["code"] == 4090
missing = _call("mcp.servers.remove", {"profile": "work", "name": "nope"})
assert "error" in missing
assert missing["error"]["code"] == 4064
bad_profile = _call(
"mcp.servers.add",
{"profile": "ghost", "name": "x", "config": {"command": "x"}},
)
assert "error" in bad_profile
assert bad_profile["error"]["code"] == 4064
def test_add_requires_transport(hermes_root):
resp = _call("mcp.servers.add", {"profile": "work", "name": "empty", "config": {}})
assert "error" in resp
assert resp["error"]["code"] == 4063
def test_default_profile_add_when_profile_omitted(hermes_root):
root = hermes_root
_result(
_call(
"mcp.servers.add",
{"name": "rootsvc", "config": {"command": "rootsvc-bin"}},
)
)
# Omitted profile → launch/default profile == HERMES_HOME root config.yaml.
default_cfg = _read_yaml(root / "config.yaml")
assert "rootsvc" in default_cfg.get("mcp_servers", {})
# ...and NOT in a named profile.
assert "rootsvc" not in _read_yaml(root / "profiles" / "work" / "config.yaml").get(
"mcp_servers", {}
)