--- id: BRIDGE-WP-0003 type: workplan title: "OpsBridge MCP Server, Skill, and Cross-Mode Test Coverage" domain: infotech repo: ops-bridge status: done owner: Bernd topic_slug: custodian state_hub_workstream_id: 97009d3f-fd92-4fd9-a308-6c2445b4d623 created: "2026-03-12" updated: "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-27–29, 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 ```python # 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: ```python @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) ```task 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 ```task 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 ```task 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: ```python 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) ```task 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: ```python 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 ```task id: BRIDGE-WP-0003-T05 state_hub_task_id: ef7fa23c-d2e1-4fe0-9e26-994c1a6ce1fb status: done priority: high ``` ```python 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 ```task id: BRIDGE-WP-0003-T06 state_hub_task_id: 71c9ee45-6928-416c-b4f3-dfb785a0ec8f status: done priority: medium ``` ```python @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 ```task id: BRIDGE-WP-0003-T07 state_hub_task_id: 618c011d-bd1b-4c8f-8750-f3d2f9fcaf88 status: done priority: medium ``` ```json { "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 ```task 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 ` 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 ```task 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: ```python @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 ```task 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): ```python @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 ```task 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. ```python @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 ```task 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: ```python 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 ```task id: BRIDGE-WP-0003-T13 state_hub_task_id: c518662a-9a5b-40de-86f5-582a16489cd3 status: done priority: medium ``` Register custom marks to silence `PytestUnknownMarkWarning`: ```toml # 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 ```task 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) ```task 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-27–29** — 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.