feat: add source config ownership policy
This commit is contained in:
@@ -125,11 +125,15 @@ implementation state.
|
||||
- Source registry startup merge is implemented: absent registry preserves
|
||||
config-only behavior, registry-only entries hydrate as dynamic connections,
|
||||
and config entries shadow same-id registry entries with an event.
|
||||
- Config shadowing is the v1 conservative behavior, not the final ownership
|
||||
- Config shadowing is the v1 conservative behavior, not the final ownership
|
||||
model. The planned follow-up is explicit config ownership policy:
|
||||
`locked` config entries stay operator-owned, while `seed` entries
|
||||
bootstrap missing store entries and then let the store own later admin
|
||||
changes.
|
||||
- Completed: config ownership policy is implemented for MCP broker config
|
||||
connections: `locked` entries stay operator-owned, while `seed` entries
|
||||
bootstrap missing store entries and then let the store own later admin
|
||||
changes.
|
||||
- Next source registry slices are planned in
|
||||
[2026-06-03 source registry next slices](./superpowers/plans/2026-06-03-source-registry-next-slices.md):
|
||||
desired-registry admin reads and safe mutation commands are complete;
|
||||
|
||||
@@ -77,7 +77,7 @@ The next executable slice is startup merge:
|
||||
`WorkflowServer` construction remains future work.
|
||||
|
||||
6. **Slice 6: Config Ownership Policy**
|
||||
- **Status: planned.**
|
||||
- **Status: complete.**
|
||||
- Replace implicit config-shadowing with explicit `locked` / `seed`
|
||||
ownership policy.
|
||||
- `locked` config entries remain operator-owned and shadow/reject registry
|
||||
|
||||
@@ -0,0 +1,722 @@
|
||||
# Source Config Ownership Policy 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:** Replace implicit "config always shadows registry" behavior with explicit config ownership policy for MCP source entries: `locked` vs `seed`.
|
||||
|
||||
**Architecture:** Keep v1 safety by making `locked` the default for existing config connections. Add `seed` as an explicit opt-in that materializes a missing store entry, then lets the store-backed registry own future admin changes. Do this in the MCP broker config path first; neutral `wf_config.server.sources` only models built-in stdlib sources today and should not grow MCP transport config in this slice.
|
||||
|
||||
**Tech Stack:** Python 3.14, Pydantic v2, wf_mcp broker config/service, wf_mcp source registry, pytest, ruff, basedpyright.
|
||||
|
||||
---
|
||||
|
||||
## Current Context
|
||||
|
||||
Relevant current behavior:
|
||||
|
||||
- `src/wf_mcp/broker/service/connection_service.py::ConnectionService.sync_connections_from_config()` loads config connections first, then ignores same-id registry entries.
|
||||
- Same-id registry entries emit `source_registry_ignored_config_shadow`.
|
||||
- `src/wf_mcp/source_registry.py::McpSourceRegistryEntry` is the persisted desired source entry.
|
||||
- `src/wf_mcp/models.py::ConnectionConfig` is the broker runtime config model used by legacy MCP config.
|
||||
- `src/wf_mcp/control.py::BrokerConfigFile.to_runtime()` converts config-file connection declarations into `ConnectionConfig`.
|
||||
|
||||
Intended new policy:
|
||||
|
||||
- `locked`: config owns the source id. Same-id registry entries remain shadowed.
|
||||
- `seed`: config bootstraps a missing registry entry. Once the registry entry exists, registry owns later runtime state for that id.
|
||||
- Backward compatibility: existing config with no policy behaves as `locked`.
|
||||
|
||||
Out of scope:
|
||||
|
||||
- Do not add MCP source transports to neutral `wf_config.server.sources`.
|
||||
- Do not add auth/catalog cleanup.
|
||||
- Do not implement live remount without reload.
|
||||
- Do not alter built-in reserved ids (`wf.std`, `wf.recipes`, `wf.admin`).
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Add Policy Field to Broker Runtime and Config Models
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_mcp/models.py`
|
||||
- Modify: `src/wf_mcp/control.py`
|
||||
- Test: `tests/wf_mcp/test_broker_config.py` or nearest existing broker config test file
|
||||
|
||||
- [ ] **Step 1: Inspect existing model shape**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
rg -n "class ConnectionConfig|class .*Connection" src/wf_mcp/models.py src/wf_mcp/control.py tests/wf_mcp -g '*.py'
|
||||
```
|
||||
|
||||
Expected: locate `ConnectionConfig` and the Pydantic config-file model that constructs it.
|
||||
|
||||
- [ ] **Step 2: Add a policy type and field to runtime config**
|
||||
|
||||
In `src/wf_mcp/models.py`, add a type alias near `ConnectionConfig`:
|
||||
|
||||
```python
|
||||
SourceConfigOwnership = Literal["locked", "seed"]
|
||||
```
|
||||
|
||||
Add to `ConnectionConfig`:
|
||||
|
||||
```python
|
||||
source_config_ownership: SourceConfigOwnership = "locked"
|
||||
```
|
||||
|
||||
If `Literal` is not imported, import it from `typing`.
|
||||
|
||||
- [ ] **Step 3: Add field to config-file connection model**
|
||||
|
||||
In `src/wf_mcp/control.py`, add the same field to the config-file connection model:
|
||||
|
||||
```python
|
||||
source_config_ownership: SourceConfigOwnership = "locked"
|
||||
```
|
||||
|
||||
When converting to `ConnectionConfig`, pass:
|
||||
|
||||
```python
|
||||
source_config_ownership=self.source_config_ownership
|
||||
```
|
||||
|
||||
If the config-file model is named differently, update the exact class that owns `id`, `server`, and `account`.
|
||||
|
||||
- [ ] **Step 4: Add config parsing tests**
|
||||
|
||||
In the existing broker config test file, add:
|
||||
|
||||
```python
|
||||
def test_broker_config_connection_defaults_to_locked(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": str(tmp_path / "store"),
|
||||
"connections": [
|
||||
{"id": "demo.default", "server": "demo", "account": "default"}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
assert config.connections[0].source_config_ownership == "locked"
|
||||
|
||||
|
||||
def test_broker_config_connection_accepts_seed_policy(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": str(tmp_path / "store"),
|
||||
"connections": [
|
||||
{
|
||||
"id": "demo.default",
|
||||
"server": "demo",
|
||||
"account": "default",
|
||||
"source_config_ownership": "seed",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
assert config.connections[0].source_config_ownership == "seed"
|
||||
```
|
||||
|
||||
Import `json`, `Path`, and `load_broker_config` as needed.
|
||||
|
||||
- [ ] **Step 5: Run focused tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_mcp/test_broker_config.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 2: Convert Seed Config Connections to Registry Entries
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_mcp/source_registry.py`
|
||||
- Test: `tests/wf_mcp/test_source_registry.py`
|
||||
|
||||
- [ ] **Step 1: Add conversion helper**
|
||||
|
||||
In `src/wf_mcp/source_registry.py`, add:
|
||||
|
||||
```python
|
||||
def connection_config_to_registry_entry(
|
||||
connection: ConnectionConfig,
|
||||
) -> McpSourceRegistryEntry:
|
||||
"""Materialize a seed config connection into persisted registry state.
|
||||
|
||||
Seed config is bootstrap-only. The registry entry must carry enough source
|
||||
identity to become the future desired-state owner after first startup.
|
||||
"""
|
||||
transport = connection.metadata.get("transport")
|
||||
if not isinstance(transport, dict):
|
||||
raise ValueError(
|
||||
f"seed connection {connection.id!r} requires metadata.transport"
|
||||
)
|
||||
profile = connection.metadata.get("profile")
|
||||
auth_ref = connection.metadata.get("auth_ref")
|
||||
return McpSourceRegistryEntry.model_validate(
|
||||
{
|
||||
"id": connection.id,
|
||||
"enabled": connection.enabled,
|
||||
"provider": connection.server,
|
||||
"account": connection.account,
|
||||
"profile": profile if isinstance(profile, str) else None,
|
||||
"transport": transport,
|
||||
"auth_ref": auth_ref if isinstance(auth_ref, str) else None,
|
||||
"metadata": {
|
||||
key: value
|
||||
for key, value in connection.metadata.items()
|
||||
if key not in {"transport", "profile", "auth_ref", "source_registry"}
|
||||
},
|
||||
}
|
||||
)
|
||||
```
|
||||
|
||||
Also export it in `__all__`.
|
||||
|
||||
- [ ] **Step 2: Add conversion test**
|
||||
|
||||
In `tests/wf_mcp/test_source_registry.py`, add:
|
||||
|
||||
```python
|
||||
def test_connection_config_to_registry_entry_preserves_transport_metadata() -> None:
|
||||
connection = ConnectionConfig(
|
||||
id="github.work",
|
||||
server="github",
|
||||
account="work",
|
||||
enabled=False,
|
||||
metadata={
|
||||
"transport": {"kind": "stdio", "command": "npx", "args": ["server"]},
|
||||
"profile": "corp",
|
||||
"auth_ref": "secret://github/work",
|
||||
"region": "us",
|
||||
},
|
||||
)
|
||||
|
||||
entry = connection_config_to_registry_entry(connection)
|
||||
|
||||
assert entry.id == "github.work"
|
||||
assert entry.provider == "github"
|
||||
assert entry.account == "work"
|
||||
assert entry.enabled is False
|
||||
assert entry.profile == "corp"
|
||||
assert entry.auth_ref == "secret://github/work"
|
||||
assert entry.transport.kind == "stdio"
|
||||
assert entry.metadata["region"] == "us"
|
||||
```
|
||||
|
||||
Add imports for `ConnectionConfig` and `connection_config_to_registry_entry`.
|
||||
|
||||
- [ ] **Step 3: Add missing transport failure test**
|
||||
|
||||
```python
|
||||
def test_connection_config_to_registry_entry_requires_transport_metadata() -> None:
|
||||
connection = ConnectionConfig(id="github.work", server="github", account="work")
|
||||
|
||||
with pytest.raises(ValueError, match="requires metadata.transport"):
|
||||
connection_config_to_registry_entry(connection)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run source registry tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_mcp/test_source_registry.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 3: Implement Locked vs Seed Startup Merge
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_mcp/broker/service/connection_service.py`
|
||||
- Test: `tests/wf_mcp/service/test_connection_service.py`
|
||||
|
||||
- [ ] **Step 1: Update imports**
|
||||
|
||||
In `src/wf_mcp/broker/service/connection_service.py`, import:
|
||||
|
||||
```python
|
||||
from ...source_registry import (
|
||||
SourceRegistryFile,
|
||||
SourceRegistryStore,
|
||||
connection_config_to_registry_entry,
|
||||
registry_entry_to_connection_config,
|
||||
)
|
||||
```
|
||||
|
||||
If `SourceRegistryStore` and `registry_entry_to_connection_config` are already imported, extend the existing import.
|
||||
|
||||
- [ ] **Step 2: Rewrite registry merge block**
|
||||
|
||||
Inside `sync_connections_from_config`, replace the current registry loop with this logic:
|
||||
|
||||
```python
|
||||
connections = list(config.connections)
|
||||
config_by_id = {connection.id: connection for connection in connections}
|
||||
registry_entries = {}
|
||||
registry_changed = False
|
||||
|
||||
if source_registry_store is not None:
|
||||
registry = source_registry_store.load_registry()
|
||||
registry_entries = registry.source_map()
|
||||
|
||||
for connection in connections:
|
||||
if connection.source_config_ownership != "seed":
|
||||
continue
|
||||
if connection.id in registry_entries:
|
||||
continue
|
||||
seeded = connection_config_to_registry_entry(connection)
|
||||
registry_entries[seeded.id] = seeded
|
||||
registry_changed = True
|
||||
self.events.record_kind(
|
||||
"source_registry_seeded_from_config",
|
||||
connection_id=seeded.id,
|
||||
payload={"server": seeded.provider, "account": seeded.account},
|
||||
)
|
||||
|
||||
if registry_changed:
|
||||
source_registry_store.save_registry(
|
||||
SourceRegistryFile(sources=list(registry_entries.values()))
|
||||
)
|
||||
|
||||
merged_connections: list[ConnectionConfig] = []
|
||||
for connection in connections:
|
||||
registry_entry = registry_entries.get(connection.id)
|
||||
if connection.source_config_ownership == "seed" and registry_entry is not None:
|
||||
merged_connections.append(registry_entry_to_connection_config(registry_entry))
|
||||
continue
|
||||
merged_connections.append(connection)
|
||||
|
||||
merged_ids = {connection.id for connection in merged_connections}
|
||||
for entry in registry_entries.values():
|
||||
config_connection = config_by_id.get(entry.id)
|
||||
if config_connection is not None:
|
||||
if config_connection.source_config_ownership == "locked":
|
||||
self.events.record_kind(
|
||||
"source_registry_ignored_config_shadow",
|
||||
connection_id=entry.id,
|
||||
payload={
|
||||
"server": entry.provider,
|
||||
"account": entry.account,
|
||||
"reason": "locked_config_connection_takes_precedence",
|
||||
},
|
||||
)
|
||||
continue
|
||||
if entry.id not in merged_ids:
|
||||
merged_connections.append(registry_entry_to_connection_config(entry))
|
||||
|
||||
connections = merged_connections
|
||||
```
|
||||
|
||||
Important notes:
|
||||
|
||||
- `locked` keeps current behavior.
|
||||
- `seed` with no store entry writes a store entry, then uses that store entry.
|
||||
- `seed` with an existing store entry uses the store entry, not config.
|
||||
- Registry-only entries still hydrate as before.
|
||||
|
||||
- [ ] **Step 3: Add locked behavior regression test**
|
||||
|
||||
In `tests/wf_mcp/service/test_connection_service.py`, keep or add:
|
||||
|
||||
```python
|
||||
def test_connection_service_sync_locked_config_shadows_registry_entry() -> None:
|
||||
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
||||
catalog = _source_catalog(service)
|
||||
store = FileSourceRegistryStore(local_temp_root() / "locked_shadow")
|
||||
store.save_registry(
|
||||
SourceRegistryFile(
|
||||
sources=[
|
||||
_registry_entry(
|
||||
"demo.default",
|
||||
provider="registry",
|
||||
account="stored",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root(),
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="demo.default",
|
||||
server="config",
|
||||
account="locked",
|
||||
source_config_ownership="locked",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
service.bind_source_catalog(catalog)
|
||||
service.sync_connections_from_config(config, source_registry_store=store)
|
||||
|
||||
connection = service.get("demo.default")
|
||||
assert connection.server == "config"
|
||||
assert connection.account == "locked"
|
||||
assert any(
|
||||
event.kind == "source_registry_ignored_config_shadow"
|
||||
for event in service.events.list_events()
|
||||
)
|
||||
```
|
||||
|
||||
Use existing helpers if names differ.
|
||||
|
||||
- [ ] **Step 4: Add seed materialization test**
|
||||
|
||||
```python
|
||||
def test_connection_service_sync_seed_config_materializes_registry_entry() -> None:
|
||||
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
||||
catalog = _source_catalog(service)
|
||||
store_root = local_temp_root() / "seed_materialized"
|
||||
store = FileSourceRegistryStore(store_root)
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root(),
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="demo.default",
|
||||
server="demo",
|
||||
account="default",
|
||||
metadata={"transport": {"kind": "stdio", "command": "demo-server"}},
|
||||
source_config_ownership="seed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
service.bind_source_catalog(catalog)
|
||||
service.sync_connections_from_config(config, source_registry_store=store)
|
||||
|
||||
registry = store.load_registry()
|
||||
assert registry.sources[0].id == "demo.default"
|
||||
assert registry.sources[0].provider == "demo"
|
||||
assert service.get("demo.default").metadata["source_registry"] is True
|
||||
assert any(
|
||||
event.kind == "source_registry_seeded_from_config"
|
||||
for event in service.events.list_events()
|
||||
)
|
||||
```
|
||||
|
||||
- [ ] **Step 5: Add seed existing-store-wins test**
|
||||
|
||||
```python
|
||||
def test_connection_service_sync_seed_existing_registry_entry_wins() -> None:
|
||||
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
||||
catalog = _source_catalog(service)
|
||||
store = FileSourceRegistryStore(local_temp_root() / "seed_existing")
|
||||
store.save_registry(
|
||||
SourceRegistryFile(
|
||||
sources=[
|
||||
_registry_entry(
|
||||
"demo.default",
|
||||
provider="registry",
|
||||
account="stored",
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root(),
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="demo.default",
|
||||
server="config",
|
||||
account="seed",
|
||||
metadata={"transport": {"kind": "stdio", "command": "config-server"}},
|
||||
source_config_ownership="seed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
service.bind_source_catalog(catalog)
|
||||
service.sync_connections_from_config(config, source_registry_store=store)
|
||||
|
||||
connection = service.get("demo.default")
|
||||
assert connection.server == "registry"
|
||||
assert connection.account == "stored"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run connection-service tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_mcp/service/test_connection_service.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 4: Update Registry Mutation Shadow Checks
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_mcp/broker/service/source_registry_admin.py`
|
||||
- Test: `tests/wf_mcp/service/test_source_registry_admin.py`
|
||||
|
||||
- [ ] **Step 1: Add config ownership lookup helper**
|
||||
|
||||
In `SourceRegistryAdminProvider`, add:
|
||||
|
||||
```python
|
||||
def _config_connection(self, source_id: str) -> ConnectionConfig | None:
|
||||
for connection in self.config_connections:
|
||||
if connection.id == source_id:
|
||||
return connection
|
||||
return None
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Change `config_source_ids` if needed**
|
||||
|
||||
Keep `config_source_ids()` unchanged for read payload compatibility:
|
||||
|
||||
```python
|
||||
def config_source_ids(self) -> set[str]:
|
||||
return {connection.id for connection in self.config_connections}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Update `add_registry_entry` shadow rejection**
|
||||
|
||||
Replace the current config id check with:
|
||||
|
||||
```python
|
||||
config_connection = self._config_connection(source_id)
|
||||
if (
|
||||
config_connection is not None
|
||||
and config_connection.source_config_ownership == "locked"
|
||||
):
|
||||
raise ValueError(
|
||||
f"cannot add {source_id!r}: id is locked by a config connection"
|
||||
)
|
||||
```
|
||||
|
||||
For `seed`, adding remains allowed only if no registry entry already exists.
|
||||
|
||||
- [ ] **Step 4: Add locked add rejection test**
|
||||
|
||||
In `tests/wf_mcp/service/test_source_registry_admin.py`, add:
|
||||
|
||||
```python
|
||||
def test_add_rejects_locked_config_shadow(tmp_path: Path) -> None:
|
||||
provider = _provider(tmp_path, config_ids=frozenset({"github.work"}))
|
||||
|
||||
with pytest.raises(ValueError, match="locked by a config connection"):
|
||||
provider.add_registry_entry(_entry_dict("github.work"))
|
||||
```
|
||||
|
||||
If `_provider` cannot pass policy, update it to build `ConnectionConfig(..., source_config_ownership="locked")`.
|
||||
|
||||
- [ ] **Step 5: Add seed add allowed test**
|
||||
|
||||
Update `_provider` to accept config connections or config policy, then add:
|
||||
|
||||
```python
|
||||
def test_add_allows_seed_config_shadow_when_registry_missing(tmp_path: Path) -> None:
|
||||
provider = _provider(
|
||||
tmp_path,
|
||||
config_connections=[
|
||||
ConnectionConfig(
|
||||
id="github.work",
|
||||
server="github",
|
||||
account="work",
|
||||
source_config_ownership="seed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = provider.add_registry_entry(_entry_dict("github.work"))
|
||||
|
||||
assert result.id == "github.work"
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run source registry admin tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_mcp/service/test_source_registry_admin.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 5: Expose Ownership in Admin Registry Payloads
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/wf_api/source_registry_admin.py`
|
||||
- Modify: `src/wf_mcp/broker/service/source_registry_admin.py`
|
||||
- Test: `tests/wf_api/test_source_registry_admin_api.py`
|
||||
- Test: `tests/wf_mcp/service/test_source_registry_admin.py`
|
||||
|
||||
- [ ] **Step 1: Extend provider protocol**
|
||||
|
||||
In `WorkflowSourceRegistryProvider`, add:
|
||||
|
||||
```python
|
||||
def config_source_ownership(self) -> Mapping[str, str]: ...
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Implement provider method**
|
||||
|
||||
In `SourceRegistryAdminProvider`, add:
|
||||
|
||||
```python
|
||||
def config_source_ownership(self) -> dict[str, str]:
|
||||
return {
|
||||
connection.id: connection.source_config_ownership
|
||||
for connection in self.config_connections
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: Include ownership in summary and inspect payloads**
|
||||
|
||||
In `WorkflowSourceRegistryApi`, compute:
|
||||
|
||||
```python
|
||||
ownership = self._provider.config_source_ownership()
|
||||
```
|
||||
|
||||
Update `_entry_summary` signature to accept `ownership: Mapping[str, str]`.
|
||||
|
||||
Include:
|
||||
|
||||
```python
|
||||
"config_ownership": ownership.get(entry_id),
|
||||
"mutable": ownership.get(entry_id) != "locked",
|
||||
```
|
||||
|
||||
For inspect payloads, include the same fields at top level:
|
||||
|
||||
```python
|
||||
"config_ownership": ownership.get(source_id),
|
||||
"mutable": ownership.get(source_id) != "locked",
|
||||
```
|
||||
|
||||
Keep existing `shadowed_by_config` for compatibility.
|
||||
|
||||
- [ ] **Step 4: Update fake providers in tests**
|
||||
|
||||
In `tests/wf_api/test_source_registry_admin_api.py`, add:
|
||||
|
||||
```python
|
||||
def config_source_ownership(self) -> dict[str, str]:
|
||||
return {source_id: "locked" for source_id in self._config_ids}
|
||||
```
|
||||
|
||||
or allow the fake to accept an ownership mapping.
|
||||
|
||||
- [ ] **Step 5: Add API payload test**
|
||||
|
||||
```python
|
||||
def test_list_registry_entries_reports_config_ownership_and_mutability() -> None:
|
||||
api = WorkflowSourceRegistryApi(
|
||||
provider=FakeRegistryProvider(
|
||||
[FakeRegistryEntry(id="github.work")],
|
||||
config_ids={"github.work"},
|
||||
)
|
||||
)
|
||||
|
||||
payload = asyncio.run(api.list_registry_entries())
|
||||
|
||||
entry = payload["entries"][0]
|
||||
assert entry["shadowed_by_config"] is True
|
||||
assert entry["config_ownership"] == "locked"
|
||||
assert entry["mutable"] is False
|
||||
```
|
||||
|
||||
- [ ] **Step 6: Run API/provider tests**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_api/test_source_registry_admin_api.py tests/wf_mcp/service/test_source_registry_admin.py -q
|
||||
```
|
||||
|
||||
Expected: all tests pass.
|
||||
|
||||
---
|
||||
|
||||
### Task 6: Update Docs and Roadmap Status
|
||||
|
||||
**Files:**
|
||||
- Modify: `docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md`
|
||||
- Modify: `docs/superpowers/plans/2026-06-03-source-registry-next-slices.md`
|
||||
- Modify: `docs/current_roadmap.md`
|
||||
|
||||
- [ ] **Step 1: Update spec Slice 6 status**
|
||||
|
||||
In `docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md`, change Slice 6 status from planned to complete and summarize:
|
||||
|
||||
```markdown
|
||||
Status: complete. MCP broker config connections now support
|
||||
`source_config_ownership="locked" | "seed"`. `locked` preserves v1 shadowing.
|
||||
`seed` materializes missing store entries and lets existing registry entries
|
||||
own future runtime state.
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Update next-slices plan**
|
||||
|
||||
In `docs/superpowers/plans/2026-06-03-source-registry-next-slices.md`, mark Slice 6 complete.
|
||||
|
||||
- [ ] **Step 3: Update current roadmap**
|
||||
|
||||
In `docs/current_roadmap.md`, replace the planned wording with implemented wording:
|
||||
|
||||
```markdown
|
||||
Config ownership policy is implemented for MCP broker config connections:
|
||||
`locked` entries stay operator-owned, while `seed` entries bootstrap missing
|
||||
store entries and then let the store own later admin changes.
|
||||
```
|
||||
|
||||
- [ ] **Step 4: Run doc diff check**
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
git diff -- docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md docs/superpowers/plans/2026-06-03-source-registry-next-slices.md docs/current_roadmap.md
|
||||
```
|
||||
|
||||
Expected: docs match the implemented slice and do not claim neutral `wf_config.server.sources` supports MCP source ownership yet.
|
||||
|
||||
---
|
||||
|
||||
## Final Verification
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
uv run pytest tests/wf_mcp/test_source_registry.py tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_source_registry_admin.py tests/wf_api/test_source_registry_admin_api.py -q
|
||||
uv run ruff check src/wf_mcp/models.py src/wf_mcp/control.py src/wf_mcp/source_registry.py src/wf_mcp/broker/service/connection_service.py src/wf_mcp/broker/service/source_registry_admin.py src/wf_api/source_registry_admin.py tests/wf_mcp/test_source_registry.py tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_source_registry_admin.py tests/wf_api/test_source_registry_admin_api.py
|
||||
uv run basedpyright --level error src/wf_mcp/models.py src/wf_mcp/control.py src/wf_mcp/source_registry.py src/wf_mcp/broker/service/connection_service.py src/wf_mcp/broker/service/source_registry_admin.py src/wf_api/source_registry_admin.py tests/wf_mcp/test_source_registry.py tests/wf_mcp/service/test_connection_service.py tests/wf_mcp/service/test_source_registry_admin.py tests/wf_api/test_source_registry_admin_api.py
|
||||
git diff --check
|
||||
```
|
||||
|
||||
Expected: pytest exits 0, ruff exits 0, basedpyright exits 0, and `git diff --check` reports no whitespace errors.
|
||||
|
||||
## Self-Review
|
||||
|
||||
- This plan intentionally keeps `locked` as the default for backward compatibility.
|
||||
- This plan does not add MCP source entries to neutral `wf_config` because current neutral source config only supports built-in sources.
|
||||
- This plan requires `seed` config connections to carry `metadata.transport`; without transport, the config cannot be materialized into a durable registry entry.
|
||||
- The admin payload adds `config_ownership` and `mutable` without removing `shadowed_by_config`, preserving compatibility.
|
||||
@@ -307,12 +307,10 @@ construction remains future work.
|
||||
|
||||
### Slice 6: Config Ownership Policy
|
||||
|
||||
Status: planned. Replace implicit "config always shadows store" with explicit
|
||||
source ownership policy. The intended shape is `locked` for operator-owned
|
||||
config entries and `seed` for bootstrap entries that should hand ownership to
|
||||
the store after initial materialization. This should update config models,
|
||||
startup merge diagnostics, registry mutation validation, and admin read payloads
|
||||
so users can tell why a source id is mutable or shadowed.
|
||||
Status: complete. MCP broker config connections now support
|
||||
`source_config_ownership="locked" | "seed"`. `locked` preserves v1 shadowing.
|
||||
`seed` materializes missing store entries and lets existing registry entries
|
||||
own future runtime state.
|
||||
|
||||
## Open Questions
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ class WorkflowSourceRegistryProvider(Protocol):
|
||||
|
||||
def config_source_ids(self) -> Set[str]: ...
|
||||
|
||||
def config_source_ownership(self) -> Mapping[str, str]: ...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class WorkflowSourceRegistryMutationProvider(Protocol):
|
||||
@@ -58,9 +60,12 @@ class WorkflowSourceRegistryApi:
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
ownership = self._provider.config_source_ownership()
|
||||
entries = sorted(
|
||||
(
|
||||
_entry_summary(_payload(item), self._provider.config_source_ids())
|
||||
_entry_summary(
|
||||
_payload(item), self._provider.config_source_ids(), ownership
|
||||
)
|
||||
for item in self._provider.list_registry_entries()
|
||||
),
|
||||
key=lambda item: str(item.get("id", "")),
|
||||
@@ -77,12 +82,15 @@ class WorkflowSourceRegistryApi:
|
||||
*,
|
||||
source_id: str,
|
||||
) -> dict[str, Any]:
|
||||
ownership = self._provider.config_source_ownership()
|
||||
for item in self._provider.list_registry_entries():
|
||||
entry = _payload(item)
|
||||
if entry.get("id") == source_id:
|
||||
return {
|
||||
"entry": entry,
|
||||
"shadowed_by_config": self._is_shadowed(source_id),
|
||||
"config_ownership": ownership.get(source_id),
|
||||
"mutable": ownership.get(source_id) != "locked",
|
||||
}
|
||||
raise KeyError(f"unknown registry source {source_id!r}")
|
||||
|
||||
@@ -174,11 +182,14 @@ def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]:
|
||||
)
|
||||
|
||||
|
||||
def _entry_summary(entry: dict[str, Any], shadowed_ids: Set[str]) -> dict[str, Any]:
|
||||
def _entry_summary(
|
||||
entry: dict[str, Any], shadowed_ids: Set[str], ownership: Mapping[str, str]
|
||||
) -> dict[str, Any]:
|
||||
transport = entry.get("transport")
|
||||
transport_kind = transport.get("kind") if isinstance(transport, Mapping) else None
|
||||
entry_id = entry["id"]
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"id": entry_id,
|
||||
"kind": entry["kind"],
|
||||
"enabled": entry["enabled"],
|
||||
"provider": entry.get("provider"),
|
||||
@@ -186,5 +197,7 @@ def _entry_summary(entry: dict[str, Any], shadowed_ids: Set[str]) -> dict[str, A
|
||||
"profile": entry.get("profile"),
|
||||
"transport_kind": transport_kind,
|
||||
"auth_ref": entry.get("auth_ref"),
|
||||
"shadowed_by_config": entry["id"] in shadowed_ids,
|
||||
"shadowed_by_config": entry_id in shadowed_ids,
|
||||
"config_ownership": ownership.get(entry_id),
|
||||
"mutable": ownership.get(entry_id) != "locked",
|
||||
}
|
||||
|
||||
@@ -5,7 +5,12 @@ 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 ...source_registry import SourceRegistryStore, registry_entry_to_connection_config
|
||||
from ...source_registry import (
|
||||
SourceRegistryFile,
|
||||
SourceRegistryStore,
|
||||
connection_config_to_registry_entry,
|
||||
registry_entry_to_connection_config,
|
||||
)
|
||||
from .events import BrokerEventRecorder
|
||||
from .source_catalog import SourceCatalogService
|
||||
|
||||
@@ -61,25 +66,68 @@ class ConnectionService:
|
||||
source_registry_store: SourceRegistryStore | None = None,
|
||||
) -> None:
|
||||
"""Reconcile registry/source state after the public server reloads config."""
|
||||
# Config-defined connections win over registry entries with the same id;
|
||||
# registry entries fill ids not present in config.
|
||||
connections = list(config.connections)
|
||||
config_ids = {connection.id for connection in connections}
|
||||
config_by_id = {connection.id: connection for connection in connections}
|
||||
registry_entries = {}
|
||||
registry_changed = False
|
||||
|
||||
if source_registry_store is not None:
|
||||
registry = source_registry_store.load_registry()
|
||||
for entry in registry.sources:
|
||||
if entry.id in config_ids:
|
||||
self.events.record_kind(
|
||||
"source_registry_ignored_config_shadow",
|
||||
connection_id=entry.id,
|
||||
payload={
|
||||
"server": entry.provider,
|
||||
"account": entry.account,
|
||||
"reason": "config_connection_takes_precedence",
|
||||
},
|
||||
registry_entries = registry.source_map()
|
||||
|
||||
for connection in connections:
|
||||
if connection.source_config_ownership != "seed":
|
||||
continue
|
||||
if connection.id in registry_entries:
|
||||
continue
|
||||
seeded = connection_config_to_registry_entry(connection)
|
||||
registry_entries[seeded.id] = seeded
|
||||
registry_changed = True
|
||||
self.events.record_kind(
|
||||
"source_registry_seeded_from_config",
|
||||
connection_id=seeded.id,
|
||||
payload={"server": seeded.provider, "account": seeded.account},
|
||||
)
|
||||
|
||||
if registry_changed:
|
||||
source_registry_store.save_registry(
|
||||
SourceRegistryFile(sources=list(registry_entries.values()))
|
||||
)
|
||||
|
||||
merged_connections: list[ConnectionConfig] = []
|
||||
for connection in connections:
|
||||
registry_entry = registry_entries.get(connection.id)
|
||||
if (
|
||||
connection.source_config_ownership == "seed"
|
||||
and registry_entry is not None
|
||||
):
|
||||
merged_connections.append(
|
||||
registry_entry_to_connection_config(registry_entry)
|
||||
)
|
||||
continue
|
||||
connections.append(registry_entry_to_connection_config(entry))
|
||||
merged_connections.append(connection)
|
||||
|
||||
merged_ids = {connection.id for connection in merged_connections}
|
||||
for entry in registry_entries.values():
|
||||
config_connection = config_by_id.get(entry.id)
|
||||
if config_connection is not None:
|
||||
if config_connection.source_config_ownership == "locked":
|
||||
self.events.record_kind(
|
||||
"source_registry_ignored_config_shadow",
|
||||
connection_id=entry.id,
|
||||
payload={
|
||||
"server": entry.provider,
|
||||
"account": entry.account,
|
||||
"reason": "locked_config_connection_takes_precedence",
|
||||
},
|
||||
)
|
||||
continue
|
||||
if entry.id not in merged_ids:
|
||||
merged_connections.append(
|
||||
registry_entry_to_connection_config(entry)
|
||||
)
|
||||
|
||||
connections = merged_connections
|
||||
|
||||
source_catalog = self._source_catalog()
|
||||
next_ids = {connection.id for connection in connections}
|
||||
|
||||
@@ -33,8 +33,20 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
|
||||
def config_source_ids(self) -> set[str]:
|
||||
return {connection.id for connection in self.config_connections}
|
||||
|
||||
def config_source_ownership(self) -> dict[str, str]:
|
||||
return {
|
||||
connection.id: connection.source_config_ownership
|
||||
for connection in self.config_connections
|
||||
}
|
||||
|
||||
# -- private helpers ----------------------------------------------------
|
||||
|
||||
def _config_connection(self, source_id: str) -> ConnectionConfig | None:
|
||||
for connection in self.config_connections:
|
||||
if connection.id == source_id:
|
||||
return connection
|
||||
return None
|
||||
|
||||
def _load(self) -> SourceRegistryFile:
|
||||
return self.source_registry_store.load_registry()
|
||||
|
||||
@@ -58,9 +70,13 @@ class SourceRegistryAdminProvider(WorkflowSourceRegistryMutationProvider):
|
||||
|
||||
def add_registry_entry(self, entry: Mapping[str, Any]) -> McpSourceRegistryEntry:
|
||||
source_id = str(entry["id"])
|
||||
if source_id in self.config_source_ids():
|
||||
config_connection = self._config_connection(source_id)
|
||||
if (
|
||||
config_connection is not None
|
||||
and config_connection.source_config_ownership == "locked"
|
||||
):
|
||||
raise ValueError(
|
||||
f"cannot add {source_id!r}: id is shadowed by a config connection"
|
||||
f"cannot add {source_id!r}: id is locked by a config connection"
|
||||
)
|
||||
validated = McpSourceRegistryEntry.model_validate(dict(entry))
|
||||
registry = self._load()
|
||||
|
||||
@@ -5,7 +5,7 @@ from typing import Annotated, Any, Literal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, TypeAdapter, field_validator
|
||||
|
||||
from ..models import BrokerConfig, ConnectionConfig
|
||||
from ..models import BrokerConfig, ConnectionConfig, SourceConfigOwnership
|
||||
|
||||
|
||||
class StdioConnectionMetadata(BaseModel):
|
||||
@@ -43,6 +43,7 @@ class ConnectionConfigFile(BaseModel):
|
||||
account: str
|
||||
enabled: bool = True
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
source_config_ownership: SourceConfigOwnership = "locked"
|
||||
|
||||
@field_validator("metadata", mode="before")
|
||||
@classmethod
|
||||
@@ -65,6 +66,7 @@ class ConnectionConfigFile(BaseModel):
|
||||
account=self.account,
|
||||
enabled=self.enabled,
|
||||
metadata=self.metadata,
|
||||
source_config_ownership=self.source_config_ownership,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from dataclasses import asdict, dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Literal
|
||||
|
||||
from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceEntry
|
||||
|
||||
@@ -10,6 +10,9 @@ from .capabilities import CatalogNodeEntry, CatalogPromptEntry, CatalogResourceE
|
||||
from wf_api.models import RawWorkflowPlan # noqa: F401
|
||||
|
||||
|
||||
SourceConfigOwnership = Literal["locked", "seed"]
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class ConnectionConfig:
|
||||
id: str
|
||||
@@ -17,6 +20,7 @@ class ConnectionConfig:
|
||||
account: str
|
||||
enabled: bool = True
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
source_config_ownership: SourceConfigOwnership = "locked"
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
|
||||
@@ -127,6 +127,39 @@ def registry_entry_to_connection_config(
|
||||
)
|
||||
|
||||
|
||||
def connection_config_to_registry_entry(
|
||||
connection: ConnectionConfig,
|
||||
) -> McpSourceRegistryEntry:
|
||||
"""Materialize a seed config connection into persisted registry state.
|
||||
|
||||
Seed config is bootstrap-only. The registry entry must carry enough source
|
||||
identity to become the future desired-state owner after first startup.
|
||||
"""
|
||||
transport = connection.metadata.get("transport")
|
||||
if not isinstance(transport, dict):
|
||||
raise ValueError(
|
||||
f"seed connection {connection.id!r} requires metadata.transport"
|
||||
)
|
||||
profile = connection.metadata.get("profile")
|
||||
auth_ref = connection.metadata.get("auth_ref")
|
||||
return McpSourceRegistryEntry.model_validate(
|
||||
{
|
||||
"id": connection.id,
|
||||
"enabled": connection.enabled,
|
||||
"provider": connection.server,
|
||||
"account": connection.account,
|
||||
"profile": profile if isinstance(profile, str) else None,
|
||||
"transport": transport,
|
||||
"auth_ref": auth_ref if isinstance(auth_ref, str) else None,
|
||||
"metadata": {
|
||||
key: value
|
||||
for key, value in connection.metadata.items()
|
||||
if key not in {"transport", "profile", "auth_ref", "source_registry"}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
__all__ = [
|
||||
"FileSourceRegistryStore",
|
||||
"HttpSourceTransport",
|
||||
@@ -135,5 +168,6 @@ __all__ = [
|
||||
"SourceRegistryStore",
|
||||
"SourceTransport",
|
||||
"StdioSourceTransport",
|
||||
"connection_config_to_registry_entry",
|
||||
"registry_entry_to_connection_config",
|
||||
]
|
||||
|
||||
@@ -38,6 +38,9 @@ class FakeRegistryProvider:
|
||||
def config_source_ids(self) -> set[str]:
|
||||
return self._config_ids
|
||||
|
||||
def config_source_ownership(self) -> dict[str, str]:
|
||||
return {source_id: "locked" for source_id in self._config_ids}
|
||||
|
||||
|
||||
def _api(
|
||||
*entries: FakeRegistryEntry,
|
||||
@@ -118,6 +121,20 @@ def test_list_shadowed_by_config() -> None:
|
||||
assert sl["shadowed_by_config"] is False
|
||||
|
||||
|
||||
def test_list_registry_entries_reports_config_ownership_and_mutability() -> None:
|
||||
api = _api(
|
||||
FakeRegistryEntry(id="github.work"),
|
||||
config_ids={"github.work"},
|
||||
)
|
||||
|
||||
payload = asyncio.run(api.list_registry_entries())
|
||||
|
||||
entry = payload["entries"][0]
|
||||
assert entry["shadowed_by_config"] is True
|
||||
assert entry["config_ownership"] == "locked"
|
||||
assert entry["mutable"] is False
|
||||
|
||||
|
||||
def test_inspect_returns_full_entry_and_shadow_flag() -> None:
|
||||
api = _api(
|
||||
FakeRegistryEntry(
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from wf_mcp.broker import WfMcpService
|
||||
from wf_mcp.broker.service.connection_service import ConnectionService
|
||||
from wf_mcp.broker.service.events import BrokerEventRecorder
|
||||
@@ -258,6 +260,118 @@ def test_connection_service_sync_registry_disabled_entry_hydrates_disabled_sourc
|
||||
assert catalog.capability_sources["demo.registry"].enabled is False
|
||||
|
||||
|
||||
def test_connection_service_sync_locked_config_shadows_registry_entry(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
||||
_source_catalog(service)
|
||||
store = FileSourceRegistryStore(tmp_path / "locked_shadow")
|
||||
store.save_registry(
|
||||
SourceRegistryFile(
|
||||
sources=[
|
||||
McpSourceRegistryEntry(
|
||||
id="demo.default",
|
||||
provider="registry",
|
||||
account="stored",
|
||||
transport=StdioSourceTransport(command="demo-server"),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root(),
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="demo.default",
|
||||
server="config",
|
||||
account="locked",
|
||||
source_config_ownership="locked",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
service.sync_connections_from_config(config, source_registry_store=store)
|
||||
|
||||
connection = service.get("demo.default")
|
||||
assert connection.server == "config"
|
||||
assert connection.account == "locked"
|
||||
assert any(
|
||||
event.kind == "source_registry_ignored_config_shadow"
|
||||
for event in service.events.list_events()
|
||||
)
|
||||
|
||||
|
||||
def test_connection_service_sync_seed_config_materializes_registry_entry(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
||||
_source_catalog(service)
|
||||
store_root = tmp_path / "seed_materialized"
|
||||
store = FileSourceRegistryStore(store_root)
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root(),
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="demo.default",
|
||||
server="demo",
|
||||
account="default",
|
||||
metadata={"transport": {"kind": "stdio", "command": "demo-server"}},
|
||||
source_config_ownership="seed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
service.sync_connections_from_config(config, source_registry_store=store)
|
||||
|
||||
registry = store.load_registry()
|
||||
assert registry.sources[0].id == "demo.default"
|
||||
assert registry.sources[0].provider == "demo"
|
||||
assert service.get("demo.default").metadata["source_registry"] is True
|
||||
all_events = service.events.list_events()
|
||||
assert any(
|
||||
event.kind == "source_registry_seeded_from_config"
|
||||
for event in all_events
|
||||
)
|
||||
|
||||
|
||||
def test_connection_service_sync_seed_existing_registry_entry_wins(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
service = ConnectionService(events=BrokerEventRecorder(EventBus()))
|
||||
_source_catalog(service)
|
||||
store = FileSourceRegistryStore(tmp_path / "seed_existing")
|
||||
store.save_registry(
|
||||
SourceRegistryFile(
|
||||
sources=[
|
||||
McpSourceRegistryEntry(
|
||||
id="demo.default",
|
||||
provider="registry",
|
||||
account="stored",
|
||||
transport=StdioSourceTransport(command="demo-server"),
|
||||
)
|
||||
]
|
||||
)
|
||||
)
|
||||
config = BrokerConfig(
|
||||
store_root=local_temp_root(),
|
||||
connections=[
|
||||
ConnectionConfig(
|
||||
id="demo.default",
|
||||
server="config",
|
||||
account="seed",
|
||||
metadata={"transport": {"kind": "stdio", "command": "config-server"}},
|
||||
source_config_ownership="seed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
service.sync_connections_from_config(config, source_registry_store=store)
|
||||
|
||||
connection = service.get("demo.default")
|
||||
assert connection.server == "registry"
|
||||
assert connection.account == "stored"
|
||||
|
||||
|
||||
def test_wfmcpservice_sync_connections_delegates_registry_store() -> None:
|
||||
service = WfMcpService(store=FileStore(local_temp_root() / "facade_registry"))
|
||||
store = FileSourceRegistryStore(local_temp_root() / "facade_registry_store")
|
||||
|
||||
@@ -48,12 +48,16 @@ def _provider(
|
||||
tmp_path: Path,
|
||||
entries: list[McpSourceRegistryEntry] | None = None,
|
||||
config_ids: frozenset[str] | None = None,
|
||||
config_connections: list[ConnectionConfig] | None = None,
|
||||
) -> SourceRegistryAdminProvider:
|
||||
store = _store_with_entries(tmp_path / "reg", *(entries or []))
|
||||
connections = [
|
||||
ConnectionConfig(id=cid, server="s", account="a")
|
||||
for cid in (config_ids or frozenset())
|
||||
]
|
||||
if config_connections is not None:
|
||||
connections = config_connections
|
||||
else:
|
||||
connections = [
|
||||
ConnectionConfig(id=cid, server="s", account="a")
|
||||
for cid in (config_ids or frozenset())
|
||||
]
|
||||
return SourceRegistryAdminProvider(
|
||||
source_registry_store=store, config_connections=connections
|
||||
)
|
||||
@@ -120,12 +124,30 @@ def test_add_persists_and_round_trips(tmp_path: Path) -> None:
|
||||
def test_add_rejects_config_shadowed_id(tmp_path: Path) -> None:
|
||||
provider = _provider(tmp_path, config_ids=frozenset({"config.server"}))
|
||||
|
||||
with pytest.raises(ValueError, match="shadowed by a config connection"):
|
||||
with pytest.raises(ValueError, match="locked by a config connection"):
|
||||
provider.add_registry_entry(_entry_dict("config.server"))
|
||||
|
||||
assert provider.list_registry_entries() == []
|
||||
|
||||
|
||||
def test_add_allows_seed_config_shadow_when_registry_missing(tmp_path: Path) -> None:
|
||||
provider = _provider(
|
||||
tmp_path,
|
||||
config_connections=[
|
||||
ConnectionConfig(
|
||||
id="github.work",
|
||||
server="github",
|
||||
account="work",
|
||||
source_config_ownership="seed",
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
result = provider.add_registry_entry(_entry_dict("github.work"))
|
||||
|
||||
assert result.id == "github.work"
|
||||
|
||||
|
||||
def test_add_rejects_duplicate_registry_id(tmp_path: Path) -> None:
|
||||
provider = _provider(tmp_path, entries=[_entry("existing.server")])
|
||||
|
||||
|
||||
@@ -549,6 +549,53 @@ def test_build_service_from_config_config_shadows_registry() -> None:
|
||||
)
|
||||
|
||||
|
||||
def test_broker_config_connection_defaults_to_locked() -> None:
|
||||
tmp_path = local_temp_root() / "broker_config_locked_default"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": str(tmp_path / "store"),
|
||||
"connections": [
|
||||
{"id": "demo.default", "server": "demo", "account": "default"}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
assert config.connections[0].source_config_ownership == "locked"
|
||||
|
||||
|
||||
def test_broker_config_connection_accepts_seed_policy() -> None:
|
||||
tmp_path = local_temp_root() / "broker_config_seed_policy"
|
||||
tmp_path.mkdir(parents=True, exist_ok=True)
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"store_root": str(tmp_path / "store"),
|
||||
"connections": [
|
||||
{
|
||||
"id": "demo.default",
|
||||
"server": "demo",
|
||||
"account": "default",
|
||||
"source_config_ownership": "seed",
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
config = load_broker_config(config_path)
|
||||
|
||||
assert config.connections[0].source_config_ownership == "seed"
|
||||
|
||||
|
||||
def _artifact() -> WorkflowArtifact:
|
||||
return WorkflowArtifact(
|
||||
id="summarize_docs",
|
||||
|
||||
@@ -10,8 +10,10 @@ from wf_mcp.source_registry import (
|
||||
McpSourceRegistryEntry,
|
||||
SourceRegistryFile,
|
||||
StdioSourceTransport,
|
||||
connection_config_to_registry_entry,
|
||||
registry_entry_to_connection_config,
|
||||
)
|
||||
from wf_mcp.models import ConnectionConfig
|
||||
|
||||
|
||||
def _entry(source_id: str = "github.work") -> McpSourceRegistryEntry:
|
||||
@@ -116,3 +118,36 @@ def test_registry_entry_to_connection_config_disabled_entry() -> None:
|
||||
config = registry_entry_to_connection_config(entry)
|
||||
|
||||
assert config.enabled is False
|
||||
|
||||
|
||||
def test_connection_config_to_registry_entry_preserves_transport_metadata() -> None:
|
||||
connection = ConnectionConfig(
|
||||
id="github.work",
|
||||
server="github",
|
||||
account="work",
|
||||
enabled=False,
|
||||
metadata={
|
||||
"transport": {"kind": "stdio", "command": "npx", "args": ["server"]},
|
||||
"profile": "corp",
|
||||
"auth_ref": "secret://github/work",
|
||||
"region": "us",
|
||||
},
|
||||
)
|
||||
|
||||
entry = connection_config_to_registry_entry(connection)
|
||||
|
||||
assert entry.id == "github.work"
|
||||
assert entry.provider == "github"
|
||||
assert entry.account == "work"
|
||||
assert entry.enabled is False
|
||||
assert entry.profile == "corp"
|
||||
assert entry.auth_ref == "secret://github/work"
|
||||
assert entry.transport.kind == "stdio"
|
||||
assert entry.metadata["region"] == "us"
|
||||
|
||||
|
||||
def test_connection_config_to_registry_entry_requires_transport_metadata() -> None:
|
||||
connection = ConnectionConfig(id="github.work", server="github", account="work")
|
||||
|
||||
with pytest.raises(ValueError, match="requires metadata.transport"):
|
||||
connection_config_to_registry_entry(connection)
|
||||
|
||||
Reference in New Issue
Block a user