Files
ops-bridge/workplans/BRIDGE-WP-0003-mcp-skill-cross-mode-tests.md
tegwick 365c0d611a 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>
2026-03-12 11:33:16 +01:00

16 KiB
Raw Blame History

id, type, title, domain, repo, status, owner, topic_slug, state_hub_workstream_id, created, updated
id type title domain repo status owner topic_slug state_hub_workstream_id created updated
BRIDGE-WP-0003 workplan OpsBridge MCP Server, Skill, and Cross-Mode Test Coverage custodian ops-bridge done Bernd custodian 97009d3f-fd92-4fd9-a308-6c2445b4d623 2026-03-12 2026-03-12

BRIDGE-WP-0003 — OpsBridge MCP Server, Skill, and Cross-Mode Test Coverage

Scope: Expose OpsBridge and OpsCatalog functionality as a FastMCP server and a Claude Code skill. Introduce a capability registry and cross-access-mode test suite that enforces test coverage parity across CLI, MCP, and skill for every operation — including a meta-test that validates the test suite itself is complete.

Depends on: BRIDGE-WP-0001 and BRIDGE-WP-0002 complete. Out of scope: Identity provider integration (FR-2729, deferred indefinitely).


Goal

After this workplan:

  1. Any Claude Code agent can call bridge_up(), bridge_status(), catalog_list_targets() etc. as first-class MCP tools — no Bash required, structured JSON in/out.
  2. Human operators can invoke /bridge-status as a skill to get an immediate, natural-language summary of tunnel health.
  3. Adding any new capability (CLI command, MCP tool) without writing tests for all required access modes causes uv run pytest to fail with a clear capability × mode gap report.
  4. The gap-detection mechanism is itself tested: a synthetic missing-mode fixture asserts the meta-test catches it.

Reference Documents

Document Location
Architecture note CLAUDE.md — Architecture section
OpsBridge FRS wiki/OpsBridgeFrs.md
State Hub MCP server (reference impl) ~/the-custodian/state-hub/mcp_server/server.py

Architecture Summary

src/bridge/
    capabilities.py         # canonical capability registry
    mcp_server/
        __init__.py
        server.py           # FastMCP app, stdio entry point

.mcp.json                   # project-scope MCP registration
scripts/
    register_mcp.py         # user-scope registration helper

~/.claude/plugins/
    ops-bridge/
        bridge-status.md    # /bridge-status skill

tests/
    conftest.py             # capability + access_mode marks, collector helper
    test_cli.py             # existing — annotated with marks (T09)
    test_mcp.py             # new — FastMCP in-process client tests
    test_skill.py           # new — static skill coverage lint
    test_coverage_completeness.py  # new — cross-mode meta-test

Capability Registry

# src/bridge/capabilities.py
from dataclasses import dataclass

ACCESS_MODES = {"cli", "mcp", "skill"}

@dataclass
class Capability:
    name: str
    description: str
    required_access_modes: frozenset[str]

CAPABILITIES: list[Capability] = [
    Capability("bridge_up",              "Start one or all tunnels",          frozenset({"cli", "mcp"})),
    Capability("bridge_down",            "Stop one or all tunnels",           frozenset({"cli", "mcp"})),
    Capability("bridge_restart",         "Restart one or all tunnels",        frozenset({"cli", "mcp"})),
    Capability("bridge_status",          "Show tunnel status",                frozenset({"cli", "mcp", "skill"})),
    Capability("bridge_logs",            "Tail tunnel audit log",             frozenset({"cli", "mcp"})),
    Capability("catalog_list_targets",   "List catalog targets",              frozenset({"cli", "mcp"})),
    Capability("catalog_show_target",    "Show target metadata",              frozenset({"cli", "mcp"})),
    Capability("catalog_list_domains",   "List catalog domains",              frozenset({"cli", "mcp"})),
    Capability("catalog_validate",       "Validate catalog consistency",      frozenset({"cli", "mcp"})),
    Capability("catalog_show_bridge",    "Show bridge metadata",              frozenset({"cli", "mcp"})),
]

Cross-Mode Test Marks

Every test that exercises a capability against an access mode carries two marks:

@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("cli")
def test_bridge_up_cli(runner, config_file):
    result = runner.invoke(app, ["up", "my-tunnel"])
    assert result.exit_code == 0

@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("mcp")
async def test_bridge_up_mcp(mcp_client):
    result = await mcp_client.call_tool("bridge_up", {"tunnel": "my-tunnel"})
    assert result["started"] == ["my-tunnel"]

Meta-Test Mechanism

test_coverage_completeness.py uses a pytest plugin hook to collect all test items, read their marks, and assert the coverage matrix is complete:

capability            cli   mcp   skill
bridge_up              ✓     ✓     —      (not required for skill)
bridge_status          ✓     ✓     ✓
catalog_list_targets   ✓     ✓     —
...

Fails with a table of gaps. The meta-test is itself validated by a fixture that injects a synthetic Capability("test_sentinel", frozenset({"cli","mcp"})), deliberately omits the mcp test, and asserts the checker raises.


Phase 1 — Capability Registry

Acceptance: from bridge.capabilities import CAPABILITIES works; every existing CLI command and the planned MCP tool set appears in the registry.

T01 — Define capability registry module (src/bridge/capabilities.py)

id: BRIDGE-WP-0003-T01
state_hub_task_id: 1397a838-b225-4452-ad53-29ad65388060
status: done
priority: high

Capability dataclass with name, description, required_access_modes. List all 10 capabilities as shown in the architecture above. No external dependencies — pure stdlib.

T02 — Meta-test: registry completeness against CLI commands and MCP tools

id: BRIDGE-WP-0003-T02
state_hub_task_id: 97467243-9237-4e63-a860-cc49587546ad
status: done
priority: high

Introspect app.registered_commands (Typer) and mcp.list_tools() (FastMCP). Assert every name appears in {c.name for c in CAPABILITIES}. Fails fast if a developer adds a CLI command or MCP tool without updating the registry.


Phase 2 — MCP Server

Acceptance: uv run python src/bridge/mcp_server/server.py starts without error; bridge_status() returns a list of tunnel dicts; bridge_up("x") returns {"started": ["x"]} or {"already_running": ["x"]}.

T03 — Add fastmcp dependency and mcp_server package skeleton

id: BRIDGE-WP-0003-T03
state_hub_task_id: f2fd64f5-31c6-493b-b48b-d13980467cca
status: done
priority: high

Add fastmcp>=2.0.0 to [project.dependencies] in pyproject.toml. Create src/bridge/mcp_server/__init__.py (empty) and server.py with:

from fastmcp import FastMCP
mcp = FastMCP(name="ops-bridge", instructions="...")
if __name__ == "__main__":
    mcp.run(transport="stdio")

T04 — Implement bridge lifecycle MCP tools (up, down, restart, status, logs)

id: BRIDGE-WP-0003-T04
state_hub_task_id: 1bfc9b36-2be3-4606-a6e9-d611d1ac33ab
status: done
priority: high

@mcp.tool() wrappers that import and call the Python library directly (no subprocess). Signatures:

def bridge_up(tunnel: str | None = None) -> dict
def bridge_down(tunnel: str | None = None) -> dict
def bridge_restart(tunnel: str | None = None) -> dict
def bridge_status() -> list[dict]
def bridge_logs(tunnel: str, lines: int = 50) -> list[dict]

All return JSON-serialisable dicts/lists. tunnel=None means all tunnels.

T05 — Implement catalog MCP tools

id: BRIDGE-WP-0003-T05
state_hub_task_id: ef7fa23c-d2e1-4fe0-9e26-994c1a6ce1fb
status: done
priority: high
def catalog_list_targets(domain: str | None = None) -> list[dict]
def catalog_show_target(target_id: str) -> dict | None
def catalog_list_domains() -> list[dict]
def catalog_validate() -> dict          # {"valid": bool, "errors": list[str]}
def catalog_show_bridge(bridge_id: str) -> dict | None

When catalog_path is not configured in tunnels.yaml, return {"error": "catalog_path not configured"} rather than raising.

T06 — Implement bridge:// and catalog:// MCP resources

id: BRIDGE-WP-0003-T06
state_hub_task_id: 71c9ee45-6928-416c-b4f3-dfb785a0ec8f
status: done
priority: medium
@mcp.resource("bridge://status")
def resource_bridge_status() -> str:
    """Live snapshot of all tunnel states."""

@mcp.resource("catalog://domains")
def resource_catalog_domains() -> str: ...

@mcp.resource("catalog://targets")
def resource_catalog_targets() -> str: ...

Resources are for cheap orientation reads; tools are for actions and parameterised queries. Both are needed.

T07 — Add .mcp.json project-scope registration config

id: BRIDGE-WP-0003-T07
state_hub_task_id: 618c011d-bd1b-4c8f-8750-f3d2f9fcaf88
status: done
priority: medium
{
  "mcpServers": {
    "ops-bridge": {
      "type": "stdio",
      "command": "uv",
      "args": ["run", "python", "src/bridge/mcp_server/server.py"],
      "cwd": "/home/worsch/ops-bridge"
    }
  }
}

Project-scope: Claude Code sessions inside ops-bridge/ get the tools automatically. See T14 for user-scope (machine-global) registration.


Phase 3 — Skill

Acceptance: /bridge-status invoked in Claude Code runs the skill, calls bridge_status MCP tool, and returns a natural-language health summary.

T08 — Implement /bridge-status skill for human operators

id: BRIDGE-WP-0003-T08
state_hub_task_id: 2c070f34-12b5-4dd9-ab24-bb7b6836773c
status: done
priority: medium

Skill file at ~/.claude/plugins/ops-bridge/bridge-status.md. Prompt instructs Claude to:

  1. Call bridge_status MCP tool
  2. Report each tunnel: name, state (with colour hint), host, uptime
  3. Flag any degraded or failed tunnels and suggest bridge restart <name>
  4. If catalog is configured, offer catalog_list_targets for discovery context

Skill prompt must reference the canonical capability names (bridge_status, catalog_list_targets) so test_skill.py can assert coverage statically.


Phase 4 — Cross-Access-Mode Test Suite

Acceptance: uv run pytest fails if any capability is missing a test for any of its required access modes. The failure message is a capability × mode gap matrix. The meta-test is itself verified by a synthetic failing fixture.

T09 — CLI test layer: annotate existing tests with capability/access_mode marks

id: BRIDGE-WP-0003-T09
state_hub_task_id: a8f3f5fb-fcd6-47e9-aad5-85dc803f796d
status: done
priority: high

Retrofit tests/test_cli.py (and other CLI test files) with:

@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("cli")
def test_bridge_up_starts_tunnel(...): ...

Every capability whose required_access_modes includes "cli" must have at least one marked test in the CLI layer.

T10 — MCP test layer: tests/test_mcp.py with FastMCP in-process test client

id: BRIDGE-WP-0003-T10
state_hub_task_id: acb7ada6-111d-4b8d-b201-45748c394c43
status: done
priority: high

Use FastMCP's Client(mcp_app) context manager (in-process, no network):

@pytest.mark.capability("bridge_up")
@pytest.mark.access_mode("mcp")
async def test_bridge_up_mcp(mcp_client, mock_tunnel_manager):
    result = await mcp_client.call_tool("bridge_up", {"tunnel": "t1"})
    assert result["started"] == ["t1"]

Cover: correct return schema, missing tunnel name handled, catalog tools graceful when catalog_path unset, resource URIs return valid JSON.

T11 — Skill test layer: tests/test_skill.py — static skill coverage lint

id: BRIDGE-WP-0003-T11
state_hub_task_id: 071adfa4-2ccb-466b-b298-35130876267f
status: done
priority: medium

Parse the skill markdown file. Assert:

  • File is syntactically valid (frontmatter parseable)
  • Each capability with "skill" in required_access_modes has its name appearing in the skill body text

This is a static lint, not an LLM invocation — fast and deterministic.

@pytest.mark.access_mode("skill")
def test_skill_covers_required_capabilities():
    skill_text = Path("~/.claude/plugins/ops-bridge/bridge-status.md").read_text()
    for cap in CAPABILITIES:
        if "skill" in cap.required_access_modes:
            assert cap.name in skill_text, f"Skill missing capability: {cap.name}"

T12 — Cross-mode completeness meta-test: tests/test_coverage_completeness.py

id: BRIDGE-WP-0003-T12
state_hub_task_id: f1277a48-1790-42bd-8c70-8ba10c68312b
status: done
priority: critical

The centrepiece. Uses a pytest plugin (conftest hook or pytest.ini collect_ignore) to collect all test items, read their marks, build the coverage matrix, and assert completeness:

def test_all_capabilities_have_all_required_mode_tests(pytestconfig):
    covered = collect_capability_coverage(pytestconfig)
    gaps = []
    for cap in CAPABILITIES:
        for mode in cap.required_access_modes:
            if (cap.name, mode) not in covered:
                gaps.append(f"  {cap.name:<30} {mode}")
    if gaps:
        pytest.fail("Missing capability × mode coverage:\n" + "\n".join(gaps))

Self-validation fixture: a separate test injects a synthetic capability Capability("_test_sentinel", frozenset({"cli","mcp"})) into a copy of CAPABILITIES, provides only a cli-marked test for it, and asserts that calling collect_capability_coverage on this patched set reports the mcp gap.

T13 — conftest.py: pytest marks registration and coverage collector helper

id: BRIDGE-WP-0003-T13
state_hub_task_id: c518662a-9a5b-40de-86f5-582a16489cd3
status: done
priority: medium

Register custom marks to silence PytestUnknownMarkWarning:

# pyproject.toml
[tool.pytest.ini_options]
markers = [
    "capability(name): the bridge capability under test",
    "access_mode(mode): access mode being tested (cli, mcp, skill)",
]

Implement collect_capability_coverage(session_or_items) in conftest.py that walks collected items and returns set[tuple[str, str]] of (capability_name, access_mode) pairs.


Phase 5 — Registration and Documentation

Acceptance: python scripts/register_mcp.py registers ops-bridge MCP at user scope; bridge --help still works; uv run pytest passes.

T14 — User-scope registration guide and patch script

id: BRIDGE-WP-0003-T14
state_hub_task_id: b86916ba-59f3-44c1-b874-8af92d30e470
status: done
priority: medium

scripts/register_mcp.py modelled on state-hub/scripts/patch_mcp_cwd.py: reads .mcp.json, registers at user scope via claude mcp add-json -s user, then patches cwd directly in ~/.claude.json. Update README.txt with:

MCP INTEGRATION
---------------
Project-scope (auto, inside ops-bridge/):
  Already configured in .mcp.json.

User-scope (machine-global, any repo):
  python scripts/register_mcp.py

T15 — Integration test: agent workflow (bridge_status → bridge_up → bridge_status)

id: BRIDGE-WP-0003-T15
state_hub_task_id: d826764f-e2f1-4f6a-842c-a1852a88b209
status: done
priority: medium

End-to-end MCP flow with mocked TunnelManager:

  1. bridge_status() → all tunnels stopped
  2. bridge_up("test-tunnel"){"started": ["test-tunnel"]}
  3. bridge_status()test-tunnel now connected

Verifies the MCP layer correctly delegates to the library and state is reflected. Marked @pytest.mark.capability("bridge_up") @pytest.mark.access_mode("mcp").


Capability × Mode Coverage Target

Capability CLI MCP Skill
bridge_up
bridge_down
bridge_restart
bridge_status
bridge_logs
catalog_list_targets
catalog_show_target
catalog_list_domains
catalog_validate
catalog_show_bridge

The skill only requires bridge_status and catalog_list_targets — the two capabilities needed for a health summary. All others are CLI+MCP only.


Deferred

  • FR-2729 — Identity provider integration — separate workplan.
  • Skill coverage for lifecycle operations/bridge-up, /bridge-down skills for human operators are low value; agents use MCP tools directly.
  • Remote MCP transport (SSE/HTTP) — stdio is sufficient for local use; remote transport is a future concern when ops-bridge runs on a headless node.