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:
154
tests/conftest.py
Normal file
154
tests/conftest.py
Normal 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
|
||||
Reference in New Issue
Block a user