Files
ops-bridge/tests/test_mcp.py
tegwick a55c685f89 feat(diagnostics): end-to-end tunnel check, stale state detection, MCP extensions
- diagnostics.py: TunnelCheckResult with SSH process liveness, port
  probe, and optional API health check; check_tunnel / check_all_tunnels
- cli.py: bridge status shows LIVE column and [STALE] marker when state
  says connected but PID is dead; bridge check wired to diagnostics
- state.py: read_raw_pid helper; _pid_alive exported for reuse
- capabilities.py: capabilities registry stubs
- mcp_server/server.py: expose check_tunnel and tunnel capabilities
  over MCP
- SCOPE.md: rapid orientation document
- workplans/OPS-WP-0001-diagnostics.md: workplan backing this feature
- tests: 207 passing (test_cli, test_mcp, test_diagnostics)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-21 15:07:47 +01:00

623 lines
23 KiB
Python

"""Tests for OpsBridge MCP server tools (FastMCP in-process client).
Uses FastMCP's Client(mcp_app) context manager — no network, no subprocess.
All tests are async; asyncio_mode = "auto" in pyproject.toml.
FastMCP 3.x returns results in result.content[0].text as a JSON string.
Use _data(result) to extract and parse.
"""
from __future__ import annotations
import json
import textwrap
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from bridge.mcp_server.server import mcp
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _data(result) -> list | dict:
"""Extract and parse JSON from a FastMCP CallToolResult.
FastMCP 3.x: non-empty results are in result.content[0].text.
Empty list/dict returns come back with empty content; result.data holds them.
"""
if not result.content:
return result.data # empty list/dict
text = result.content[0].text
return json.loads(text)
def _write_config(tmp_path: Path, content: str) -> Path:
f = tmp_path / "tunnels.yaml"
f.write_text(content)
return f
def _simple_config(tmp_path: Path) -> Path:
return _write_config(tmp_path, textwrap.dedent("""\
tunnels:
test-tunnel:
host: host.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: operator.bernd
actors:
operator.bernd:
class: human
description: Bernd
"""))
def _catalog_config(tmp_path: Path, catalog_dir: Path) -> Path:
return _write_config(tmp_path, textwrap.dedent(f"""\
tunnels:
test-tunnel:
host: host.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: operator.bernd
actors:
operator.bernd:
class: human
description: Bernd
catalog_path: {catalog_dir}
"""))
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def env_simple(tmp_path, monkeypatch):
cfg = _simple_config(tmp_path)
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
@pytest.fixture
def env_catalog(tmp_path, catalog_dir, monkeypatch):
cfg = _catalog_config(tmp_path, catalog_dir)
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
@pytest.fixture
def env_no_catalog(tmp_path, monkeypatch):
cfg = _simple_config(tmp_path)
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
# ---------------------------------------------------------------------------
# bridge_status
# ---------------------------------------------------------------------------
class TestMcpBridgeStatus:
@pytest.mark.capability("bridge_status")
@pytest.mark.access_mode("mcp")
async def test_bridge_status_returns_list(self, env_simple):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_status", {})
data = _data(result)
assert isinstance(data, list)
assert len(data) == 1
row = data[0]
assert row["tunnel"] == "test-tunnel"
assert "state" in row
assert "actor" in row
assert "host" in row
async def test_bridge_status_bad_config(self, tmp_path, monkeypatch):
monkeypatch.setenv("BRIDGE_CONFIG", str(tmp_path / "nonexistent.yaml"))
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_status", {})
data = _data(result)
assert isinstance(data, list)
assert "error" in data[0]
# ---------------------------------------------------------------------------
# bridge_up
# ---------------------------------------------------------------------------
class TestMcpBridgeUp:
@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("mcp")
async def test_bridge_up_starts_tunnel(self, env_simple):
with patch("bridge.manager.TunnelManager") as mock_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = False
mock_cls.return_value = mock_mgr
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_up", {"tunnel": "test-tunnel"})
data = _data(result)
assert "started" in data
assert "test-tunnel" in data["started"]
async def test_bridge_up_already_running(self, env_simple):
with patch("bridge.manager.TunnelManager") as mock_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = True
mock_cls.return_value = mock_mgr
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_up", {"tunnel": "test-tunnel"})
data = _data(result)
assert "already_running" in data
assert "test-tunnel" in data["already_running"]
async def test_bridge_up_unknown_tunnel(self, env_simple):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_up", {"tunnel": "nonexistent"})
data = _data(result)
assert "error" in data
async def test_bridge_up_all_tunnels(self, env_simple):
with patch("bridge.manager.TunnelManager") as mock_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = False
mock_cls.return_value = mock_mgr
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_up", {})
data = _data(result)
assert "started" in data
assert "test-tunnel" in data["started"]
# ---------------------------------------------------------------------------
# bridge_down
# ---------------------------------------------------------------------------
class TestMcpBridgeDown:
@pytest.mark.capability("bridge_down")
@pytest.mark.access_mode("mcp")
async def test_bridge_down_stops_tunnel(self, env_simple):
with patch("bridge.manager.TunnelManager") as mock_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = True
mock_cls.return_value = mock_mgr
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_down", {"tunnel": "test-tunnel"})
data = _data(result)
assert "stopped" in data
assert "test-tunnel" in data["stopped"]
async def test_bridge_down_not_running(self, env_simple):
with patch("bridge.manager.TunnelManager") as mock_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = False
mock_cls.return_value = mock_mgr
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_down", {"tunnel": "test-tunnel"})
data = _data(result)
assert "not_running" in data
assert "test-tunnel" in data["not_running"]
async def test_bridge_down_unknown_tunnel(self, env_simple):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_down", {"tunnel": "nonexistent"})
data = _data(result)
assert "error" in data
# ---------------------------------------------------------------------------
# bridge_restart
# ---------------------------------------------------------------------------
class TestMcpBridgeRestart:
@pytest.mark.capability("bridge_restart")
@pytest.mark.access_mode("mcp")
async def test_bridge_restart_calls_stop_then_start(self, env_simple):
with patch("bridge.manager.TunnelManager") as mock_cls:
mock_mgr = MagicMock()
call_order = []
mock_mgr.stop.side_effect = lambda: call_order.append("stop")
mock_mgr.start.side_effect = lambda: call_order.append("start")
mock_cls.return_value = mock_mgr
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_restart", {"tunnel": "test-tunnel"})
data = _data(result)
assert "restarted" in data
assert "test-tunnel" in data["restarted"]
assert call_order == ["stop", "start"]
async def test_bridge_restart_unknown_tunnel(self, env_simple):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_restart", {"tunnel": "nonexistent"})
data = _data(result)
assert "error" in data
# ---------------------------------------------------------------------------
# bridge_logs
# ---------------------------------------------------------------------------
class TestMcpBridgeLogs:
@pytest.mark.capability("bridge_logs")
@pytest.mark.access_mode("mcp")
async def test_bridge_logs_returns_list(self, env_simple, tmp_path):
import json as _json
state_dir = tmp_path / "state"
state_dir.mkdir(parents=True, exist_ok=True)
log_file = state_dir / "test-tunnel.log"
log_file.write_text(
_json.dumps({
"timestamp": "2026-01-01T00:00:00+00:00",
"tunnel": "test-tunnel",
"actor": "operator.bernd",
"actor_class": "human",
"event": "bridge_started",
}) + "\n"
)
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_logs", {"tunnel": "test-tunnel"})
data = _data(result)
assert isinstance(data, list)
assert len(data) == 1
assert data[0]["event"] == "bridge_started"
async def test_bridge_logs_unknown_tunnel(self, env_simple):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_logs", {"tunnel": "nonexistent"})
data = _data(result)
assert isinstance(data, list)
assert "error" in data[0]
async def test_bridge_logs_empty(self, env_simple):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_logs", {"tunnel": "test-tunnel"})
data = _data(result)
assert isinstance(data, list)
assert data == []
# ---------------------------------------------------------------------------
# catalog_list_targets
# ---------------------------------------------------------------------------
class TestMcpCatalogListTargets:
@pytest.mark.capability("catalog_list_targets")
@pytest.mark.access_mode("mcp")
async def test_catalog_list_targets_returns_list(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_list_targets", {})
data = _data(result)
assert isinstance(data, list)
assert any(t["id"] == "state-hub" for t in data)
async def test_catalog_list_targets_domain_filter(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_list_targets", {"domain": "coulombcore"})
data = _data(result)
assert all(t["domain"] == "coulombcore" for t in data)
async def test_catalog_list_targets_no_catalog(self, env_no_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_list_targets", {})
data = _data(result)
assert isinstance(data, list)
assert "error" in data[0]
# ---------------------------------------------------------------------------
# catalog_show_target
# ---------------------------------------------------------------------------
class TestMcpCatalogShowTarget:
@pytest.mark.capability("catalog_show_target")
@pytest.mark.access_mode("mcp")
async def test_catalog_show_target_returns_metadata(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_show_target", {"target_id": "state-hub"})
data = _data(result)
assert data["id"] == "state-hub"
assert data["domain"] == "coulombcore"
async def test_catalog_show_target_not_found(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_show_target", {"target_id": "nonexistent"})
data = _data(result)
assert "error" in data
async def test_catalog_show_target_no_catalog(self, env_no_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_show_target", {"target_id": "x"})
data = _data(result)
assert "error" in data
# ---------------------------------------------------------------------------
# catalog_list_domains
# ---------------------------------------------------------------------------
class TestMcpCatalogListDomains:
@pytest.mark.capability("catalog_list_domains")
@pytest.mark.access_mode("mcp")
async def test_catalog_list_domains_returns_list(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_list_domains", {})
data = _data(result)
assert isinstance(data, list)
assert any(d["id"] == "coulombcore" for d in data)
async def test_catalog_list_domains_no_catalog(self, env_no_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_list_domains", {})
data = _data(result)
assert isinstance(data, list)
assert "error" in data[0]
# ---------------------------------------------------------------------------
# catalog_validate
# ---------------------------------------------------------------------------
class TestMcpCatalogValidate:
@pytest.mark.capability("catalog_validate")
@pytest.mark.access_mode("mcp")
async def test_catalog_validate_clean(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_validate", {})
data = _data(result)
assert data["valid"] is True
async def test_catalog_validate_no_catalog(self, env_no_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_validate", {})
data = _data(result)
assert data["valid"] is False
assert len(data["errors"]) > 0
async def test_catalog_validate_with_errors(self, tmp_path, monkeypatch):
root = tmp_path / "bad-catalog"
domain_dir = root / "domains" / "d"
(domain_dir / "targets").mkdir(parents=True)
(domain_dir / "domain.yaml").write_text("type: domain\nid: d\nname: D\n")
(domain_dir / "targets" / "t.yaml").write_text(
"type: target\nid: t\ndomain: d\nkind: service\n"
"reachable_via:\n - missing-bridge\n"
)
cfg = tmp_path / "tunnels.yaml"
cfg.write_text(f"tunnels: {{}}\nactors: {{}}\ncatalog_path: {root}\n")
monkeypatch.setenv("BRIDGE_CONFIG", str(cfg))
monkeypatch.setenv("BRIDGE_STATE_DIR", str(tmp_path / "state"))
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_validate", {})
data = _data(result)
assert data["valid"] is False
assert any("missing-bridge" in e for e in data["errors"])
# ---------------------------------------------------------------------------
# catalog_show_bridge
# ---------------------------------------------------------------------------
class TestMcpCatalogShowBridge:
@pytest.mark.capability("catalog_show_bridge")
@pytest.mark.access_mode("mcp")
async def test_catalog_show_bridge_returns_metadata(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool(
"catalog_show_bridge", {"bridge_id": "state-hub-coulombcore"}
)
data = _data(result)
assert data["id"] == "state-hub-coulombcore"
assert data["host"] == "coulombcore.local"
async def test_catalog_show_bridge_not_found(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_show_bridge", {"bridge_id": "nonexistent"})
data = _data(result)
assert "error" in data
async def test_catalog_show_bridge_no_catalog(self, env_no_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("catalog_show_bridge", {"bridge_id": "x"})
data = _data(result)
assert "error" in data
# ---------------------------------------------------------------------------
# bridge_check
# ---------------------------------------------------------------------------
class TestMcpBridgeCheck:
@pytest.mark.capability("bridge_check")
@pytest.mark.access_mode("mcp")
async def test_bridge_check_tool(self, env_simple):
"""bridge_check returns a list of dicts with 'ok' key."""
from bridge.diagnostics import TunnelCheckResult
mock_result = TunnelCheckResult(
tunnel="test-tunnel",
ssh_process="ok",
pid=12345,
remote_port="listening",
local_api=None,
latency_ms=None,
stale_state=False,
)
with patch("bridge.mcp_server.server.check_all_tunnels", return_value=[mock_result]):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_check", {})
data = _data(result)
assert isinstance(data, list)
assert len(data) == 1
row = data[0]
assert "ok" in row
assert row["ok"] is True
assert row["tunnel"] == "test-tunnel"
assert row["ssh_process"] == "ok"
assert row["remote_port"] == "listening"
async def test_bridge_check_specific_tunnel(self, env_simple):
"""bridge_check with tunnel arg calls check_tunnel for that tunnel."""
from bridge.diagnostics import TunnelCheckResult
mock_result = TunnelCheckResult(
tunnel="test-tunnel",
ssh_process="dead",
pid=None,
remote_port="closed",
local_api=None,
latency_ms=None,
stale_state=True,
)
with patch("bridge.mcp_server.server.check_tunnel", return_value=mock_result):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_check", {"tunnel": "test-tunnel"})
data = _data(result)
assert isinstance(data, list)
assert data[0]["ok"] is False
assert data[0]["stale_state"] is True
async def test_bridge_check_unknown_tunnel(self, env_simple):
"""bridge_check with unknown tunnel returns error dict."""
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_check", {"tunnel": "nonexistent"})
data = _data(result)
assert isinstance(data, list)
assert "error" in data[0]
async def test_bridge_check_bad_config(self, tmp_path, monkeypatch):
"""bridge_check with bad config returns error dict."""
monkeypatch.setenv("BRIDGE_CONFIG", str(tmp_path / "nonexistent.yaml"))
from fastmcp import Client
async with Client(mcp) as c:
result = await c.call_tool("bridge_check", {})
data = _data(result)
assert isinstance(data, list)
assert "error" in data[0]
# ---------------------------------------------------------------------------
# Resources
# ---------------------------------------------------------------------------
class TestMcpResources:
async def test_bridge_status_resource(self, env_simple):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.read_resource("bridge://status")
content = result[0].text if hasattr(result[0], "text") else str(result[0])
data = json.loads(content)
assert isinstance(data, list)
async def test_catalog_domains_resource(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.read_resource("catalog://domains")
content = result[0].text if hasattr(result[0], "text") else str(result[0])
data = json.loads(content)
assert isinstance(data, list)
async def test_catalog_targets_resource(self, env_catalog):
from fastmcp import Client
async with Client(mcp) as c:
result = await c.read_resource("catalog://targets")
content = result[0].text if hasattr(result[0], "text") else str(result[0])
data = json.loads(content)
assert isinstance(data, list)
# ---------------------------------------------------------------------------
# T15 — Agent workflow integration test: bridge_status → bridge_up → bridge_status
# ---------------------------------------------------------------------------
class TestMcpAgentWorkflow:
"""T15: Verify the MCP layer supports an agent's typical tunnel management workflow."""
@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("mcp")
async def test_agent_status_up_status_workflow(self, env_simple, tmp_path):
"""Agent workflow: check status (stopped) → start tunnel → verify started."""
from fastmcp import Client
from bridge.models import BridgeState
state_dir = tmp_path / "state"
# Step 1: bridge_status → all stopped
async with Client(mcp) as c:
result = await c.call_tool("bridge_status", {})
rows = _data(result)
assert rows[0]["state"] == BridgeState.STOPPED.value
# Step 2: bridge_up — mock TunnelManager to capture the call and write state
def mock_start_writes_state():
sd = state_dir
sd.mkdir(parents=True, exist_ok=True)
(sd / "test-tunnel.state").write_text(BridgeState.CONNECTED.value)
(sd / "test-tunnel.pid").write_text("12345")
with patch("bridge.manager.TunnelManager") as mock_cls:
mock_mgr = MagicMock()
mock_mgr.is_running.return_value = False
mock_mgr.start.side_effect = mock_start_writes_state
mock_cls.return_value = mock_mgr
async with Client(mcp) as c:
result = await c.call_tool("bridge_up", {"tunnel": "test-tunnel"})
up_data = _data(result)
assert "test-tunnel" in up_data["started"]
# Step 3: bridge_status → reflects connected state
async with Client(mcp) as c:
result = await c.call_tool("bridge_status", {})
rows = _data(result)
assert rows[0]["tunnel"] == "test-tunnel"
assert rows[0]["state"] == BridgeState.CONNECTED.value