feat: expose source registry admin reads
This commit is contained in:
@@ -130,10 +130,12 @@ implementation state.
|
||||
[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 remain later
|
||||
slices.
|
||||
- Next executable source registry slice:
|
||||
[2026-06-04 source registry admin reads](./superpowers/plans/2026-06-04-source-registry-admin-reads.md).
|
||||
It keeps desired registry state separate from observed source inventory
|
||||
and adds read-only admin/RPC/CLI access before any mutation commands.
|
||||
- Completed: desired-registry admin read plumbing is available through
|
||||
`WorkflowSourceRegistryApi`, JSON-RPC methods
|
||||
(`workflow.admin.source_registry.list` / `.inspect`), and CLI commands
|
||||
(`wf admin registry list` / `wf admin registry inspect`). Local/static
|
||||
servers report unavailable instead of empty; a concrete MCP-backed
|
||||
`WorkflowServer` construction path remains future work. No mutations added.
|
||||
- Longer term: make the MCP frontend an adapter over these neutral workflow,
|
||||
source-admin, and config-admin surfaces so the old `wf_mcp` server entry
|
||||
point can shrink or retire.
|
||||
|
||||
@@ -59,9 +59,13 @@ The next executable slice is startup merge:
|
||||
- Emit events/diagnostics for shadowed registry entries.
|
||||
|
||||
4. **Slice 4: Read Desired Registry Through Admin**
|
||||
- **Status: planned.**
|
||||
- **Status: complete.**
|
||||
- Expose desired registry entries separately from observed source inventory.
|
||||
- Make the difference visible in API/CLI docs.
|
||||
- `WorkflowSourceRegistryApi` provides neutral read-only access.
|
||||
- JSON-RPC methods `workflow.admin.source_registry.list` / `.inspect`.
|
||||
- CLI commands `wf admin registry list` / `wf admin registry inspect`.
|
||||
- Local/static servers report unavailable instead of empty.
|
||||
- Concrete MCP-backed `WorkflowServer` construction remains future work.
|
||||
|
||||
5. **Slice 5: Mutation Commands**
|
||||
- Add add/update/enable/disable/remove operations.
|
||||
@@ -323,8 +327,13 @@ with config-defined connections/sources.
|
||||
|
||||
Expose desired registry entries distinctly from observed source inventory.
|
||||
|
||||
Implementation plan:
|
||||
`docs/superpowers/plans/2026-06-04-source-registry-admin-reads.md`.
|
||||
Status: complete for API/transport/CLI plumbing. `WorkflowSourceRegistryApi`
|
||||
provides neutral read-only access. JSON-RPC methods
|
||||
`workflow.admin.source_registry.list` / `.inspect` are registered. CLI commands
|
||||
`wf admin registry list` / `wf admin registry inspect` are available for targets
|
||||
that expose the surface. Local/static servers report
|
||||
`source_registry_unavailable`. Concrete MCP-backed `WorkflowServer` construction
|
||||
remains future work.
|
||||
|
||||
### Why
|
||||
|
||||
|
||||
@@ -269,12 +269,14 @@ registry entries.
|
||||
|
||||
### Slice 4: Read Registry Through Admin
|
||||
|
||||
Implementation plan:
|
||||
[2026-06-04 source registry admin reads](../plans/2026-06-04-source-registry-admin-reads.md).
|
||||
|
||||
- Add admin read method for desired registry entries if needed.
|
||||
- Keep current source inventory list as runtime/observed source inventory.
|
||||
- Document difference between desired registry and observed source catalog.
|
||||
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
|
||||
|
||||
|
||||
@@ -24,6 +24,10 @@ from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability
|
||||
from .runs import WorkflowRunApi
|
||||
from .service import WorkflowApi
|
||||
from .source_admin import WorkflowSourceAdminApi
|
||||
from .source_registry_admin import (
|
||||
WorkflowSourceRegistryApi,
|
||||
WorkflowSourceRegistryProvider,
|
||||
)
|
||||
from .surface import (
|
||||
WorkflowAdminSurface,
|
||||
WorkflowApiSurface,
|
||||
@@ -33,6 +37,7 @@ from .surface import (
|
||||
WorkflowDraftSurface,
|
||||
WorkflowRunSurface,
|
||||
WorkflowSourceAdminSurface,
|
||||
WorkflowSourceRegistrySurface,
|
||||
)
|
||||
from .wrapper_hints import (
|
||||
MissingDecision,
|
||||
@@ -101,6 +106,9 @@ __all__ = [
|
||||
"WorkflowRunSurface",
|
||||
"WorkflowSourceAdminApi",
|
||||
"WorkflowSourceAdminSurface",
|
||||
"WorkflowSourceRegistryApi",
|
||||
"WorkflowSourceRegistryProvider",
|
||||
"WorkflowSourceRegistrySurface",
|
||||
"WorkflowSpecProvider",
|
||||
"WorkflowSurfaceCapabilityId",
|
||||
"WrapperAuthoringHints",
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping, Sequence, Set
|
||||
from dataclasses import asdict, is_dataclass
|
||||
from typing import Any, Protocol
|
||||
|
||||
from wf_platform import page_items
|
||||
|
||||
|
||||
class WorkflowSourceRegistryProvider(Protocol):
|
||||
"""Provides desired source registry state for read-only admin frontends."""
|
||||
|
||||
def list_registry_entries(self) -> Sequence[Mapping[str, Any] | object]: ...
|
||||
|
||||
def config_source_ids(self) -> Set[str]: ...
|
||||
|
||||
|
||||
class WorkflowSourceRegistryApi:
|
||||
"""Protocol-neutral read-only desired source registry operations.
|
||||
|
||||
This surface is intentionally separate from WorkflowSourceAdminApi.
|
||||
WorkflowSourceAdminApi exposes observed/hydrated runtime source inventory.
|
||||
This API exposes desired, server-owned configuration state persisted in the
|
||||
source registry file.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
provider: WorkflowSourceRegistryProvider,
|
||||
) -> None:
|
||||
self._provider = provider
|
||||
|
||||
async def list_registry_entries(
|
||||
self,
|
||||
*,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
shadowed_ids = set(self._provider.config_source_ids())
|
||||
entries = sorted(
|
||||
(
|
||||
_entry_summary(_payload(item), shadowed_ids)
|
||||
for item in self._provider.list_registry_entries()
|
||||
),
|
||||
key=lambda item: str(item.get("id", "")),
|
||||
)
|
||||
page = page_items(entries, cursor=cursor, limit=limit)
|
||||
return {
|
||||
"entries": list(page.items),
|
||||
"next_cursor": page.next_cursor,
|
||||
"total": page.total,
|
||||
}
|
||||
|
||||
async def inspect_registry_entry(
|
||||
self,
|
||||
*,
|
||||
source_id: str,
|
||||
) -> dict[str, Any]:
|
||||
shadowed_ids = set(self._provider.config_source_ids())
|
||||
for item in self._provider.list_registry_entries():
|
||||
entry = _payload(item)
|
||||
if entry.get("id") == source_id:
|
||||
return {
|
||||
"entry": entry,
|
||||
"shadowed_by_config": source_id in shadowed_ids,
|
||||
}
|
||||
raise KeyError(f"unknown registry source {source_id!r}")
|
||||
|
||||
|
||||
def _payload(value: Mapping[str, Any] | object) -> dict[str, Any]:
|
||||
"""Normalize provider objects without depending on MCP registry types."""
|
||||
if isinstance(value, Mapping):
|
||||
return dict(value)
|
||||
if is_dataclass(value) and not isinstance(value, type):
|
||||
return asdict(value)
|
||||
model_dump = getattr(value, "model_dump", None)
|
||||
if callable(model_dump):
|
||||
result = model_dump(mode="json")
|
||||
if isinstance(result, dict):
|
||||
return result
|
||||
raise TypeError(
|
||||
f"source registry payload object is not serializable: {type(value)!r}"
|
||||
)
|
||||
|
||||
|
||||
def _entry_summary(entry: dict[str, Any], shadowed_ids: set[str]) -> dict[str, Any]:
|
||||
transport = entry.get("transport")
|
||||
transport_kind = transport.get("kind") if isinstance(transport, Mapping) else None
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"kind": entry["kind"],
|
||||
"enabled": entry["enabled"],
|
||||
"provider": entry.get("provider"),
|
||||
"account": entry.get("account"),
|
||||
"profile": entry.get("profile"),
|
||||
"transport_kind": transport_kind,
|
||||
"auth_ref": entry.get("auth_ref"),
|
||||
"shadowed_by_config": entry["id"] in shadowed_ids,
|
||||
}
|
||||
@@ -226,6 +226,23 @@ class WorkflowAdminSurface(Protocol):
|
||||
async def list_events(self) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
class WorkflowSourceRegistrySurface(Protocol):
|
||||
"""Read-only desired source registry methods exposed by platform frontends."""
|
||||
|
||||
async def list_registry_entries(
|
||||
self,
|
||||
*,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def inspect_registry_entry(
|
||||
self,
|
||||
*,
|
||||
source_id: str,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"WorkflowAdminSurface",
|
||||
"WorkflowApiSurface",
|
||||
@@ -235,4 +252,5 @@ __all__ = [
|
||||
"WorkflowDraftSurface",
|
||||
"WorkflowRunSurface",
|
||||
"WorkflowSourceAdminSurface",
|
||||
"WorkflowSourceRegistrySurface",
|
||||
]
|
||||
|
||||
@@ -8,12 +8,16 @@ import typer
|
||||
from wf_cli.context import load_cli_context_from_typer
|
||||
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||
|
||||
from . import source_registry
|
||||
|
||||
app = typer.Typer(
|
||||
name="admin",
|
||||
help="Read workflow server admin and config state.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
app.add_typer(source_registry.app, name="registry")
|
||||
|
||||
|
||||
@app.command("connections")
|
||||
def list_connections(
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
|
||||
from wf_cli.context import load_cli_context_from_typer
|
||||
from wf_cli.formats import ListOutputFormat, emit_list_payload
|
||||
from wf_cli.io import emit_json
|
||||
|
||||
app = typer.Typer(
|
||||
name="registry",
|
||||
help="List and inspect desired persisted source registry entries.",
|
||||
no_args_is_help=True,
|
||||
)
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_registry_entries(
|
||||
ctx: typer.Context,
|
||||
cursor: Annotated[
|
||||
str | None, typer.Option("--cursor", help="Pagination cursor.")
|
||||
] = None,
|
||||
limit: Annotated[
|
||||
int, typer.Option("--limit", min=1, max=100, help="Maximum rows.")
|
||||
] = 50,
|
||||
output_format: Annotated[
|
||||
ListOutputFormat, typer.Option("--format", help="Output format.")
|
||||
] = ListOutputFormat.JSON,
|
||||
) -> None:
|
||||
"""List desired persisted source registry entries."""
|
||||
context = load_cli_context_from_typer(ctx)
|
||||
if context.source_registry_admin is None:
|
||||
raise typer.BadParameter(
|
||||
"source registry admin reads are not available for this server"
|
||||
)
|
||||
payload = asyncio.run(
|
||||
context.source_registry_admin.list_registry_entries(cursor=cursor, limit=limit)
|
||||
)
|
||||
emit_list_payload(
|
||||
payload,
|
||||
collection_key="entries",
|
||||
output_format=output_format,
|
||||
id_field="id",
|
||||
summary_fields=("kind", "enabled", "provider", "account", "transport_kind"),
|
||||
)
|
||||
|
||||
|
||||
@app.command("inspect")
|
||||
def inspect_registry_entry(
|
||||
ctx: typer.Context,
|
||||
source_id: Annotated[str, typer.Argument(help="Source registry entry id.")],
|
||||
) -> None:
|
||||
"""Inspect one desired persisted source registry entry."""
|
||||
context = load_cli_context_from_typer(ctx)
|
||||
if context.source_registry_admin is None:
|
||||
raise typer.BadParameter(
|
||||
"source registry admin reads are not available for this server"
|
||||
)
|
||||
payload = asyncio.run(
|
||||
context.source_registry_admin.inspect_registry_entry(source_id=source_id)
|
||||
)
|
||||
emit_json(payload)
|
||||
@@ -15,6 +15,7 @@ from wf_api import (
|
||||
WorkflowApiSurface,
|
||||
WorkflowSourceAdminApi,
|
||||
WorkflowSourceAdminSurface,
|
||||
WorkflowSourceRegistrySurface,
|
||||
)
|
||||
from wf_config import (
|
||||
FilesystemStoreConfig,
|
||||
@@ -38,6 +39,7 @@ class CliContext:
|
||||
handlers: WorkflowApiSurface
|
||||
source_admin: WorkflowSourceAdminSurface
|
||||
admin: WorkflowAdminSurface
|
||||
source_registry_admin: WorkflowSourceRegistrySurface | None = None
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -120,6 +122,7 @@ def load_cli_context(
|
||||
handlers=client,
|
||||
source_admin=client,
|
||||
admin=client,
|
||||
source_registry_admin=client,
|
||||
)
|
||||
|
||||
if _is_legacy_mcp_config(resolved_config_path):
|
||||
@@ -165,6 +168,7 @@ def load_cli_context(
|
||||
handlers=client,
|
||||
source_admin=client,
|
||||
admin=client,
|
||||
source_registry_admin=client,
|
||||
)
|
||||
raise ValueError(f"unsupported workflow target {target!r}")
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Sequence
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from ...models import ConnectionConfig
|
||||
from ...source_registry import SourceRegistryStore
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class SourceRegistryAdminProvider:
|
||||
"""Read desired MCP source registry state without mutating it."""
|
||||
|
||||
source_registry_store: SourceRegistryStore
|
||||
config_connections: Sequence[ConnectionConfig] = field(default_factory=tuple)
|
||||
|
||||
def list_registry_entries(self) -> list[object]:
|
||||
return list(self.source_registry_store.load_registry().sources)
|
||||
|
||||
def config_source_ids(self) -> set[str]:
|
||||
return {connection.id for connection in self.config_connections}
|
||||
@@ -9,6 +9,7 @@ from wf_api import (
|
||||
WorkflowAdminApi,
|
||||
WorkflowApi,
|
||||
WorkflowSourceAdminApi,
|
||||
WorkflowSourceRegistryApi,
|
||||
durable_workflow_api,
|
||||
)
|
||||
from wf_api.local_sources import builtin_sources, get_qualified_spec
|
||||
@@ -260,6 +261,7 @@ class WorkflowServer:
|
||||
source_admin: WorkflowSourceAdminApi
|
||||
admin: WorkflowAdminApi
|
||||
events: InMemoryWorkflowEventRecorder
|
||||
source_registry_admin: WorkflowSourceRegistryApi | None = None
|
||||
|
||||
@staticmethod
|
||||
def trace_range(*, start: int, limit: int) -> TraceRange:
|
||||
|
||||
@@ -13,6 +13,7 @@ from .methods_capabilities import register_methods as register_capability_method
|
||||
from .methods_deployments import register_methods as register_deployment_methods
|
||||
from .methods_drafts import register_methods as register_draft_methods
|
||||
from .methods_runs import register_methods as register_run_methods
|
||||
from .methods_source_registry import register_methods as register_source_registry_methods
|
||||
from .methods_sources import register_methods as register_source_methods
|
||||
|
||||
|
||||
@@ -46,6 +47,7 @@ def create_rpc_app(server: WorkflowServer, *, rpc_path: str = "/rpc") -> jsonrpc
|
||||
register_deployment_methods(entrypoint, server)
|
||||
register_run_methods(entrypoint, server)
|
||||
register_source_methods(entrypoint, server)
|
||||
register_source_registry_methods(entrypoint, server)
|
||||
register_admin_methods(entrypoint, server)
|
||||
|
||||
app.bind_entrypoint(entrypoint)
|
||||
|
||||
@@ -11,6 +11,7 @@ from .client_capabilities import RpcCapabilityClientMixin
|
||||
from .client_deployments import RpcDeploymentClientMixin
|
||||
from .client_drafts import RpcDraftClientMixin
|
||||
from .client_runs import RpcRunClientMixin
|
||||
from .client_source_registry import RpcSourceRegistryClientMixin
|
||||
from .client_sources import RpcSourceAdminClientMixin
|
||||
|
||||
|
||||
@@ -23,6 +24,7 @@ class RpcWorkflowApiClient(
|
||||
RpcDeploymentClientMixin,
|
||||
RpcRunClientMixin,
|
||||
RpcSourceAdminClientMixin,
|
||||
RpcSourceRegistryClientMixin,
|
||||
RpcAdminClientMixin,
|
||||
):
|
||||
"""WorkflowApiSurface implementation backed by JSON-RPC HTTP calls.
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
class RpcSourceRegistryClientMixin:
|
||||
"""JSON-RPC implementation of read-only desired source registry surface methods."""
|
||||
|
||||
async def _call(self, method: str, params: dict[str, Any]) -> dict[str, Any]: ...
|
||||
|
||||
async def list_registry_entries(
|
||||
self,
|
||||
*,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"workflow.admin.source_registry.list",
|
||||
{"cursor": cursor, "limit": limit},
|
||||
)
|
||||
|
||||
async def inspect_registry_entry(
|
||||
self,
|
||||
*,
|
||||
source_id: str,
|
||||
) -> dict[str, Any]:
|
||||
return await self._call(
|
||||
"workflow.admin.source_registry.inspect",
|
||||
{"source_id": source_id},
|
||||
)
|
||||
@@ -0,0 +1,64 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from fastapi import Body
|
||||
import fastapi_jsonrpc as jsonrpc
|
||||
from fastapi_jsonrpc import Params
|
||||
|
||||
from wf_server import WorkflowServer
|
||||
|
||||
from .errors import WorkflowRpcError, raise_workflow_rpc_error
|
||||
from .models import InspectRegistryEntryParams, ListRegistryEntriesParams
|
||||
|
||||
|
||||
def register_methods(
|
||||
entrypoint: jsonrpc.Entrypoint,
|
||||
server: WorkflowServer,
|
||||
) -> None:
|
||||
"""Register read-only desired source registry JSON-RPC methods."""
|
||||
|
||||
@entrypoint.method(
|
||||
name="workflow.admin.source_registry.list",
|
||||
errors=[WorkflowRpcError],
|
||||
)
|
||||
async def workflow_admin_source_registry_list(
|
||||
params: ListRegistryEntriesParams = Body(
|
||||
default_factory=ListRegistryEntriesParams,
|
||||
),
|
||||
) -> dict[str, Any]:
|
||||
if server.source_registry_admin is None:
|
||||
raise WorkflowRpcError(
|
||||
data={
|
||||
"code": "source_registry_unavailable",
|
||||
"message": "source registry admin reads are not available for this server",
|
||||
}
|
||||
)
|
||||
try:
|
||||
return await server.source_registry_admin.list_registry_entries(
|
||||
cursor=params.cursor,
|
||||
limit=params.limit,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
|
||||
@entrypoint.method(
|
||||
name="workflow.admin.source_registry.inspect",
|
||||
errors=[WorkflowRpcError],
|
||||
)
|
||||
async def workflow_admin_source_registry_inspect(
|
||||
params: InspectRegistryEntryParams = Params(...), # type: ignore[reportArgumentType]
|
||||
) -> dict[str, Any]:
|
||||
if server.source_registry_admin is None:
|
||||
raise WorkflowRpcError(
|
||||
data={
|
||||
"code": "source_registry_unavailable",
|
||||
"message": "source registry admin reads are not available for this server",
|
||||
}
|
||||
)
|
||||
try:
|
||||
return await server.source_registry_admin.inspect_registry_entry(
|
||||
source_id=params.source_id,
|
||||
)
|
||||
except (ValueError, KeyError, LookupError, FileNotFoundError) as exc:
|
||||
raise_workflow_rpc_error(exc)
|
||||
@@ -179,3 +179,12 @@ class ResumeRunParams(RpcParamsModel):
|
||||
resume_payload: dict[str, Any] = Field(default_factory=dict)
|
||||
resume_outcome: str = Field(default="submitted", min_length=1)
|
||||
trace_range: TraceRangeParams | None = None
|
||||
|
||||
|
||||
class ListRegistryEntriesParams(RpcParamsModel):
|
||||
cursor: str | None = Field(default=None)
|
||||
limit: int = Field(default=50, ge=1, le=100)
|
||||
|
||||
|
||||
class InspectRegistryEntryParams(RpcParamsModel):
|
||||
source_id: str = Field(min_length=1)
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
|
||||
from wf_api import WorkflowSourceRegistryApi, WorkflowSourceRegistrySurface
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FakeRegistryEntry:
|
||||
id: str
|
||||
kind: str = "mcp"
|
||||
enabled: bool = True
|
||||
provider: str = ""
|
||||
account: str = ""
|
||||
profile: str | None = None
|
||||
transport: dict[str, Any] = field(default_factory=lambda: {"kind": "stdio"})
|
||||
auth_ref: str | None = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class FakeRegistryProvider:
|
||||
def __init__(
|
||||
self,
|
||||
entries: list[FakeRegistryEntry] | None = None,
|
||||
config_ids: set[str] | None = None,
|
||||
) -> None:
|
||||
self._entries = entries or []
|
||||
self._config_ids = config_ids or set()
|
||||
|
||||
def list_registry_entries(self) -> list[FakeRegistryEntry]:
|
||||
return self._entries
|
||||
|
||||
def config_source_ids(self) -> set[str]:
|
||||
return self._config_ids
|
||||
|
||||
|
||||
def _api(
|
||||
*entries: FakeRegistryEntry,
|
||||
config_ids: set[str] | None = None,
|
||||
) -> WorkflowSourceRegistryApi:
|
||||
return WorkflowSourceRegistryApi(
|
||||
provider=FakeRegistryProvider(list(entries), config_ids),
|
||||
)
|
||||
|
||||
|
||||
def test_list_returns_compact_summaries_in_id_order() -> None:
|
||||
api = _api(
|
||||
FakeRegistryEntry(id="zeta.work", provider="zeta", account="work"),
|
||||
FakeRegistryEntry(id="alpha.personal", provider="alpha", account="personal"),
|
||||
)
|
||||
|
||||
payload = asyncio.run(api.list_registry_entries())
|
||||
|
||||
assert payload["total"] == 2
|
||||
assert [e["id"] for e in payload["entries"]] == ["alpha.personal", "zeta.work"]
|
||||
|
||||
|
||||
def test_list_summary_fields() -> None:
|
||||
api = _api(
|
||||
FakeRegistryEntry(
|
||||
id="github.work",
|
||||
provider="github",
|
||||
account="work",
|
||||
profile="dev",
|
||||
transport={"kind": "stdio", "command": "npx"},
|
||||
auth_ref="github.work",
|
||||
),
|
||||
)
|
||||
|
||||
payload = asyncio.run(api.list_registry_entries())
|
||||
entry = payload["entries"][0]
|
||||
|
||||
assert entry["id"] == "github.work"
|
||||
assert entry["kind"] == "mcp"
|
||||
assert entry["enabled"] is True
|
||||
assert entry["provider"] == "github"
|
||||
assert entry["account"] == "work"
|
||||
assert entry["profile"] == "dev"
|
||||
assert entry["transport_kind"] == "stdio"
|
||||
assert entry["auth_ref"] == "github.work"
|
||||
|
||||
|
||||
def test_list_pagination() -> None:
|
||||
api = _api(
|
||||
FakeRegistryEntry(id="a"),
|
||||
FakeRegistryEntry(id="b"),
|
||||
FakeRegistryEntry(id="c"),
|
||||
)
|
||||
|
||||
first = asyncio.run(api.list_registry_entries(limit=2))
|
||||
second = asyncio.run(api.list_registry_entries(cursor=first["next_cursor"], limit=2))
|
||||
|
||||
assert [e["id"] for e in first["entries"]] == ["a", "b"]
|
||||
assert first["next_cursor"] == "2"
|
||||
assert [e["id"] for e in second["entries"]] == ["c"]
|
||||
assert second["next_cursor"] is None
|
||||
|
||||
|
||||
def test_list_shadowed_by_config() -> None:
|
||||
api = _api(
|
||||
FakeRegistryEntry(id="github.work"),
|
||||
FakeRegistryEntry(id="slack.personal"),
|
||||
config_ids={"github.work"},
|
||||
)
|
||||
|
||||
payload = asyncio.run(api.list_registry_entries())
|
||||
|
||||
gh = next(e for e in payload["entries"] if e["id"] == "github.work")
|
||||
sl = next(e for e in payload["entries"] if e["id"] == "slack.personal")
|
||||
assert gh["shadowed_by_config"] is True
|
||||
assert sl["shadowed_by_config"] is False
|
||||
|
||||
|
||||
def test_inspect_returns_full_entry_and_shadow_flag() -> None:
|
||||
api = _api(
|
||||
FakeRegistryEntry(
|
||||
id="github.work",
|
||||
provider="github",
|
||||
account="work",
|
||||
transport={"kind": "stdio", "command": "npx", "args": [], "env": {}},
|
||||
auth_ref="github.work",
|
||||
),
|
||||
config_ids={"github.work"},
|
||||
)
|
||||
|
||||
payload = asyncio.run(api.inspect_registry_entry(source_id="github.work"))
|
||||
|
||||
assert payload["entry"]["id"] == "github.work"
|
||||
assert payload["entry"]["transport"]["kind"] == "stdio"
|
||||
assert payload["shadowed_by_config"] is True
|
||||
|
||||
|
||||
def test_inspect_unknown_raises_key_error() -> None:
|
||||
api = _api(FakeRegistryEntry(id="github.work"))
|
||||
|
||||
with pytest.raises(KeyError, match="unknown registry source 'missing'"):
|
||||
asyncio.run(api.inspect_registry_entry(source_id="missing"))
|
||||
|
||||
|
||||
def test_api_satisfies_surface_protocol() -> None:
|
||||
api: WorkflowSourceRegistrySurface = _api(FakeRegistryEntry(id="x"))
|
||||
|
||||
assert api is not None
|
||||
@@ -0,0 +1,77 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from wf_cli.app import app
|
||||
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def test_wf_admin_registry_help_exists() -> None:
|
||||
result = runner.invoke(app, ["admin", "registry", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "list" in result.output
|
||||
assert "inspect" in result.output
|
||||
|
||||
|
||||
def test_wf_admin_registry_list_help_exists() -> None:
|
||||
result = runner.invoke(app, ["admin", "registry", "list", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "--limit" in result.output
|
||||
assert "--cursor" in result.output
|
||||
|
||||
|
||||
def test_wf_admin_registry_inspect_help_exists() -> None:
|
||||
result = runner.invoke(app, ["admin", "registry", "inspect", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "SOURCE_ID" in result.output
|
||||
|
||||
|
||||
def test_wf_admin_registry_list_local_static_returns_unavailable(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"client": {"target": {"kind": "local"}},
|
||||
"server": {"store": {"kind": "filesystem", "root": str(tmp_path / "store")}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["--config", str(config_path), "admin", "registry", "list"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "not available" in result.output
|
||||
|
||||
|
||||
def test_wf_admin_registry_inspect_local_static_returns_unavailable(tmp_path: Path) -> None:
|
||||
config_path = tmp_path / "wf_mcp.config.json"
|
||||
config_path.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"version": 1,
|
||||
"client": {"target": {"kind": "local"}},
|
||||
"server": {"store": {"kind": "filesystem", "root": str(tmp_path / "store")}},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["--config", str(config_path), "admin", "registry", "inspect", "github.work"],
|
||||
)
|
||||
|
||||
assert result.exit_code != 0
|
||||
assert "not available" in result.output
|
||||
@@ -0,0 +1,69 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from wf_mcp.broker.service.source_registry_admin import SourceRegistryAdminProvider
|
||||
from wf_mcp.models import ConnectionConfig
|
||||
from wf_mcp.source_registry import (
|
||||
FileSourceRegistryStore,
|
||||
McpSourceRegistryEntry,
|
||||
SourceRegistryFile,
|
||||
StdioSourceTransport,
|
||||
)
|
||||
|
||||
|
||||
def _store_with_entries(root: Path, *entries: McpSourceRegistryEntry) -> FileSourceRegistryStore:
|
||||
store = FileSourceRegistryStore(root)
|
||||
store.save_registry(SourceRegistryFile(sources=list(entries)))
|
||||
return store
|
||||
|
||||
|
||||
def _entry(source_id: str, *, provider: str = "github", account: str = "work") -> McpSourceRegistryEntry:
|
||||
return McpSourceRegistryEntry(
|
||||
id=source_id,
|
||||
provider=provider,
|
||||
account=account,
|
||||
transport=StdioSourceTransport(command="npx"),
|
||||
)
|
||||
|
||||
|
||||
def test_provider_lists_entries_from_store(tmp_path: Path) -> None:
|
||||
store = _store_with_entries(
|
||||
tmp_path / "reg",
|
||||
_entry("alpha.work"),
|
||||
_entry("zeta.personal", provider="zeta", account="personal"),
|
||||
)
|
||||
provider = SourceRegistryAdminProvider(source_registry_store=store)
|
||||
|
||||
entries = provider.list_registry_entries()
|
||||
|
||||
assert len(entries) == 2
|
||||
ids = {getattr(e, "id", getattr(e, "get", lambda k: None)("id")) for e in entries}
|
||||
assert ids == {"alpha.work", "zeta.personal"}
|
||||
|
||||
|
||||
def test_provider_reports_config_shadowed_ids(tmp_path: Path) -> None:
|
||||
store = _store_with_entries(tmp_path / "reg", _entry("github.work"))
|
||||
connections = [
|
||||
ConnectionConfig(id="github.work", server="github", account="work"),
|
||||
ConnectionConfig(id="other.personal", server="other", account="personal"),
|
||||
]
|
||||
provider = SourceRegistryAdminProvider(
|
||||
source_registry_store=store,
|
||||
config_connections=connections,
|
||||
)
|
||||
|
||||
shadowed = provider.config_source_ids()
|
||||
|
||||
assert shadowed == {"github.work", "other.personal"}
|
||||
|
||||
|
||||
def test_provider_empty_store(tmp_path: Path) -> None:
|
||||
store = FileSourceRegistryStore(tmp_path / "reg")
|
||||
provider = SourceRegistryAdminProvider(source_registry_store=store)
|
||||
|
||||
entries = provider.list_registry_entries()
|
||||
shadowed = provider.config_source_ids()
|
||||
|
||||
assert entries == []
|
||||
assert shadowed == set()
|
||||
@@ -157,3 +157,9 @@ def test_local_static_server_inspects_and_reads_bounded_trace(tmp_path) -> None:
|
||||
assert trace["trace_start"] == 0
|
||||
assert trace["trace_limit"] == 1
|
||||
assert len(trace["trace"]) == 1
|
||||
|
||||
|
||||
def test_local_static_server_has_no_source_registry_admin(tmp_path) -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
|
||||
assert server.source_registry_admin is None
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, replace
|
||||
|
||||
import httpx
|
||||
|
||||
from wf_api import WorkflowSourceRegistryApi
|
||||
from wf_server import build_local_static_workflow_server
|
||||
from wf_transport_rpc_http import RpcWorkflowApiClient, create_rpc_app
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class FakeRegistryEntry:
|
||||
id: str
|
||||
kind: str = "mcp"
|
||||
enabled: bool = True
|
||||
provider: str = "github"
|
||||
account: str = "work"
|
||||
profile: str | None = None
|
||||
transport: dict[str, str] | None = None
|
||||
auth_ref: str | None = "github.work"
|
||||
metadata: dict[str, object] | None = None
|
||||
|
||||
|
||||
class FakeRegistryProvider:
|
||||
def list_registry_entries(self) -> list[FakeRegistryEntry]:
|
||||
return [
|
||||
FakeRegistryEntry(
|
||||
id="github.work",
|
||||
transport={"kind": "stdio", "command": "npx"},
|
||||
metadata={},
|
||||
)
|
||||
]
|
||||
|
||||
def config_source_ids(self) -> set[str]:
|
||||
return {"github.work"}
|
||||
|
||||
|
||||
async def _rpc(
|
||||
client: httpx.AsyncClient, method: str, params: dict
|
||||
) -> dict:
|
||||
response = await client.post(
|
||||
"/rpc",
|
||||
json={"jsonrpc": "2.0", "id": "test", "method": method, "params": params},
|
||||
)
|
||||
assert response.status_code == 200
|
||||
return response.json()
|
||||
|
||||
|
||||
def test_rpc_source_registry_list_unavailable_on_local_static(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
app = create_rpc_app(server)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
payload = await _rpc(
|
||||
client, "workflow.admin.source_registry.list", {"limit": 10}
|
||||
)
|
||||
|
||||
assert "error" in payload
|
||||
assert payload["error"]["data"]["code"] == "source_registry_unavailable"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_rpc_source_registry_inspect_unavailable_on_local_static(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
app = create_rpc_app(server)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
payload = await _rpc(
|
||||
client,
|
||||
"workflow.admin.source_registry.inspect",
|
||||
{"source_id": "github.work"},
|
||||
)
|
||||
|
||||
assert "error" in payload
|
||||
assert payload["error"]["data"]["code"] == "source_registry_unavailable"
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_rpc_source_registry_methods_return_registry_payloads(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
server = replace(
|
||||
build_local_static_workflow_server(tmp_path / "store"),
|
||||
source_registry_admin=WorkflowSourceRegistryApi(
|
||||
provider=FakeRegistryProvider()
|
||||
),
|
||||
)
|
||||
app = create_rpc_app(server)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as client:
|
||||
listed = await _rpc(
|
||||
client, "workflow.admin.source_registry.list", {"limit": 10}
|
||||
)
|
||||
inspected = await _rpc(
|
||||
client,
|
||||
"workflow.admin.source_registry.inspect",
|
||||
{"source_id": "github.work"},
|
||||
)
|
||||
|
||||
assert listed["result"]["entries"][0]["id"] == "github.work"
|
||||
assert listed["result"]["entries"][0]["shadowed_by_config"] is True
|
||||
assert inspected["result"]["entry"]["transport"]["kind"] == "stdio"
|
||||
assert inspected["result"]["shadowed_by_config"] is True
|
||||
|
||||
asyncio.run(scenario())
|
||||
|
||||
|
||||
def test_rpc_client_source_registry_calls_correct_methods(tmp_path) -> None:
|
||||
async def scenario() -> None:
|
||||
server = build_local_static_workflow_server(tmp_path / "store")
|
||||
app = create_rpc_app(server)
|
||||
transport = httpx.ASGITransport(app=app)
|
||||
async with httpx.AsyncClient(
|
||||
transport=transport, base_url="http://test"
|
||||
) as http_client:
|
||||
client = RpcWorkflowApiClient(
|
||||
url="http://test/rpc",
|
||||
timeout_seconds=5,
|
||||
http_client=http_client,
|
||||
)
|
||||
try:
|
||||
await client.list_registry_entries(limit=5)
|
||||
except RuntimeError as exc:
|
||||
list_error = str(exc)
|
||||
else:
|
||||
list_error = None
|
||||
|
||||
try:
|
||||
await client.inspect_registry_entry(source_id="x")
|
||||
except RuntimeError as exc:
|
||||
inspect_error = str(exc)
|
||||
else:
|
||||
inspect_error = None
|
||||
|
||||
assert list_error is not None
|
||||
assert "source registry admin reads are not available" in list_error
|
||||
assert inspect_error is not None
|
||||
assert "source registry admin reads are not available" in inspect_error
|
||||
|
||||
asyncio.run(scenario())
|
||||
Reference in New Issue
Block a user