# Store-Backed Source Registry Design Related: - [Runtime source lifecycle](./2026-06-09-runtime-source-lifecycle.md) ## Purpose The workflow server now has neutral read-only workflow, source-admin, and admin/config surfaces. The next platform step is safe mutation: adding, updating, disabling, and removing server-owned sources/connections without editing config files by hand. This spec defines the persistence and merge rules before mutation commands are implemented. ## Current State `wf_mcp.storage.FileStore` persists: - auth records under `auth/.json` - catalog snapshots under `catalog/.json` It does not persist the connection/source registry itself. Current connection definitions come from config and live in memory through `ConnectionService`. Neutral workflow config currently has: - `client.target`: local or JSON-RPC HTTP target - `server.store`: filesystem store root - `server.transports`: server-hosted transports - `server.sources`: static built-in sources such as `wf.std` / `wf.recipes` Legacy MCP config still has `connections`. ## Goals - Persist server-owned dynamic source/connection changes across process restarts. - Keep config useful as bootstrap and deployment-time infrastructure. - Preserve structural source identity. Source ids are explicit ids, not parsed from dotted display names. - Make mutation validation explicit and fail-fast. - Keep disabled/missing sources visible as diagnostics, not silent deletion from deployments or runs. - Avoid turning catalog snapshots into source definitions. Catalogs are observed capability state; registry entries are desired configuration state. ## Non-Goals - No UI design. - No auth-secret format redesign beyond referencing existing auth records. - No SQL store in the first implementation. - No automatic source id inference from provider/account strings. - No workflow lifecycle changes. ## Registry Model The persisted registry stores desired source/connection definitions: ```json { "version": 1, "sources": [ { "id": "github.work", "kind": "mcp", "enabled": true, "provider": "github", "account": "work", "profile": null, "transport": { "kind": "stdio", "command": "npx", "args": ["-y", "@modelcontextprotocol/server-github"], "env": {} }, "auth_ref": "github.work", "metadata": {} } ] } ``` ### Source Identity `id` is the stable workflow-facing concrete source id. Examples: - `github.work` - `github.personal` - `everything.default` - `wf.std` The id is not decomposed for meaning. The structured fields carry meaning: - `provider`: logical provider/server family, such as `github` - `account`: account/workspace name, such as `work` - `profile`: optional variant under one provider/account - `transport`: concrete connectivity details Transport belongs to the concrete source entry, not only to the provider. The same provider/account may need different connection transports in different server deployments, and one source id must carry the exact runtime transport the server should use. ### Source Kinds Initial persisted kind: - `mcp`: upstream MCP source Bootstrap-only / built-in kinds can remain config/code-owned for now: - `stdlib` - `docs` - `admin` Do not persist built-in `wf.std`, `wf.recipes`, or `wf.admin` in the dynamic registry unless/until there is a real need. ## Store Shape Add a registry store beside existing auth/catalog files: ```text / auth/ catalog/ source_registry.json ``` Why one file first: - The registry is small. - Writes can be atomic by writing a temp file then replacing it. - Whole-file validation is simple. - It matches config-like semantics. Future stores can expose the same interface over SQL or another durable backend. ## Merge Rules On server startup: 1. Load built-in sources from code/config. 2. Load config-defined connections/sources. 3. Load `source_registry.json`. 4. Merge into one desired source map. 5. Hydrate runtime `ConnectionService` / `SourceCatalogService` from that map. Current v1 precedence: 1. Built-in reserved ids always win for reserved ids. 2. Config-defined entries win over dynamic registry entries with the same id. 3. Dynamic registry entries fill in ids not present in config. Rationale: this first pass is conservative. Config is deployment bootstrap and operator-controlled, so dynamic store state should not secretly override source definitions checked into deployment config before source ownership policy exists. Long-term ownership policy: - Config remains valid for bootstrap, dev/test portability, disaster recovery, and operator-managed static sources. - Store-backed registry is the normal mutable desired-state authority for server-owned dynamic sources. - Config should not win forever by default. Future source definitions should distinguish at least two config ownership modes: - `locked`: config owns the id. Registry add/update for that id is rejected or remains explicitly shadowed. - `seed`: config creates or hydrates the initial store entry when missing, then the store owns later admin changes. Until that policy exists in models and startup merge, same-id config entries continue to shadow store entries for safety. Duplicate behavior: - Duplicate ids inside one config or one registry file are validation errors. - A registry entry with the same id as config is allowed but ignored with a diagnostic/event in v1. Future `seed` config entries should instead allow the store entry to become authoritative after seeding. - A registry entry using a reserved id is invalid. ## Mutation Rules Initial mutation commands should target the registry only, not config: - add source - update source - enable/disable source - remove source Rules: - Mutations validate the full registry before saving. - Mutations write one atomic registry replacement. - Enabling a source requires validation of source shape and transport config. - Optional live validation can be requested, but ordinary connection failure should not corrupt registry state. - Removing a source deletes desired registry state only. It does not delete old catalog/auth files in the first pass. - Disable is preferred over remove when deployments may still reference the source. ## Runtime Semantics If a deployment references a missing/disabled/unreachable source: - validation reports diagnostics (`source_missing`, `source_disabled`, `source_unreachable`) - run start fails or is blocked by validation - existing deployments are not rewritten - ordinary dead tools/sources do not become interrupts or pauses ## Events Registry mutations should emit broker/server events: - `source_registered` - `source_updated` - `source_enabled` - `source_disabled` - `source_removed` - `source_registry_ignored_config_shadow` Events are read-only through the existing `WorkflowAdminApi`. ## API Surfaces Do not add mutation to `WorkflowApiSurface`. Likely surfaces: - `WorkflowSourceAdminSurface`: read-only source list/inspect already exists - future `WorkflowSourceRegistrySurface`: mutating registry operations - `WorkflowAdminSurface`: read-only connections/status/events already exists The mutation surface may live in `wf_api` if it remains protocol-neutral. If it becomes transport/provider-heavy, split it into a platform admin package instead of bloating workflow lifecycle APIs. ## First Implementation Slices ### Slice 1: Registry Models and File Store - Add Pydantic registry models. - Add `SourceRegistryStore` protocol. - Add `FileSourceRegistryStore`. - Validate duplicate ids and reserved ids. - Atomic write for filesystem store. - No runtime wiring yet. Status: complete. The first implementation lives in `wf_mcp.source_registry` because the entry shape is MCP-specific: connection-id validation, reserved source ids, and concrete MCP transports all come from the MCP broker layer. ### Slice 2A: Generic Registry Mechanics Status: complete. Generic registry mechanics live in `wf_api.source_registry`; MCP source entries and transports remain in `wf_mcp.source_registry`. - Move protocol-neutral registry mechanics to `wf_api.source_registry`. - Keep MCP source entries, MCP transports, connection-id parsing, and reserved id rules in `wf_mcp.source_registry`. - Generic mechanics include safe registry-id validation, duplicate-id checking, a registry-store protocol, and a small atomic JSON model store. - Do not wire registry state into startup yet. This resolves the current location tension without pretending an MCP source entry is a generic workflow source. Future non-MCP source families should reuse the generic mechanics and define their own entry models. ### Slice 2B: MCP Entry Conversion Status: complete. `registry_entry_to_connection_config()` converts MCP registry entries to broker `ConnectionConfig` values while preserving metadata, `auth_ref`, profile, transport details, enabled state, and source-registry origin. - Add explicit conversion from `McpSourceRegistryEntry` to `ConnectionConfig`. - Preserve structural fields such as provider, account, profile, transport, enabled state, auth reference, and metadata. - Do not merge config and registry yet. ### Slice 3: Startup Merge Status: complete. Broker/service construction now loads `source_registry.json`, merges config-defined connections with dynamic registry entries, preserves config precedence, and emits `source_registry_ignored_config_shadow` for shadowed registry entries. ### Slice 4: Read Registry Through Admin Status: complete for API/transport/CLI plumbing. `WorkflowSourceRegistryApi` provides neutral read-only access to desired registry entries. JSON-RPC methods `workflow.admin.source_registry.list` and `.inspect` are registered. CLI commands `wf admin registry list` and `wf admin registry inspect` are available for targets that expose the surface. Local/static servers report `source_registry_unavailable` instead of pretending to have an empty registry. No mutations added. `wf source list` behavior remains unchanged. Concrete MCP-backed `WorkflowServer` construction remains future work. ### Slice 5: Mutating RPC/CLI Status: complete. Add/update/enable/disable/remove operations are available through `WorkflowSourceRegistryApi`, JSON-RPC methods, and CLI commands. Mutations target persisted desired registry state only; config files, auth records, and catalog snapshots are not mutated. Config-shadowed add is rejected in v1. Remove requires `--confirm` in CLI. Local/static servers report unavailable for mutation commands. Concrete MCP-backed `WorkflowServer` construction remains future work. ### Slice 6: Config Ownership Policy 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. ### 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. ## Open Questions - Should dynamic registry entries support non-MCP transports in v1, or only MCP stdio/HTTP? - Should auth references be required for sources that need auth, or optional until live validation? - Should strict mode fail on `locked` config/store conflicts, or keep them as explicit shadow diagnostics? - Should disabled registry entries still hydrate as disabled sources so inspect can explain them, or stay only in registry/admin output? ## Recommendation Implement Slice 2A next. Keep it independent from runtime startup so the generic/MCP split becomes solid before it affects source hydration. Catalog/auth remain observed/secret state; the new registry is desired source configuration state. Generic registry mechanics can live in `wf_api`, but provider-specific entries and conversion into broker runtime connections stay with the package that owns that provider.