12 KiB
Store-Backed Source Registry Design
Related:
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/<connection_id>.json - catalog snapshots under
catalog/<connection_id>.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 targetserver.store: filesystem store rootserver.transports: server-hosted transportsserver.sources: static built-in sources such aswf.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:
{
"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.workgithub.personaleverything.defaultwf.std
The id is not decomposed for meaning. The structured fields carry meaning:
provider: logical provider/server family, such asgithubaccount: account/workspace name, such asworkprofile: optional variant under one provider/accounttransport: 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:
stdlibdocsadmin
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:
<store_root>/
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:
- Load built-in sources from code/config.
- Load config-defined connections/sources.
- Load
source_registry.json. - Merge into one desired source map.
- Hydrate runtime
ConnectionService/SourceCatalogServicefrom that map.
Current v1 precedence:
- Built-in reserved ids always win for reserved ids.
- Config-defined entries win over dynamic registry entries with the same id.
- 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
seedconfig 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_registeredsource_updatedsource_enabledsource_disabledsource_removedsource_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
SourceRegistryStoreprotocol. - 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
McpSourceRegistryEntrytoConnectionConfig. - 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
lockedconfig/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.