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,73 @@
"""Canonical capability registry for OpsBridge.
Every operation that can be invoked via CLI, MCP, or Skill must be listed here.
The cross-mode test suite uses this registry to enforce test coverage parity.
"""
from __future__ import annotations
from dataclasses import dataclass
ACCESS_MODES = frozenset({"cli", "mcp", "skill"})
@dataclass(frozen=True)
class Capability:
name: str
description: str
required_access_modes: frozenset[str]
CAPABILITIES: list[Capability] = [
Capability(
name="bridge_up",
description="Start one or all tunnels",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="bridge_down",
description="Stop one or all tunnels",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="bridge_restart",
description="Restart one or all tunnels",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="bridge_status",
description="Show tunnel status",
required_access_modes=frozenset({"cli", "mcp", "skill"}),
),
Capability(
name="bridge_logs",
description="Tail tunnel audit log",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="catalog_list_targets",
description="List catalog targets",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="catalog_show_target",
description="Show target metadata",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="catalog_list_domains",
description="List catalog domains",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="catalog_validate",
description="Validate catalog consistency",
required_access_modes=frozenset({"cli", "mcp"}),
),
Capability(
name="catalog_show_bridge",
description="Show bridge metadata",
required_access_modes=frozenset({"cli", "mcp"}),
),
]
CAPABILITIES_BY_NAME: dict[str, Capability] = {c.name: c for c in CAPABILITIES}

View File

@@ -2,9 +2,8 @@
from __future__ import annotations
import logging
import warnings
from pathlib import Path
from typing import Any, Dict
from typing import Any
import yaml

View File

@@ -11,7 +11,6 @@ import typer
from bridge.audit import AuditLogger
from bridge.config import ConfigError, load_config
from bridge.manager import TunnelManager
from bridge.models import BridgeState
from bridge.state import StateManager
app = typer.Typer(
@@ -40,7 +39,7 @@ def _load_or_exit():
def _load_catalog_or_exit(cfg):
from bridge.catalog.loader import CatalogLoadError, load_catalog
from bridge.catalog.loader import load_catalog
if cfg.catalog_path is None:
typer.echo("Error: catalog_path not configured in tunnels.yaml", err=True)
raise typer.Exit(1)

View File

@@ -1,7 +1,7 @@
"""HTTP health checker for OpsBridge."""
from __future__ import annotations
from dataclasses import dataclass, field
from dataclasses import dataclass
from typing import Optional
import httpx

View File

@@ -10,7 +10,6 @@ from pathlib import Path
from typing import List, Optional
from bridge.audit import AuditEvent, AuditLogger
from bridge.config import BridgeConfig
from bridge.health import HealthChecker
from bridge.models import BridgeState, TunnelConfig
from bridge.state import StateManager

View File

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")