Files
lda-wf/docs/historical/superpowers/plans/2026-06-05-source-registry-apply-reload.md
T

25 KiB

Source Registry Apply/Reload 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: Add an explicit wf admin registry apply operation so persisted source registry changes can affect the running MCP-backed WorkflowServer without restarting the process.

Architecture: Keep registry add/update/enable/disable/remove as desired-state writes only. Add a separate apply operation that mirrors the old wf.admin.reload_config pattern: reconcile current config plus registry store into runtime connection/source state using ConnectionService.sync_connections_from_config(...). Expose the operation through wf_api, JSON-RPC HTTP, and CLI.

Tech Stack: Python 3.14, Pydantic v2, Typer, JSON-RPC HTTP transport, wf_api.source_registry_admin, wf_mcp.broker.service.ConnectionService, FileSourceRegistryStore, pytest, ruff, basedpyright.


File Structure

  • Modify src/wf_api/source_registry_admin.py: add an apply provider protocol and WorkflowSourceRegistryApi.apply_registry_changes().
  • Modify src/wf_api/surface.py: add apply_registry_changes() to WorkflowSourceRegistrySurface.
  • Modify src/wf_mcp/broker/service/source_registry_admin.py: implement apply/reload logic for MCP-backed runtime state.
  • Modify src/wf_mcp/broker/server.py: wire apply dependencies into SourceRegistryAdminProvider.
  • Modify src/wf_transport_rpc_http/models.py: add empty params model if needed.
  • Modify src/wf_transport_rpc_http/methods_source_registry.py: register workflow.admin.source_registry.apply.
  • Modify src/wf_transport_rpc_http/client_source_registry.py: add RPC client method.
  • Modify src/wf_cli/commands/source_registry.py: add wf admin registry apply.
  • Modify tests in tests/wf_api, tests/wf_mcp/service, tests/wf_transport_rpc_http, and tests/wf_cli.
  • Update docs/current_roadmap.md and docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md.

Current Context

Registry mutation operations currently write desired state only:

wf admin registry add/update/enable/disable/remove
  -> WorkflowSourceRegistryApi
  -> SourceRegistryAdminProvider
  -> FileSourceRegistryStore

Runtime source state is built by:

service.sync_connections_from_config(
    config,
    source_registry_store=FileSourceRegistryStore(config.store_root),
)

The old config reload path already uses the same reconciliation hook:

def sync_service(config: BrokerConfig) -> None:
    service.sync_connections_from_config(
        config,
        source_registry_store=FileSourceRegistryStore(config.store_root),
    )

This plan adds the registry equivalent of reload, but does not mutate config and does not attempt proxy/FastMCP remount semantics. It updates the neutral workflow server's runtime source/catalog state.

Apply Semantics

apply_registry_changes should:

  • Load the current registry from the registry store.
  • Re-run ConnectionService.sync_connections_from_config(config, source_registry_store=store).
  • Ensure SDK adapters exist for any newly registered connection server.
  • Return a compact summary:
{
    "applied": True,
    "registered": ["new.source"],
    "updated": ["existing.source"],
    "removed": ["old.source"],
    "connection_count": 5,
    "registry_entry_count": 2,
}

Rules:

  • Config locked entries still win over same-id registry entries.
  • Config seed entries still yield to existing registry entries.
  • Disabled registry entries should remain visible as disabled runtime sources when current reconciliation does that today; do not invent new hide/remove semantics in this slice.
  • Local/static servers without registry admin still return source_registry_unavailable.
  • No automatic apply after add/update/enable/disable/remove in v1.
  • No upstream proxy remount, no long-lived subscription handling, no auth prompt flow in this slice.

Task 1: Add API-Layer Apply Contract

Files:

  • Modify: src/wf_api/source_registry_admin.py

  • Modify: src/wf_api/surface.py

  • Test: tests/wf_api/test_source_registry_admin_api.py

  • Step 1: Add a failing API test

Add this test to tests/wf_api/test_source_registry_admin_api.py:

class RecordingApplyProvider:
    def __init__(self) -> None:
        self.called = False

    def apply_registry_changes(self) -> dict[str, object]:
        self.called = True
        return {
            "applied": True,
            "registered": ["demo.new"],
            "updated": [],
            "removed": [],
            "connection_count": 1,
            "registry_entry_count": 1,
        }


async def test_apply_registry_changes_delegates_to_apply_provider() -> None:
    read_provider = _provider([])
    apply_provider = RecordingApplyProvider()
    api = WorkflowSourceRegistryApi(
        provider=read_provider,
        apply_provider=apply_provider,
    )

    payload = await api.apply_registry_changes()

    assert apply_provider.called is True
    assert payload["applied"] is True
    assert payload["registered"] == ["demo.new"]
    assert payload["connection_count"] == 1

Also add:

async def test_apply_registry_changes_requires_apply_provider() -> None:
    api = WorkflowSourceRegistryApi(provider=_provider([]))

    with pytest.raises(TypeError, match="apply_registry_changes requires"):
        await api.apply_registry_changes()
  • Step 2: Run failing tests

Run:

uv run pytest tests/wf_api/test_source_registry_admin_api.py -q

Expected: fail because WorkflowSourceRegistryApi.__init__ does not accept apply_provider and apply_registry_changes does not exist.

  • Step 3: Add provider protocol and API method

In src/wf_api/source_registry_admin.py, add:

@runtime_checkable
class WorkflowSourceRegistryApplyProvider(Protocol):
    """Applies desired registry state to the currently running server."""

    def apply_registry_changes(self) -> Mapping[str, Any] | object: ...

Change WorkflowSourceRegistryApi.__init__ to:

def __init__(
    self,
    *,
    provider: WorkflowSourceRegistryProvider,
    mutation_provider: WorkflowSourceRegistryMutationProvider | None = None,
    apply_provider: WorkflowSourceRegistryApplyProvider | None = None,
) -> None:
    self._provider = provider
    self._mutation_provider = mutation_provider
    self._apply_provider = apply_provider

Add:

async def apply_registry_changes(self) -> dict[str, Any]:
    if self._apply_provider is None:
        raise TypeError("apply_registry_changes requires an apply provider")
    return _payload(self._apply_provider.apply_registry_changes())

Export WorkflowSourceRegistryApplyProvider from src/wf_api/__init__.py.

  • Step 4: Extend protocol surface

In src/wf_api/surface.py, add to WorkflowSourceRegistrySurface:

async def apply_registry_changes(self) -> dict[str, Any]: ...
  • Step 5: Run API tests

Run:

uv run pytest tests/wf_api/test_source_registry_admin_api.py -q

Expected: pass.

Task 2: Implement MCP Runtime Apply Provider

Files:

  • Modify: src/wf_mcp/broker/service/source_registry_admin.py

  • Modify: src/wf_mcp/broker/server.py

  • Test: tests/wf_mcp/service/test_source_registry_admin.py

  • Step 1: Add failing service test

Add this helper near existing test helpers in tests/wf_mcp/service/test_source_registry_admin.py:

def _apply_provider(
    tmp_path,
    *,
    config_connections=(),
    registry_sources=(),
):
    from wf_mcp.broker.service.connection_service import ConnectionService
    from wf_mcp.broker.service.events import BrokerEventRecorder
    from wf_mcp.broker.service.source_catalog import SourceCatalogService
    from wf_mcp.models import BrokerConfig
    from wf_mcp.source_registry import FileSourceRegistryStore, SourceRegistryFile
    from wf_mcp.storage import FileStore
    from wf_mcp.events import EventBus

    events = BrokerEventRecorder(EventBus())
    connection_service = ConnectionService(events=events)
    source_catalog = SourceCatalogService(
        store=FileStore(tmp_path),
        connection_lookup=connection_service.get,
        connection_list_enabled=connection_service.list_enabled,
        connection_list_all=connection_service.list_all,
        tool_executor_for=lambda connection: None,
        load_auth=lambda connection_id: None,
        emit_event=events.record_event,
    )
    connection_service.bind_source_catalog(source_catalog)
    store = FileSourceRegistryStore(tmp_path)
    store.save_registry(SourceRegistryFile(sources=list(registry_sources)))
    config = BrokerConfig(store_root=tmp_path, connections=list(config_connections))
    provider = SourceRegistryAdminProvider(
        source_registry_store=store,
        config_connections=config.connections,
        connection_service=connection_service,
        config=config,
        ensure_adapter=lambda connection: None,
    )
    return provider, connection_service, source_catalog

Then add:

def test_source_registry_apply_materializes_registry_connection(tmp_path) -> None:
    entry = _registry_entry("dynamic.default", provider="dynamic", account="default")
    provider, connection_service, source_catalog = _apply_provider(
        tmp_path,
        registry_sources=[entry],
    )

    payload = provider.apply_registry_changes()

    assert payload["applied"] is True
    assert payload["registered"] == ["dynamic.default"]
    assert payload["updated"] == []
    assert payload["removed"] == []
    assert payload["connection_count"] == 1
    assert payload["registry_entry_count"] == 1
    assert connection_service.get("dynamic.default").server == "dynamic"
    assert source_catalog.capability_sources["dynamic.default"].enabled is True

Add a second test for removal:

def test_source_registry_apply_removes_deleted_registry_connection(tmp_path) -> None:
    entry = _registry_entry("dynamic.default", provider="dynamic", account="default")
    provider, connection_service, source_catalog = _apply_provider(
        tmp_path,
        registry_sources=[entry],
    )
    provider.apply_registry_changes()
    provider.remove_registry_entry("dynamic.default")

    payload = provider.apply_registry_changes()

    assert payload["removed"] == ["dynamic.default"]
    assert "dynamic.default" not in connection_service.connections.connections
    assert "dynamic.default" not in source_catalog.capability_sources
  • Step 2: Run failing service tests

Run:

uv run pytest tests/wf_mcp/service/test_source_registry_admin.py -q

Expected: fail because SourceRegistryAdminProvider lacks runtime apply dependencies and method.

  • Step 3: Extend provider dataclass

In src/wf_mcp/broker/service/source_registry_admin.py, import:

from collections.abc import Callable

from ...models import BrokerConfig
from .connection_service import ConnectionService

Change the dataclass fields to include:

connection_service: ConnectionService | None = None
config: BrokerConfig | None = None
ensure_adapter: Callable[[ConnectionConfig], None] | None = None
  • Step 4: Add apply implementation

Add this method to SourceRegistryAdminProvider:

def apply_registry_changes(self) -> dict[str, Any]:
    """Reconcile desired registry state into the live service connection graph.

    This mirrors config reload reconciliation, but it only applies persisted
    registry state. It does not mutate config files or remount FastMCP proxy
    providers.
    """
    if self.connection_service is None or self.config is None:
        raise RuntimeError("source registry apply requires runtime service context")

    before = {connection.id: connection for connection in self.connection_service.list_all()}
    registry = self.source_registry_store.load_registry()
    self.connection_service.sync_connections_from_config(
        self.config,
        source_registry_store=self.source_registry_store,
    )
    after = {connection.id: connection for connection in self.connection_service.list_all()}

    if self.ensure_adapter is not None:
        for connection in after.values():
            self.ensure_adapter(connection)

    before_ids = set(before)
    after_ids = set(after)
    updated = sorted(
        source_id
        for source_id in before_ids & after_ids
        if before[source_id] != after[source_id]
    )
    return {
        "applied": True,
        "registered": sorted(after_ids - before_ids),
        "updated": updated,
        "removed": sorted(before_ids - after_ids),
        "connection_count": len(after),
        "registry_entry_count": len(registry.sources),
    }
  • Step 5: Wire provider in MCP-backed WorkflowServer

In src/wf_mcp/broker/server.py, inside workflow_server_from_service, replace:

registry_provider = SourceRegistryAdminProvider(
    source_registry_store=source_registry_store,
    config_connections=config.connections,
)

with:

def ensure_adapter(connection: ConnectionConfig) -> None:
    if connection.server not in service.adapters:
        service.register_adapter(connection.server, McpSdkAdapter())


registry_provider = SourceRegistryAdminProvider(
    source_registry_store=source_registry_store,
    config_connections=config.connections,
    connection_service=service.connection_service,
    config=config,
    ensure_adapter=ensure_adapter,
)

Add apply_provider=registry_provider when constructing WorkflowSourceRegistryApi:

source_registry_admin = WorkflowSourceRegistryApi(
    provider=registry_provider,
    mutation_provider=registry_provider,
    apply_provider=registry_provider,
)

Import ConnectionConfig if needed.

  • Step 6: Run service tests

Run:

uv run pytest tests/wf_mcp/service/test_source_registry_admin.py tests/wf_mcp/service/test_connection_service.py -q

Expected: pass.

Task 3: Add JSON-RPC Apply Method and Client

Files:

  • Modify: src/wf_transport_rpc_http/models.py

  • Modify: src/wf_transport_rpc_http/methods_source_registry.py

  • Modify: src/wf_transport_rpc_http/client_source_registry.py

  • Test: tests/wf_transport_rpc_http/test_source_registry_rpc.py

  • Step 1: Add failing RPC tests

In tests/wf_transport_rpc_http/test_source_registry_rpc.py, add:

async def test_rpc_source_registry_apply_unavailable_on_local_static(tmp_path) -> None:
    app = create_rpc_app(build_local_static_workflow_server(tmp_path / "store"))
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        payload = await _rpc(
            client,
            "workflow.admin.source_registry.apply",
            {},
        )

    assert payload["error"]["data"]["code"] == "source_registry_unavailable"

Add a fake method test:

async def test_rpc_source_registry_apply_returns_summary(tmp_path) -> None:
    admin = AsyncMock()
    admin.apply_registry_changes.return_value = {
        "applied": True,
        "registered": ["demo.new"],
        "updated": [],
        "removed": [],
        "connection_count": 1,
        "registry_entry_count": 1,
    }
    server = build_local_static_workflow_server(tmp_path / "store")
    server.source_registry_admin = admin
    app = create_rpc_app(server)
    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        payload = await _rpc(
            client,
            "workflow.admin.source_registry.apply",
            {},
        )

    assert payload["result"]["applied"] is True
    assert payload["result"]["registered"] == ["demo.new"]
    admin.apply_registry_changes.assert_awaited_once()

Add a client method existence test near existing client tests:

async def test_rpc_client_source_registry_apply_method_exists() -> None:
    calls = []

    class Client(RpcSourceRegistryClientMixin):
        async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]:
            calls.append((method, params))
            return {"applied": True}

    payload = await Client().apply_registry_changes()

    assert payload["applied"] is True
    assert calls == [("workflow.admin.source_registry.apply", {})]
  • Step 2: Run failing RPC tests

Run:

uv run pytest tests/wf_transport_rpc_http/test_source_registry_rpc.py -q

Expected: fail because the method/client do not exist.

  • Step 3: Add params model

In src/wf_transport_rpc_http/models.py, add:

class ApplyRegistryChangesParams(RpcParamsModel):
    pass
  • Step 4: Register JSON-RPC method

In src/wf_transport_rpc_http/methods_source_registry.py, import ApplyRegistryChangesParams.

Add inside register_methods:

@entrypoint.method(
    name="workflow.admin.source_registry.apply",
    errors=[WorkflowRpcError],
)
async def workflow_admin_source_registry_apply(
    params: ApplyRegistryChangesParams = RpcParams(),
) -> dict[str, Any]:
    admin = _require_source_registry_admin(server, operation="apply")
    try:
        return await admin.apply_registry_changes()
    except (ValueError, KeyError, LookupError, FileNotFoundError, RuntimeError) as exc:
        raise_workflow_rpc_error(exc)
  • Step 5: Add client method

In src/wf_transport_rpc_http/client_source_registry.py, add:

async def apply_registry_changes(self) -> dict[str, Any]:
    return await self._call(
        "workflow.admin.source_registry.apply",
        {},
    )
  • Step 6: Run RPC tests

Run:

uv run pytest tests/wf_transport_rpc_http/test_source_registry_rpc.py -q

Expected: pass.

Task 4: Add CLI Apply Command

Files:

  • Modify: src/wf_cli/commands/source_registry.py

  • Test: tests/wf_cli/test_source_registry.py

  • Step 1: Add failing CLI test

In tests/wf_cli/test_source_registry.py, add:

def test_registry_apply_calls_surface(monkeypatch) -> None:
    surface = MagicMock()
    surface.apply_registry_changes.return_value = {
        "applied": True,
        "registered": ["demo.new"],
        "updated": [],
        "removed": [],
        "connection_count": 1,
        "registry_entry_count": 1,
    }
    _patch_context(monkeypatch, source_registry_admin=surface)
    result = CliRunner().invoke(app, ["apply"])

    assert result.exit_code == 0, result.output
    payload = json.loads(result.output)
    assert payload["applied"] is True
    assert payload["registered"] == ["demo.new"]

Use the existing test helper names in that file. If the helper is named differently, adapt only the helper call; keep the assertion payload.

  • Step 2: Run failing CLI tests

Run:

uv run pytest tests/wf_cli/test_source_registry.py -q

Expected: fail because apply command does not exist.

  • Step 3: Add command

In src/wf_cli/commands/source_registry.py, add:

@app.command("apply")
def apply_registry_changes(ctx: typer.Context) -> None:
    """Apply desired registry state to the running server."""
    context = load_cli_context_from_typer(ctx)
    admin = _require_registry_admin(context)
    payload = asyncio.run(admin.apply_registry_changes())
    emit_json(payload)
  • Step 4: Run CLI tests

Run:

uv run pytest tests/wf_cli/test_source_registry.py -q

Expected: pass.

Task 5: MCP-Backed RPC Integration Test

Files:

  • Modify: tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py

  • Step 1: Add integration test

Add a test that proves persisted registry changes affect live source inventory after apply:

async def test_mcp_backed_rpc_applies_source_registry_changes(tmp_path) -> None:
    config = BrokerConfig(store_root=tmp_path / "store", connections=[])
    server = build_workflow_server_from_config(config)
    app = create_rpc_app(server)

    async with AsyncClient(
        transport=ASGITransport(app=app),
        base_url="http://test",
    ) as client:
        await _rpc(
            client,
            "workflow.admin.source_registry.add",
            {
                "entry": {
                    "kind": "mcp",
                    "id": "dynamic.default",
                    "enabled": True,
                    "provider": "dynamic",
                    "account": "default",
                    "transport": {
                        "kind": "stdio",
                        "command": "dynamic-server",
                        "args": [],
                        "env": {},
                    },
                }
            },
        )
        before = await _rpc(
            client,
            "workflow.sources.list",
            {"limit": 50},
        )
        applied = await _rpc(
            client,
            "workflow.admin.source_registry.apply",
            {},
        )
        after = await _rpc(
            client,
            "workflow.sources.list",
            {"limit": 50},
        )

    before_ids = {source["id"] for source in before["result"]["sources"]}
    after_ids = {source["id"] for source in after["result"]["sources"]}
    assert "dynamic.default" not in before_ids
    assert applied["result"]["registered"] == ["dynamic.default"]
    assert "dynamic.default" in after_ids

If the existing helper for source list uses a different JSON-RPC method name, use the method already registered in methods_sources.py.

  • Step 2: Run integration test

Run:

uv run pytest tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py -q

Expected: pass.

Task 6: Docs and Final Verification

Files:

  • Modify: docs/current_roadmap.md

  • Modify: docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md

  • Modify: docs/wf_cli.md

  • Step 1: Update roadmap

In docs/current_roadmap.md, under "Source registry apply/reload", add:

    Completed: desired source registry mutations can now be applied explicitly
    through `wf admin registry apply` / `workflow.admin.source_registry.apply`.
    V1 apply reconciles registry state into the current server connection/source
    graph; it does not auto-apply mutations, mutate config files, or remount
    MCP proxy providers.
  • Step 2: Update source registry spec

In docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md, add an "Apply semantics" section:

### Apply Semantics

Registry mutation commands write desired persisted state. They do not implicitly
change the running server. `apply_registry_changes` is the explicit boundary
that reconciles desired registry state with the current runtime source graph.

The apply operation mirrors config reload reconciliation by calling the same
connection/source merge logic. It preserves `locked` config shadowing and `seed`
config handoff rules. It does not mutate config files, remount public MCP proxy
providers, or handle upstream credential prompts.
  • Step 3: Update CLI docs

In docs/wf_cli.md, near source registry commands, add:

After `wf admin registry add/update/enable/disable/remove`, call:

```bash
wf --url http://127.0.0.1:8765/rpc admin registry apply

Apply updates the running server's source graph from desired registry state. It is explicit in v1; registry mutations are not auto-applied.


- [ ] **Step 4: Run focused tests**

Run:

```bash
uv run pytest tests/wf_api/test_source_registry_admin_api.py tests/wf_mcp/service/test_source_registry_admin.py tests/wf_transport_rpc_http/test_source_registry_rpc.py tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py tests/wf_cli/test_source_registry.py -q

Expected: pass.

  • Step 5: Run lint/type checks

Run:

uv run ruff check src/wf_api src/wf_mcp/broker src/wf_transport_rpc_http src/wf_cli tests/wf_api tests/wf_mcp tests/wf_transport_rpc_http tests/wf_cli
uv run basedpyright --level error src/wf_api src/wf_mcp/broker src/wf_transport_rpc_http src/wf_cli tests/wf_api tests/wf_mcp tests/wf_transport_rpc_http tests/wf_cli

Expected: 0 errors.

  • Step 6: Commit

Run:

git add src/wf_api src/wf_mcp/broker src/wf_transport_rpc_http src/wf_cli tests/wf_api tests/wf_mcp tests/wf_transport_rpc_http tests/wf_cli docs/current_roadmap.md docs/superpowers/specs/2026-06-03-store-backed-source-registry-design.md docs/wf_cli.md
git commit -m "feat: apply source registry changes"

Self-Review Checklist

  • Registry mutation methods still only persist desired state.
  • apply_registry_changes is the only new runtime-adoption operation.
  • Local/static servers still return source_registry_unavailable.
  • Locked config entries still shadow same-id registry entries.
  • Seed config entries still bootstrap/yield according to existing merge logic.
  • No proxy/FastMCP remount behavior is introduced.
  • No registry operation mutates wf_mcp.config.json or neutral workflow config.