feat: expose source registry admin reads

This commit is contained in:
lda
2026-06-04 01:20:51 +07:00 Verified
parent 21e379657a
commit dfa600b24a
21 changed files with 805 additions and 14 deletions
+6 -4
View File
@@ -130,10 +130,12 @@ implementation state.
[2026-06-03 source registry next slices](./superpowers/plans/2026-06-03-source-registry-next-slices.md): [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 desired-registry admin reads and safe mutation commands remain later
slices. slices.
- Next executable source registry slice: - Completed: desired-registry admin read plumbing is available through
[2026-06-04 source registry admin reads](./superpowers/plans/2026-06-04-source-registry-admin-reads.md). `WorkflowSourceRegistryApi`, JSON-RPC methods
It keeps desired registry state separate from observed source inventory (`workflow.admin.source_registry.list` / `.inspect`), and CLI commands
and adds read-only admin/RPC/CLI access before any mutation 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, - 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 source-admin, and config-admin surfaces so the old `wf_mcp` server entry
point can shrink or retire. point can shrink or retire.
@@ -59,9 +59,13 @@ The next executable slice is startup merge:
- Emit events/diagnostics for shadowed registry entries. - Emit events/diagnostics for shadowed registry entries.
4. **Slice 4: Read Desired Registry Through Admin** 4. **Slice 4: Read Desired Registry Through Admin**
- **Status: planned.** - **Status: complete.**
- Expose desired registry entries separately from observed source inventory. - 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** 5. **Slice 5: Mutation Commands**
- Add add/update/enable/disable/remove operations. - 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. Expose desired registry entries distinctly from observed source inventory.
Implementation plan: Status: complete for API/transport/CLI plumbing. `WorkflowSourceRegistryApi`
`docs/superpowers/plans/2026-06-04-source-registry-admin-reads.md`. 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 ### Why
@@ -269,12 +269,14 @@ registry entries.
### Slice 4: Read Registry Through Admin ### Slice 4: Read Registry Through Admin
Implementation plan: Status: complete for API/transport/CLI plumbing. `WorkflowSourceRegistryApi`
[2026-06-04 source registry admin reads](../plans/2026-06-04-source-registry-admin-reads.md). provides neutral read-only access to desired registry entries. JSON-RPC methods
`workflow.admin.source_registry.list` and `.inspect` are registered. CLI commands
- Add admin read method for desired registry entries if needed. `wf admin registry list` and `wf admin registry inspect` are available for
- Keep current source inventory list as runtime/observed source inventory. targets that expose the surface. Local/static servers report
- Document difference between desired registry and observed source catalog. `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 ### Slice 5: Mutating RPC/CLI
+8
View File
@@ -24,6 +24,10 @@ from .refs import WorkflowSurfaceCapabilityId, parse_workflow_surface_capability
from .runs import WorkflowRunApi from .runs import WorkflowRunApi
from .service import WorkflowApi from .service import WorkflowApi
from .source_admin import WorkflowSourceAdminApi from .source_admin import WorkflowSourceAdminApi
from .source_registry_admin import (
WorkflowSourceRegistryApi,
WorkflowSourceRegistryProvider,
)
from .surface import ( from .surface import (
WorkflowAdminSurface, WorkflowAdminSurface,
WorkflowApiSurface, WorkflowApiSurface,
@@ -33,6 +37,7 @@ from .surface import (
WorkflowDraftSurface, WorkflowDraftSurface,
WorkflowRunSurface, WorkflowRunSurface,
WorkflowSourceAdminSurface, WorkflowSourceAdminSurface,
WorkflowSourceRegistrySurface,
) )
from .wrapper_hints import ( from .wrapper_hints import (
MissingDecision, MissingDecision,
@@ -101,6 +106,9 @@ __all__ = [
"WorkflowRunSurface", "WorkflowRunSurface",
"WorkflowSourceAdminApi", "WorkflowSourceAdminApi",
"WorkflowSourceAdminSurface", "WorkflowSourceAdminSurface",
"WorkflowSourceRegistryApi",
"WorkflowSourceRegistryProvider",
"WorkflowSourceRegistrySurface",
"WorkflowSpecProvider", "WorkflowSpecProvider",
"WorkflowSurfaceCapabilityId", "WorkflowSurfaceCapabilityId",
"WrapperAuthoringHints", "WrapperAuthoringHints",
+100
View File
@@ -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,
}
+18
View File
@@ -226,6 +226,23 @@ class WorkflowAdminSurface(Protocol):
async def list_events(self) -> dict[str, Any]: ... 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__ = [ __all__ = [
"WorkflowAdminSurface", "WorkflowAdminSurface",
"WorkflowApiSurface", "WorkflowApiSurface",
@@ -235,4 +252,5 @@ __all__ = [
"WorkflowDraftSurface", "WorkflowDraftSurface",
"WorkflowRunSurface", "WorkflowRunSurface",
"WorkflowSourceAdminSurface", "WorkflowSourceAdminSurface",
"WorkflowSourceRegistrySurface",
] ]
+4
View File
@@ -8,12 +8,16 @@ import typer
from wf_cli.context import load_cli_context_from_typer from wf_cli.context import load_cli_context_from_typer
from wf_cli.formats import ListOutputFormat, emit_list_payload from wf_cli.formats import ListOutputFormat, emit_list_payload
from . import source_registry
app = typer.Typer( app = typer.Typer(
name="admin", name="admin",
help="Read workflow server admin and config state.", help="Read workflow server admin and config state.",
no_args_is_help=True, no_args_is_help=True,
) )
app.add_typer(source_registry.app, name="registry")
@app.command("connections") @app.command("connections")
def list_connections( def list_connections(
+64
View File
@@ -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)
+4
View File
@@ -15,6 +15,7 @@ from wf_api import (
WorkflowApiSurface, WorkflowApiSurface,
WorkflowSourceAdminApi, WorkflowSourceAdminApi,
WorkflowSourceAdminSurface, WorkflowSourceAdminSurface,
WorkflowSourceRegistrySurface,
) )
from wf_config import ( from wf_config import (
FilesystemStoreConfig, FilesystemStoreConfig,
@@ -38,6 +39,7 @@ class CliContext:
handlers: WorkflowApiSurface handlers: WorkflowApiSurface
source_admin: WorkflowSourceAdminSurface source_admin: WorkflowSourceAdminSurface
admin: WorkflowAdminSurface admin: WorkflowAdminSurface
source_registry_admin: WorkflowSourceRegistrySurface | None = None
@dataclass(frozen=True) @dataclass(frozen=True)
@@ -120,6 +122,7 @@ def load_cli_context(
handlers=client, handlers=client,
source_admin=client, source_admin=client,
admin=client, admin=client,
source_registry_admin=client,
) )
if _is_legacy_mcp_config(resolved_config_path): if _is_legacy_mcp_config(resolved_config_path):
@@ -165,6 +168,7 @@ def load_cli_context(
handlers=client, handlers=client,
source_admin=client, source_admin=client,
admin=client, admin=client,
source_registry_admin=client,
) )
raise ValueError(f"unsupported workflow target {target!r}") 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}
+2
View File
@@ -9,6 +9,7 @@ from wf_api import (
WorkflowAdminApi, WorkflowAdminApi,
WorkflowApi, WorkflowApi,
WorkflowSourceAdminApi, WorkflowSourceAdminApi,
WorkflowSourceRegistryApi,
durable_workflow_api, durable_workflow_api,
) )
from wf_api.local_sources import builtin_sources, get_qualified_spec from wf_api.local_sources import builtin_sources, get_qualified_spec
@@ -260,6 +261,7 @@ class WorkflowServer:
source_admin: WorkflowSourceAdminApi source_admin: WorkflowSourceAdminApi
admin: WorkflowAdminApi admin: WorkflowAdminApi
events: InMemoryWorkflowEventRecorder events: InMemoryWorkflowEventRecorder
source_registry_admin: WorkflowSourceRegistryApi | None = None
@staticmethod @staticmethod
def trace_range(*, start: int, limit: int) -> TraceRange: def trace_range(*, start: int, limit: int) -> TraceRange:
+2
View File
@@ -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_deployments import register_methods as register_deployment_methods
from .methods_drafts import register_methods as register_draft_methods from .methods_drafts import register_methods as register_draft_methods
from .methods_runs import register_methods as register_run_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 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_deployment_methods(entrypoint, server)
register_run_methods(entrypoint, server) register_run_methods(entrypoint, server)
register_source_methods(entrypoint, server) register_source_methods(entrypoint, server)
register_source_registry_methods(entrypoint, server)
register_admin_methods(entrypoint, server) register_admin_methods(entrypoint, server)
app.bind_entrypoint(entrypoint) app.bind_entrypoint(entrypoint)
+2
View File
@@ -11,6 +11,7 @@ from .client_capabilities import RpcCapabilityClientMixin
from .client_deployments import RpcDeploymentClientMixin from .client_deployments import RpcDeploymentClientMixin
from .client_drafts import RpcDraftClientMixin from .client_drafts import RpcDraftClientMixin
from .client_runs import RpcRunClientMixin from .client_runs import RpcRunClientMixin
from .client_source_registry import RpcSourceRegistryClientMixin
from .client_sources import RpcSourceAdminClientMixin from .client_sources import RpcSourceAdminClientMixin
@@ -23,6 +24,7 @@ class RpcWorkflowApiClient(
RpcDeploymentClientMixin, RpcDeploymentClientMixin,
RpcRunClientMixin, RpcRunClientMixin,
RpcSourceAdminClientMixin, RpcSourceAdminClientMixin,
RpcSourceRegistryClientMixin,
RpcAdminClientMixin, RpcAdminClientMixin,
): ):
"""WorkflowApiSurface implementation backed by JSON-RPC HTTP calls. """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)
+9
View File
@@ -179,3 +179,12 @@ class ResumeRunParams(RpcParamsModel):
resume_payload: dict[str, Any] = Field(default_factory=dict) resume_payload: dict[str, Any] = Field(default_factory=dict)
resume_outcome: str = Field(default="submitted", min_length=1) resume_outcome: str = Field(default="submitted", min_length=1)
trace_range: TraceRangeParams | None = None 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
+77
View File
@@ -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_start"] == 0
assert trace["trace_limit"] == 1 assert trace["trace_limit"] == 1
assert len(trace["trace"]) == 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())