generated from coulomb/repo-seed
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:
73
src/bridge/capabilities.py
Normal file
73
src/bridge/capabilities.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
0
src/bridge/mcp_server/__init__.py
Normal file
0
src/bridge/mcp_server/__init__.py
Normal file
465
src/bridge/mcp_server/server.py
Normal file
465
src/bridge/mcp_server/server.py
Normal 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")
|
||||
Reference in New Issue
Block a user