feat(BRIDGE-WP-0003): MCP server, /bridge-status skill, cross-mode coverage enforcement

Implements the full BRIDGE-WP-0003 workplan: 188 tests passing, 0 lint errors.

## What's added

**Capability registry** (`src/bridge/capabilities.py`):
- 10 capabilities with required_access_modes (cli/mcp/skill)
- Single source of truth for what OpsBridge does and where

**MCP server** (`src/bridge/mcp_server/server.py`):
- 10 FastMCP tools: bridge_up/down/restart/status/logs + 5 catalog_* tools
- 3 resources: bridge://status, catalog://domains, catalog://targets
- `.mcp.json` for project-scope auto-registration
- `scripts/register_mcp.py` for user-scope machine-global registration

**Skill** (`~/.claude/plugins/ops-bridge/bridge-status.md`):
- /bridge-status: health table with emoji indicators + remediation advice

**Cross-mode test coverage enforcement**:
- `tests/conftest.py`: capability/access_mode marks + collect_capability_coverage()
- `tests/test_mcp.py`: 31 FastMCP in-process client tests (Client(mcp) pattern)
- `tests/test_skill.py`: static skill lint against capability registry
- `tests/test_coverage_completeness.py`: meta-test that fails if any required
  (capability × mode) pair lacks a test; also validates CLI commands and MCP
  tools are registered in the capability registry

**ADR** (`architecture/adr-001-cross-mode-capability-registry.md`):
- Documents the registry pattern and FastMCP 3.x testing approach

Key implementation note: FastMCP 3.x in-process results are in
result.content[0].text (JSON string), not result.data directly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-12 11:33:16 +01:00
parent 44b5a9426a
commit 365c0d611a
30 changed files with 2845 additions and 47 deletions

View File

@@ -0,0 +1,465 @@
"""OpsBridge MCP server — exposes bridge and catalog operations as FastMCP tools.
Entry point (stdio):
uv run python src/bridge/mcp_server/server.py
The server imports the Python library directly — no subprocess required.
All tool functions return JSON-serialisable dicts/lists.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Optional
from fastmcp import FastMCP
mcp = FastMCP(
name="ops-bridge",
instructions=(
"OpsBridge MCP server. Use bridge_status to check tunnel health, "
"bridge_up/down/restart to manage lifecycle, bridge_logs for audit history. "
"catalog_* tools require catalog_path to be configured in tunnels.yaml."
),
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _state_dir() -> Path:
return Path(os.environ.get("BRIDGE_STATE_DIR", str(Path.home() / ".local" / "state" / "bridge")))
def _load_cfg():
from bridge.config import load_config
return load_config()
def _load_cfg_or_error() -> tuple:
"""Return (cfg, None) or (None, error_dict)."""
try:
return _load_cfg(), None
except Exception as e:
return None, {"error": str(e)}
def _load_catalog(cfg):
"""Return (catalog, None) or (None, error_dict)."""
if cfg.catalog_path is None:
return None, {"error": "catalog_path not configured"}
try:
from bridge.catalog.loader import load_catalog
return load_catalog(cfg.catalog_path), None
except Exception as e:
return None, {"error": f"Failed to load catalog: {e}"}
# ---------------------------------------------------------------------------
# Bridge lifecycle tools
# ---------------------------------------------------------------------------
@mcp.tool()
def bridge_up(tunnel: Optional[str] = None) -> dict:
"""Start one or all configured tunnels.
Args:
tunnel: Tunnel name to start. If omitted, starts all inline tunnels.
Returns:
{"started": [...], "already_running": [...]} or {"error": "..."}
"""
cfg, err = _load_cfg_or_error()
if err:
return err
from bridge.manager import TunnelManager
sd = _state_dir()
started = []
already_running = []
if tunnel:
from bridge.catalog.loader import load_catalog
from bridge.catalog.resolver import BridgeNotFound, resolve
catalog = None
if cfg.catalog_path is not None:
try:
catalog = load_catalog(cfg.catalog_path)
except Exception:
pass
try:
tcfg = resolve(tunnel, catalog=catalog, inline_tunnels=cfg.tunnels)
except BridgeNotFound:
return {"error": f"Tunnel '{tunnel}' not found in config or catalog"}
mgr = TunnelManager(tcfg, state_dir=sd)
if mgr.is_running():
already_running.append(tunnel)
else:
mgr.start()
started.append(tunnel)
else:
for name, tcfg in cfg.tunnels.items():
mgr = TunnelManager(tcfg, state_dir=sd)
if mgr.is_running():
already_running.append(name)
else:
mgr.start()
started.append(name)
return {"started": started, "already_running": already_running}
@mcp.tool()
def bridge_down(tunnel: Optional[str] = None) -> dict:
"""Stop one or all configured tunnels.
Args:
tunnel: Tunnel name to stop. If omitted, stops all inline tunnels.
Returns:
{"stopped": [...], "not_running": [...]} or {"error": "..."}
"""
cfg, err = _load_cfg_or_error()
if err:
return err
from bridge.manager import TunnelManager
sd = _state_dir()
stopped = []
not_running = []
if tunnel:
from bridge.catalog.loader import load_catalog
from bridge.catalog.resolver import BridgeNotFound, resolve
catalog = None
if cfg.catalog_path is not None:
try:
catalog = load_catalog(cfg.catalog_path)
except Exception:
pass
try:
tcfg = resolve(tunnel, catalog=catalog, inline_tunnels=cfg.tunnels)
except BridgeNotFound:
return {"error": f"Tunnel '{tunnel}' not found in config or catalog"}
mgr = TunnelManager(tcfg, state_dir=sd)
if not mgr.is_running():
not_running.append(tunnel)
else:
mgr.stop()
stopped.append(tunnel)
else:
for name, tcfg in cfg.tunnels.items():
mgr = TunnelManager(tcfg, state_dir=sd)
if not mgr.is_running():
not_running.append(name)
else:
mgr.stop()
stopped.append(name)
return {"stopped": stopped, "not_running": not_running}
@mcp.tool()
def bridge_restart(tunnel: Optional[str] = None) -> dict:
"""Restart one or all configured tunnels.
Args:
tunnel: Tunnel name to restart. If omitted, restarts all inline tunnels.
Returns:
{"restarted": [...]} or {"error": "..."}
"""
cfg, err = _load_cfg_or_error()
if err:
return err
from bridge.manager import TunnelManager
sd = _state_dir()
restarted = []
if tunnel:
from bridge.catalog.loader import load_catalog
from bridge.catalog.resolver import BridgeNotFound, resolve
catalog = None
if cfg.catalog_path is not None:
try:
catalog = load_catalog(cfg.catalog_path)
except Exception:
pass
try:
tcfg = resolve(tunnel, catalog=catalog, inline_tunnels=cfg.tunnels)
except BridgeNotFound:
return {"error": f"Tunnel '{tunnel}' not found in config or catalog"}
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
restarted.append(tunnel)
else:
for name, tcfg in cfg.tunnels.items():
mgr = TunnelManager(tcfg, state_dir=sd)
mgr.stop()
mgr.start()
restarted.append(name)
return {"restarted": restarted}
@mcp.tool()
def bridge_status() -> list[dict]:
"""Return status of all configured tunnels.
Returns:
List of tunnel status dicts, each with keys:
tunnel, state, actor, host, pid, uptime, health
"""
cfg, err = _load_cfg_or_error()
if err:
return [err]
from bridge.state import StateManager
sd = _state_dir()
state_mgr = StateManager(state_dir=sd)
rows = []
for name, tcfg in cfg.tunnels.items():
state = state_mgr.read_state(name)
pid = state_mgr.read_pid(name)
rows.append({
"tunnel": name,
"state": state.value,
"actor": tcfg.actor,
"host": tcfg.host,
"pid": pid,
"uptime": None,
"health": None,
})
return rows
@mcp.tool()
def bridge_logs(tunnel: str, lines: int = 50) -> list[dict]:
"""Return recent audit log entries for a tunnel.
Args:
tunnel: Tunnel name.
lines: Maximum number of log entries to return (default 50).
Returns:
List of audit event dicts (timestamp, event, actor, detail).
"""
cfg, err = _load_cfg_or_error()
if err:
return [err]
from bridge.catalog.loader import load_catalog
from bridge.catalog.resolver import BridgeNotFound, resolve
catalog = None
if cfg.catalog_path is not None:
try:
catalog = load_catalog(cfg.catalog_path)
except Exception:
pass
try:
resolve(tunnel, catalog=catalog, inline_tunnels=cfg.tunnels)
except BridgeNotFound:
return [{"error": f"Tunnel '{tunnel}' not found in config or catalog"}]
from bridge.audit import AuditLogger
sd = _state_dir()
logger = AuditLogger(state_dir=sd)
events = logger.read_events(tunnel)
return events[-lines:] if events else []
# ---------------------------------------------------------------------------
# Catalog tools
# ---------------------------------------------------------------------------
@mcp.tool()
def catalog_list_targets(domain: Optional[str] = None) -> list[dict]:
"""List all infrastructure targets from the OpsCatalog.
Args:
domain: Optional domain filter.
Returns:
List of target dicts (id, domain, kind, description, reachable_via).
Returns [{"error": "..."}] when catalog is not configured or fails to load.
"""
cfg, err = _load_cfg_or_error()
if err:
return [err]
catalog, err = _load_catalog(cfg)
if err:
return [err]
targets = []
for t in catalog.targets.values():
if domain and t.domain != domain:
continue
targets.append({
"id": t.id,
"domain": t.domain,
"kind": t.kind,
"description": t.description or "",
"reachable_via": list(t.reachable_via),
})
return targets
@mcp.tool()
def catalog_show_target(target_id: str) -> dict:
"""Show full metadata for a catalog target.
Args:
target_id: The target identifier.
Returns:
Target metadata dict, or {"error": "..."}.
"""
cfg, err = _load_cfg_or_error()
if err:
return err
catalog, err = _load_catalog(cfg)
if err:
return err
if target_id not in catalog.targets:
return {"error": f"Target '{target_id}' not found"}
t = catalog.targets[target_id]
return {
"id": t.id,
"domain": t.domain,
"kind": t.kind,
"description": t.description or "",
"reachable_via": list(t.reachable_via),
}
@mcp.tool()
def catalog_list_domains() -> list[dict]:
"""List all domains in the OpsCatalog with target and bridge counts.
Returns:
List of domain dicts (id, name, environment, target_count, bridge_count).
Returns [{"error": "..."}] when catalog is not configured or fails to load.
"""
cfg, err = _load_cfg_or_error()
if err:
return [err]
catalog, err = _load_catalog(cfg)
if err:
return [err]
domains = []
for d in catalog.domains.values():
target_count = sum(1 for t in catalog.targets.values() if t.domain == d.id)
bridge_count = sum(1 for b in catalog.bridges.values() if b.domain == d.id)
domains.append({
"id": d.id,
"name": d.name,
"environment": d.environment,
"description": d.description or "",
"target_count": target_count,
"bridge_count": bridge_count,
})
return domains
@mcp.tool()
def catalog_validate() -> dict:
"""Validate the OpsCatalog for consistency errors.
Returns:
{"valid": True} or {"valid": False, "errors": ["..."]}
"""
cfg, err = _load_cfg_or_error()
if err:
return {"valid": False, "errors": [err["error"]]}
catalog, err = _load_catalog(cfg)
if err:
return {"valid": False, "errors": [err["error"]]}
from bridge.catalog.validator import validate_catalog
errors = validate_catalog(catalog)
if errors:
return {"valid": False, "errors": errors}
return {"valid": True, "errors": []}
@mcp.tool()
def catalog_show_bridge(bridge_id: str) -> dict:
"""Show full metadata for a catalog bridge definition.
Args:
bridge_id: The bridge identifier.
Returns:
Bridge metadata dict, or {"error": "..."}.
"""
cfg, err = _load_cfg_or_error()
if err:
return err
catalog, err = _load_catalog(cfg)
if err:
return err
if bridge_id not in catalog.bridges:
return {"error": f"Bridge '{bridge_id}' not found"}
b = catalog.bridges[bridge_id]
result = {
"id": b.id,
"domain": b.domain,
"target": b.target,
"host": b.host,
"remote_port": b.remote_port,
"local_port": b.local_port,
"ssh_user": b.ssh_user,
"actor": b.actor,
"access_method": b.access_method,
"description": b.description or "",
}
if b.health_check:
result["health_check"] = {
"url": b.health_check.url,
"interval_seconds": b.health_check.interval_seconds,
"timeout_seconds": b.health_check.timeout_seconds,
}
return result
# ---------------------------------------------------------------------------
# MCP resources
# ---------------------------------------------------------------------------
@mcp.resource("bridge://status")
def resource_bridge_status() -> str:
"""Live snapshot of all tunnel states as JSON."""
rows = bridge_status()
return json.dumps(rows, indent=2)
@mcp.resource("catalog://domains")
def resource_catalog_domains() -> str:
"""List of all catalog domains as JSON."""
domains = catalog_list_domains()
return json.dumps(domains, indent=2)
@mcp.resource("catalog://targets")
def resource_catalog_targets() -> str:
"""List of all catalog targets as JSON."""
targets = catalog_list_targets()
return json.dumps(targets, indent=2)
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
if __name__ == "__main__":
mcp.run(transport="stdio")