606 lines
19 KiB
Markdown
606 lines
19 KiB
Markdown
# WfMcpService ConnectionService Extraction Implementation Plan
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Move connection registration and config reconciliation out of `WfMcpService` into a focused `ConnectionService`.
|
|
|
|
**Architecture:** `ConnectionService` owns the broker-local `ConnectionRegistry` and emits connection lifecycle events. `SourceCatalogService` still owns capability sources, so `ConnectionService` binds to it after both services are constructed. `WfMcpService` remains a compatibility facade with `.connections`, `register_connection()`, and `sync_connections_from_config()` delegating to the new service.
|
|
|
|
**Tech Stack:** Python 3.14, dataclasses, pytest, ruff, basedpyright, existing `wf_mcp` broker service modules.
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
- Create `src/wf_mcp/broker/service/connection_service.py`
|
|
- Owns `ConnectionRegistry`.
|
|
- Validates connection IDs and reserved IDs.
|
|
- Registers connections and hydrates source catalog snapshots.
|
|
- Reconciles config reload changes.
|
|
- Modify `src/wf_mcp/broker/service/core.py`
|
|
- Removes direct `ConnectionRegistry` field from `WfMcpService`.
|
|
- Constructs `ConnectionService`, passes its lookup/list callbacks into `SourceCatalogService`, then binds the source catalog back to `ConnectionService`.
|
|
- Keeps compatibility property/method delegates.
|
|
- Create `tests/wf_mcp/service/test_connection_service.py`
|
|
- Direct tests for `ConnectionService`.
|
|
- Service facade smoke test for `.connections` compatibility.
|
|
- Modify `docs/current_roadmap.md`
|
|
- Mark the connection-service extraction as the current/complete slice after implementation.
|
|
- Optionally modify `docs/superpowers/research/2026-06-01-wf-api-extraction-map.md`
|
|
- Add one ownership note if the file still tracks `WfMcpService` decomposition.
|
|
|
|
---
|
|
|
|
### Task 1: Add Direct ConnectionService Tests
|
|
|
|
**Files:**
|
|
|
|
- Create: `tests/wf_mcp/service/test_connection_service.py`
|
|
|
|
- [ ] **Step 1: Create direct tests for the new service boundary**
|
|
|
|
Create `tests/wf_mcp/service/test_connection_service.py` with:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from wf_mcp.broker.service.connection_service import ConnectionService
|
|
from wf_mcp.broker.service.events import BrokerEventRecorder
|
|
from wf_mcp.broker.service.source_catalog import SourceCatalogService
|
|
from wf_mcp.events import EventBus
|
|
from wf_mcp.models import BrokerConfig, ConnectionConfig
|
|
from wf_mcp.runtime import ToolExecutor
|
|
from wf_mcp.storage import FileStore
|
|
|
|
from ..test_support import local_temp_root
|
|
|
|
|
|
def _source_catalog(service: ConnectionService) -> SourceCatalogService:
|
|
store = FileStore(local_temp_root() / "connection_service_catalog")
|
|
|
|
def _tool_executor_for(_connection: ConnectionConfig) -> ToolExecutor:
|
|
raise AssertionError("tool executor should not be needed in these tests")
|
|
|
|
catalog = SourceCatalogService(
|
|
store=store,
|
|
connection_lookup=service.get,
|
|
connection_list_enabled=service.list_enabled,
|
|
connection_list_all=service.list_all,
|
|
tool_executor_for=_tool_executor_for,
|
|
load_auth=lambda _connection_id: None,
|
|
emit_event=service.events.record_event,
|
|
)
|
|
service.bind_source_catalog(catalog)
|
|
return catalog
|
|
|
|
|
|
def test_connection_service_rejects_reserved_connection_ids() -> None:
|
|
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
|
_source_catalog(service)
|
|
|
|
for connection_id in ("wf.admin", "wf.mcp"):
|
|
try:
|
|
service.register_connection(
|
|
ConnectionConfig(id=connection_id, server="wf", account="reserved")
|
|
)
|
|
except ValueError as exc:
|
|
assert connection_id in str(exc)
|
|
assert "reserved by wf-mcp" in str(exc)
|
|
else:
|
|
raise AssertionError(f"expected {connection_id!r} to be rejected")
|
|
|
|
|
|
def test_connection_service_registers_connection_and_empty_source() -> None:
|
|
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
|
catalog = _source_catalog(service)
|
|
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
|
|
assert service.get("demo.personal").server == "demo"
|
|
assert [connection.id for connection in service.list_enabled()] == ["demo.personal"]
|
|
source = catalog.capability_sources["demo.personal"]
|
|
assert source.enabled is True
|
|
assert source.description == "No catalog loaded for demo.personal."
|
|
assert service.events.list_events()[0].kind == "connection_registered"
|
|
assert service.events.list_events()[0].connection_id == "demo.personal"
|
|
|
|
|
|
def test_connection_service_sync_removes_retired_connections_and_sources() -> None:
|
|
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
|
catalog = _source_catalog(service)
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
|
|
service.sync_connections_from_config(
|
|
BrokerConfig(store_root=local_temp_root(), connections=[])
|
|
)
|
|
|
|
assert service.list_all() == []
|
|
assert "demo.personal" not in catalog.capability_sources
|
|
|
|
|
|
def test_connection_service_sync_updates_existing_source_enabled_flag() -> None:
|
|
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
|
catalog = _source_catalog(service)
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
|
|
service.sync_connections_from_config(
|
|
BrokerConfig(
|
|
store_root=local_temp_root(),
|
|
connections=[
|
|
ConnectionConfig(
|
|
id="demo.personal",
|
|
server="demo",
|
|
account="personal",
|
|
enabled=False,
|
|
)
|
|
],
|
|
)
|
|
)
|
|
|
|
assert service.get("demo.personal").enabled is False
|
|
assert catalog.capability_sources["demo.personal"].enabled is False
|
|
```
|
|
|
|
- [ ] **Step 2: Run the direct test and confirm it fails before implementation**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_mcp/service/test_connection_service.py -q
|
|
```
|
|
|
|
Expected: import failure for `wf_mcp.broker.service.connection_service`.
|
|
|
|
---
|
|
|
|
### Task 2: Implement ConnectionService
|
|
|
|
**Files:**
|
|
|
|
- Create: `src/wf_mcp/broker/service/connection_service.py`
|
|
|
|
- [ ] **Step 1: Add the service implementation**
|
|
|
|
Create `src/wf_mcp/broker/service/connection_service.py` with:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
|
|
from ...connections import ConnectionRegistry, parse_connection_id
|
|
from ...models import BrokerConfig, ConnectionConfig
|
|
from ...shared.names import RESERVED_CONNECTION_IDS
|
|
from .events import BrokerEventRecorder
|
|
from .source_catalog import SourceCatalogService
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class ConnectionService:
|
|
"""Own broker connection registration and config reconciliation.
|
|
|
|
SourceCatalogService needs connection lookup callbacks during construction,
|
|
while registering a connection needs source-catalog hydration. The catalog is
|
|
therefore bound after both services exist; `_source_catalog()` makes that
|
|
construction cycle explicit and fail-fast.
|
|
"""
|
|
|
|
events: BrokerEventRecorder
|
|
connections: ConnectionRegistry = field(default_factory=ConnectionRegistry)
|
|
source_catalog: SourceCatalogService | None = None
|
|
|
|
def bind_source_catalog(self, source_catalog: SourceCatalogService) -> None:
|
|
self.source_catalog = source_catalog
|
|
|
|
def get(self, connection_id: str) -> ConnectionConfig:
|
|
return self.connections.get(connection_id)
|
|
|
|
def list_all(self) -> list[ConnectionConfig]:
|
|
return self.connections.list_all()
|
|
|
|
def list_enabled(self) -> list[ConnectionConfig]:
|
|
return self.connections.list_enabled()
|
|
|
|
def register_connection(self, connection: ConnectionConfig) -> None:
|
|
self._validate_connection_id(connection.id)
|
|
self.connections.register(connection)
|
|
self._source_catalog().hydrate_connection_source_from_snapshot(connection)
|
|
self.events.record_kind(
|
|
"connection_registered",
|
|
connection_id=connection.id,
|
|
payload={"server": connection.server, "account": connection.account},
|
|
)
|
|
|
|
def sync_connections_from_config(self, config: BrokerConfig) -> None:
|
|
"""Reconcile registry/source state after the public server reloads config."""
|
|
source_catalog = self._source_catalog()
|
|
next_ids = {connection.id for connection in config.connections}
|
|
previous_ids = set(self.connections.connections)
|
|
for connection_id in previous_ids - next_ids:
|
|
del self.connections.connections[connection_id]
|
|
source_catalog.capability_sources.pop(connection_id, None)
|
|
|
|
for connection in config.connections:
|
|
self._validate_connection_id(connection.id)
|
|
self.connections.register(connection)
|
|
source = source_catalog.capability_sources.get(connection.id)
|
|
if source is None:
|
|
source_catalog.hydrate_connection_source_from_snapshot(connection)
|
|
else:
|
|
source.enabled = connection.enabled
|
|
|
|
def _source_catalog(self) -> SourceCatalogService:
|
|
if self.source_catalog is None:
|
|
raise RuntimeError("ConnectionService requires a bound SourceCatalogService")
|
|
return self.source_catalog
|
|
|
|
@staticmethod
|
|
def _validate_connection_id(connection_id: str) -> None:
|
|
parse_connection_id(connection_id)
|
|
if connection_id in RESERVED_CONNECTION_IDS:
|
|
raise ValueError(f"connection id {connection_id!r} is reserved by wf-mcp")
|
|
```
|
|
|
|
- [ ] **Step 2: Run the direct tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_mcp/service/test_connection_service.py -q
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
- [ ] **Step 3: Run ruff on the new files**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run ruff check src/wf_mcp/broker/service/connection_service.py tests/wf_mcp/service/test_connection_service.py
|
|
```
|
|
|
|
Expected: all checks pass.
|
|
|
|
---
|
|
|
|
### Task 3: Wire WfMcpService Through ConnectionService
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/wf_mcp/broker/service/core.py`
|
|
|
|
- [ ] **Step 1: Update imports and dataclass fields**
|
|
|
|
In `src/wf_mcp/broker/service/core.py`:
|
|
|
|
Remove:
|
|
|
|
```python
|
|
from ...connections import ConnectionRegistry, parse_connection_id
|
|
from ...shared.names import RESERVED_CONNECTION_IDS
|
|
```
|
|
|
|
Replace with:
|
|
|
|
```python
|
|
from ...connections import ConnectionRegistry
|
|
```
|
|
|
|
Add:
|
|
|
|
```python
|
|
from .connection_service import ConnectionService
|
|
```
|
|
|
|
In `WfMcpService`, remove the dataclass field:
|
|
|
|
```python
|
|
connections: ConnectionRegistry = field(default_factory=ConnectionRegistry)
|
|
```
|
|
|
|
Add this init-false field near the other service fields:
|
|
|
|
```python
|
|
connection_service: ConnectionService = field(init=False)
|
|
```
|
|
|
|
- [ ] **Step 2: Construct and bind the connection service**
|
|
|
|
In `__post_init__`, replace the source-catalog construction block with this shape:
|
|
|
|
```python
|
|
self.events = BrokerEventRecorder(self.event_bus)
|
|
self.connection_service = ConnectionService(events=self.events)
|
|
self.upstream = UpstreamTransportService(
|
|
store=self.store,
|
|
event_sink=self.events.record_event,
|
|
tool_executor=self.tool_executor,
|
|
)
|
|
self.source_catalog = SourceCatalogService(
|
|
store=self.store,
|
|
connection_lookup=self.connection_service.get,
|
|
connection_list_enabled=self.connection_service.list_enabled,
|
|
connection_list_all=self.connection_service.list_all,
|
|
tool_executor_for=self.upstream.tool_executor_for,
|
|
load_auth=self.upstream.load_auth,
|
|
emit_event=self.events.record_event,
|
|
default_catalog_max_age_seconds=self.default_catalog_max_age_seconds,
|
|
)
|
|
self.connection_service.bind_source_catalog(self.source_catalog)
|
|
```
|
|
|
|
- [ ] **Step 3: Preserve `.connections` compatibility as a property**
|
|
|
|
Add this property below `capability_sources` or above it:
|
|
|
|
```python
|
|
@property
|
|
def connections(self) -> ConnectionRegistry:
|
|
"""Compatibility view of the broker connection registry.
|
|
|
|
Connection lifecycle ownership has moved to ConnectionService. Keep this
|
|
property because admin handlers, CLI helpers, and tests still inspect the
|
|
registry through the service facade.
|
|
"""
|
|
return self.connection_service.connections
|
|
```
|
|
|
|
- [ ] **Step 4: Replace connection lifecycle method bodies with delegates**
|
|
|
|
Replace `register_connection` with:
|
|
|
|
```python
|
|
def register_connection(self, connection: ConnectionConfig) -> None:
|
|
self.connection_service.register_connection(connection)
|
|
```
|
|
|
|
Replace `sync_connections_from_config` with:
|
|
|
|
```python
|
|
def sync_connections_from_config(self, config: BrokerConfig) -> None:
|
|
self.connection_service.sync_connections_from_config(config)
|
|
```
|
|
|
|
- [ ] **Step 5: Run focused service tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_catalog.py tests/wf_mcp/service/test_sources.py tests/wf_mcp/test_events.py -q
|
|
```
|
|
|
|
Expected: all selected tests pass.
|
|
|
|
---
|
|
|
|
### Task 4: Add Facade Compatibility Tests
|
|
|
|
**Files:**
|
|
|
|
- Modify: `tests/wf_mcp/service/test_connection_service.py`
|
|
|
|
- [ ] **Step 1: Add WfMcpService compatibility coverage**
|
|
|
|
Append these imports:
|
|
|
|
```python
|
|
from wf_mcp.broker import WfMcpService
|
|
```
|
|
|
|
Append these tests:
|
|
|
|
```python
|
|
def test_wfmcpservice_exposes_connection_registry_from_connection_service() -> None:
|
|
service = WfMcpService(store=FileStore(local_temp_root() / "connection_facade"))
|
|
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
|
|
assert service.connections is service.connection_service.connections
|
|
assert service.connections.get("demo.personal").account == "personal"
|
|
assert "demo.personal" in service.capability_sources
|
|
|
|
|
|
def test_wfmcpservice_sync_connections_delegates_to_connection_service() -> None:
|
|
service = WfMcpService(store=FileStore(local_temp_root() / "connection_sync"))
|
|
service.register_connection(
|
|
ConnectionConfig(id="demo.personal", server="demo", account="personal")
|
|
)
|
|
|
|
service.sync_connections_from_config(
|
|
BrokerConfig(
|
|
store_root=local_temp_root(),
|
|
connections=[
|
|
ConnectionConfig(
|
|
id="demo.work",
|
|
server="demo",
|
|
account="work",
|
|
enabled=True,
|
|
)
|
|
],
|
|
)
|
|
)
|
|
|
|
assert [connection.id for connection in service.connections.list_all()] == [
|
|
"demo.work"
|
|
]
|
|
assert "demo.personal" not in service.capability_sources
|
|
assert "demo.work" in service.capability_sources
|
|
```
|
|
|
|
- [ ] **Step 2: Run the compatibility tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_mcp/service/test_connection_service.py -q
|
|
```
|
|
|
|
Expected: all tests pass.
|
|
|
|
---
|
|
|
|
### Task 5: Clean Imports and Verify Call Sites
|
|
|
|
**Files:**
|
|
|
|
- Modify: `src/wf_mcp/broker/service/core.py`
|
|
- Possibly modify files only if ruff reports stale imports.
|
|
|
|
- [ ] **Step 1: Search for stale direct ownership assumptions**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n 'parse_connection_id|RESERVED_CONNECTION_IDS|connection_service|ConnectionRegistry|connections: ConnectionRegistry' src/wf_mcp/broker/service tests/wf_mcp/service
|
|
```
|
|
|
|
Expected:
|
|
|
|
- `parse_connection_id` and `RESERVED_CONNECTION_IDS` appear in `connection_service.py`, not `core.py`.
|
|
- `connections: ConnectionRegistry` appears in `connection_service.py`, not `core.py`.
|
|
- `connection_service` appears in `core.py` and direct tests.
|
|
|
|
- [ ] **Step 2: Run ruff on modified service files**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run ruff check src/wf_mcp/broker/service/core.py src/wf_mcp/broker/service/connection_service.py tests/wf_mcp/service/test_connection_service.py
|
|
```
|
|
|
|
Expected: all checks pass. If ruff reports unused imports in `core.py`, remove only those imports.
|
|
|
|
- [ ] **Step 3: Run basedpyright on modified source**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run basedpyright --level error
|
|
```
|
|
|
|
Expected: 0 errors.
|
|
|
|
---
|
|
|
|
### Task 6: Update Roadmap and Extraction Map
|
|
|
|
**Files:**
|
|
|
|
- Modify: `docs/current_roadmap.md`
|
|
- Modify if present/relevant: `docs/superpowers/research/2026-06-01-wf-api-extraction-map.md`
|
|
|
|
- [ ] **Step 1: Update `docs/current_roadmap.md`**
|
|
|
|
Find the bullet that says:
|
|
|
|
```markdown
|
|
- Next planned service extraction: move connection registration/config reconciliation
|
|
into a `ConnectionService`. That slice should own reserved connection-id
|
|
rejection, `register_connection`, `sync_connections_from_config`, and source
|
|
hydration coordination while leaving `WfMcpService` as a compatibility
|
|
coordinator.
|
|
```
|
|
|
|
Replace it with:
|
|
|
|
```markdown
|
|
- Connection ownership now lives in `ConnectionService`: it owns the broker
|
|
`ConnectionRegistry`, reserved connection-id rejection, `register_connection`,
|
|
and `sync_connections_from_config`. `WfMcpService.connections` remains a
|
|
compatibility property while source hydration still belongs to
|
|
`SourceCatalogService`.
|
|
```
|
|
|
|
- [ ] **Step 2: Update the extraction map if it contains the WfMcpService split notes**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n 'ConnectionService|connection registration|sync_connections_from_config|WfMcpService' docs/superpowers/research/2026-06-01-wf-api-extraction-map.md
|
|
```
|
|
|
|
If the file exists and contains the service split section, add this short note near the other extracted-service bullets:
|
|
|
|
```markdown
|
|
- Connection registration/config reload reconciliation is now owned by
|
|
`wf_mcp.broker.service.connection_service.ConnectionService`. The service owns
|
|
the `ConnectionRegistry`; `WfMcpService.connections` is only a compatibility
|
|
property.
|
|
```
|
|
|
|
- [ ] **Step 3: Run docs grep to verify roadmap wording**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
rg -n 'ConnectionService|Connection ownership|Next planned service extraction' docs/current_roadmap.md docs/superpowers/research/2026-06-01-wf-api-extraction-map.md
|
|
```
|
|
|
|
Expected:
|
|
|
|
- `docs/current_roadmap.md` mentions completed `ConnectionService` ownership.
|
|
- No stale "Next planned service extraction" wording for this same slice remains.
|
|
|
|
---
|
|
|
|
### Task 7: Final Verification
|
|
|
|
**Files:**
|
|
|
|
- No new files.
|
|
|
|
- [ ] **Step 1: Run focused tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_catalog.py tests/wf_mcp/service/test_sources.py tests/wf_mcp/service/test_events.py tests/wf_mcp/test_broker_server.py tests/wf_mcp/test_admin_surface.py -q
|
|
```
|
|
|
|
Expected: all selected tests pass.
|
|
|
|
- [ ] **Step 2: Run full test suite**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest -q
|
|
```
|
|
|
|
Expected: full suite passes with the existing skipped/xfailed counts only.
|
|
|
|
- [ ] **Step 3: Run final static checks**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run ruff check
|
|
uv run ruff format --check
|
|
uv run basedpyright --level error
|
|
```
|
|
|
|
Expected:
|
|
|
|
- ruff check passes.
|
|
- ruff format check passes for Python files.
|
|
- basedpyright reports 0 errors.
|
|
|
|
If markdown format checks complain about preview-only markdown behavior, do not rewrite unrelated markdown. Report it as a formatting-tool limitation and keep the code checks green.
|
|
|
|
---
|
|
|
|
## Self-Review
|
|
|
|
- Spec coverage: The plan moves reserved ID validation, `register_connection`, and `sync_connections_from_config` into `ConnectionService`; preserves `.connections` compatibility; keeps source hydration coordination explicit through a post-construction bind.
|
|
- Placeholder scan: No `TBD`, generic "add tests", or unfilled implementation steps remain.
|
|
- Type consistency: `ConnectionService` exposes `get`, `list_all`, and `list_enabled` so `SourceCatalogService` can use bound methods without depending on `WfMcpService`.
|
|
- Risk: The construction cycle between connection lookup and source hydration is intentionally represented by `bind_source_catalog()`. The fail-fast `_source_catalog()` guard prevents silent use before binding.
|