feat: add source config ownership policy

This commit is contained in:
lda
2026-06-04 17:49:05 +07:00 Unverified
parent b211eef1c7
commit 00305c1398
15 changed files with 1112 additions and 36 deletions
+5 -1
View File
@@ -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
+17 -4
View File
@@ -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",
}
+63 -15
View File
@@ -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()
+3 -1
View File
@@ -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,
)
+5 -1
View File
@@ -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)
+34
View File
@@ -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")])
+47
View File
@@ -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",
+35
View File
@@ -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)