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

154
tests/conftest.py Normal file
View File

@@ -0,0 +1,154 @@
"""Shared pytest configuration for OpsBridge tests.
Registers capability and access_mode marks, and provides the
collect_capability_coverage() helper used by the cross-mode meta-test.
"""
from __future__ import annotations
import textwrap
from typing import Iterable
import pytest
# ---------------------------------------------------------------------------
# Shared fixtures
# ---------------------------------------------------------------------------
VALID_CONFIG = textwrap.dedent("""\
tunnels:
test-tunnel:
host: host.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: operator.bernd
actors:
operator.bernd:
class: human
description: Bernd
""")
VALID_CONFIG_WITH_CATALOG = textwrap.dedent("""\
tunnels:
test-tunnel:
host: host.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: operator.bernd
actors:
operator.bernd:
class: human
description: Bernd
catalog_path: {catalog_path}
""")
@pytest.fixture
def config_file(tmp_path):
f = tmp_path / "tunnels.yaml"
f.write_text(VALID_CONFIG)
return f
@pytest.fixture
def state_dir(tmp_path):
d = tmp_path / "state"
d.mkdir()
return d
@pytest.fixture
def catalog_dir(tmp_path):
"""Minimal catalog directory with one domain, target, and bridge."""
cat = tmp_path / "catalog"
domain_dir = cat / "domains" / "coulombcore"
domain_dir.mkdir(parents=True)
(domain_dir / "domain.yaml").write_text(textwrap.dedent("""\
type: domain
id: coulombcore
name: CoulombCore Infrastructure
description: Core infrastructure domain
environment: production
"""))
targets_dir = domain_dir / "targets"
targets_dir.mkdir()
(targets_dir / "state-hub.yaml").write_text(textwrap.dedent("""\
type: target
id: state-hub
domain: coulombcore
kind: service
description: Infrastructure state coordination service
reachable_via:
- state-hub-coulombcore
"""))
bridges_dir = domain_dir / "bridges"
bridges_dir.mkdir()
(bridges_dir / "state-hub-coulombcore.yaml").write_text(textwrap.dedent("""\
type: bridge
id: state-hub-coulombcore
domain: coulombcore
target: state-hub
description: Bridge to state hub
access_method: ssh-reverse
host: coulombcore.local
remote_port: 18000
local_port: 8000
ssh_user: ubuntu
ssh_key: ~/.ssh/id_ops
actor: agent.claude-coulombcore
reconnect:
max_attempts: 0
backoff_initial: 5
backoff_max: 60
"""))
actors_dir = cat / "actors"
actors_dir.mkdir()
(actors_dir / "agent.yaml").write_text(textwrap.dedent("""\
type: actor
id: agent.claude-coulombcore
class: automation
description: Claude Code agent on CoulombCore
"""))
return cat
@pytest.fixture
def config_file_with_catalog(tmp_path, catalog_dir):
f = tmp_path / "tunnels.yaml"
f.write_text(VALID_CONFIG_WITH_CATALOG.format(catalog_path=str(catalog_dir)))
return f
# ---------------------------------------------------------------------------
# Coverage collector helper
# ---------------------------------------------------------------------------
def collect_capability_coverage(items: Iterable) -> set[tuple[str, str]]:
"""Walk pytest items and return set of (capability_name, access_mode) pairs.
Each test item is inspected for `capability` and `access_mode` markers.
A pair is added for every combination of capability × access_mode marks
found on a single item.
Args:
items: Iterable of pytest.Item objects (from session.items or similar).
Returns:
Set of (capability_name, access_mode) tuples found across all items.
"""
covered: set[tuple[str, str]] = set()
for item in items:
capabilities = [
m.args[0] for m in item.iter_markers("capability") if m.args
]
modes = [
m.args[0] for m in item.iter_markers("access_mode") if m.args
]
for cap in capabilities:
for mode in modes:
covered.add((cap, mode))
return covered