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

527 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
id: BRIDGE-WP-0003
type: workplan
title: "OpsBridge MCP Server, Skill, and Cross-Mode Test Coverage"
domain: custodian
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-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
```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 <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
```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-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.