578 lines
17 KiB
Markdown
578 lines
17 KiB
Markdown
# Source Admin API Surface 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 a protocol-neutral read-only source/admin surface in `wf_api` and make MCP admin source tools delegate through it.
|
|
|
|
**Architecture:** `WorkflowApiSurface` stays workflow-lifecycle-only. Source/admin operations get a sibling `WorkflowSourceAdminSurface` plus `WorkflowSourceAdminApi` implementation over `WorkflowOperationContext.specs.capability_sources`. The MCP admin handler becomes an adapter over this neutral API while connection/raw MCP methods remain MCP-broker-owned.
|
|
|
|
**Tech Stack:** Python 3.14, dataclasses, Protocols, Pydantic-backed platform models, pytest, ruff, basedpyright.
|
|
|
|
---
|
|
|
|
## Current Findings
|
|
|
|
- Source catalog internals already moved out of the old god service into `src/wf_mcp/broker/service/source_catalog.py`.
|
|
- `wf_api` does not currently have a source/admin API module.
|
|
- MCP admin source tools still flow through:
|
|
|
|
```text
|
|
wf_mcp.admin_surface.tools
|
|
-> BrokerAdminHandlers
|
|
-> WfMcpService.list_source_summaries / inspect_source
|
|
-> SourceCatalogService
|
|
```
|
|
|
|
- `WorkflowOperationContext.specs.capability_sources` already exposes the source inventory `wf_api` needs for read-only source listing and inspection.
|
|
|
|
## Scope
|
|
|
|
In scope:
|
|
|
|
- `list_sources(cursor=None, limit=50) -> dict`
|
|
- `inspect_source(source_id: str) -> dict`
|
|
- Protocol-neutral `WorkflowSourceAdminSurface`
|
|
- Local implementation `WorkflowSourceAdminApi`
|
|
- MCP `BrokerAdminHandlers.list_sources()` and `.inspect_source()` delegate through `WorkflowSourceAdminApi`
|
|
- Focused tests proving payload compatibility with existing MCP source output
|
|
|
|
Out of scope for this slice:
|
|
|
|
- Adding/removing/updating sources
|
|
- Store-backed source registry
|
|
- Connection status, catalog refresh, raw method invocation
|
|
- JSON-RPC transport methods for source admin
|
|
- CLI `wf source ...` commands
|
|
|
|
Those are follow-up slices once this neutral seam exists.
|
|
|
|
## File Structure
|
|
|
|
- Create `src/wf_api/source_admin.py`
|
|
- Owns `WorkflowSourceAdminApi`.
|
|
- Uses only `WorkflowOperationContext` and platform source models.
|
|
- Imports no `wf_mcp`.
|
|
|
|
- Modify `src/wf_api/surface.py`
|
|
- Adds sibling protocol `WorkflowSourceAdminSurface`.
|
|
- Does not make `WorkflowApiSurface` inherit it.
|
|
|
|
- Modify `src/wf_api/__init__.py`
|
|
- Exports `WorkflowSourceAdminApi` and `WorkflowSourceAdminSurface`.
|
|
|
|
- Modify `src/wf_mcp/admin_surface/handlers/broker.py`
|
|
- Construct `WorkflowSourceAdminApi(context_from_service(service))`.
|
|
- Delegate `list_sources` and `inspect_source` to it.
|
|
- Keep connection/catalog/resource/raw methods unchanged.
|
|
- Make those two source methods async, matching the async MCP tool boundary.
|
|
|
|
- Modify `src/wf_mcp/admin_surface/tools.py`
|
|
- Await async source handler methods.
|
|
|
|
- Create `tests/wf_api/test_source_admin_api.py`
|
|
- Direct neutral API tests.
|
|
|
|
- Modify `tests/wf_mcp/test_admin_surface.py`
|
|
- Add adapter smoke coverage for the neutral source admin delegation.
|
|
|
|
- Modify `docs/current_roadmap.md`
|
|
- Mark the read-only neutral source/admin seam as completed.
|
|
|
|
---
|
|
|
|
### Task 1: Add failing `wf_api` source admin tests
|
|
|
|
**Files:**
|
|
- Create: `tests/wf_api/test_source_admin_api.py`
|
|
|
|
- [ ] **Step 1: Write direct tests**
|
|
|
|
Create `tests/wf_api/test_source_admin_api.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
import pytest
|
|
|
|
from wf_api import WorkflowSourceAdminApi
|
|
from wf_api.operation_context import WorkflowOperationContext
|
|
from wf_authoring import NodeSpec
|
|
from wf_platform import (
|
|
CapabilityBuckets,
|
|
CapabilitySource,
|
|
SourcePermissions,
|
|
SourceVisibility,
|
|
)
|
|
|
|
|
|
class DummyEvents:
|
|
def record_event(self, event: object) -> None:
|
|
pass
|
|
|
|
def record_workflow_event(
|
|
self,
|
|
event_type: str,
|
|
*,
|
|
capability_id: str,
|
|
payload: dict[str, Any],
|
|
) -> None:
|
|
pass
|
|
|
|
|
|
class DummyRuntime:
|
|
async def run_workflow_from_plan(self, *args: Any, **kwargs: Any) -> object:
|
|
raise AssertionError("source admin tests must not run workflows")
|
|
|
|
async def resume_workflow_from_plan(
|
|
self,
|
|
*args: Any,
|
|
**kwargs: Any,
|
|
) -> object:
|
|
raise AssertionError("source admin tests must not resume workflows")
|
|
|
|
|
|
class StaticSpecProvider:
|
|
def __init__(self, sources: dict[str, CapabilitySource]) -> None:
|
|
self._sources = sources
|
|
|
|
@property
|
|
def capability_sources(self) -> dict[str, CapabilitySource]:
|
|
return self._sources
|
|
|
|
def get_qualified_spec(self, qualified_name: str) -> NodeSpec[Any, Any]:
|
|
raise KeyError(f"unknown capability {qualified_name!r}")
|
|
|
|
|
|
def _api(*sources: CapabilitySource) -> WorkflowSourceAdminApi:
|
|
provider = StaticSpecProvider({source.id: source for source in sources})
|
|
return WorkflowSourceAdminApi(
|
|
WorkflowOperationContext(
|
|
artifact_store=None,
|
|
draft_workspace_store=None,
|
|
run_store=None,
|
|
events=DummyEvents(),
|
|
specs=provider,
|
|
runtime=DummyRuntime(),
|
|
live_sources=None,
|
|
)
|
|
)
|
|
|
|
|
|
def _source(source_id: str, *, enabled: bool = True) -> CapabilitySource:
|
|
return CapabilitySource(
|
|
id=source_id,
|
|
kind="connection",
|
|
enabled=enabled,
|
|
capabilities=CapabilityBuckets(),
|
|
visibility=SourceVisibility(
|
|
planner=True,
|
|
mcp_client=True,
|
|
admin_dashboard=True,
|
|
),
|
|
permissions=SourcePermissions(calls_upstream=True),
|
|
description=f"{source_id} source",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_admin_lists_compact_sources_in_id_order() -> None:
|
|
api = _api(_source("zeta.personal"), _source("alpha.personal", enabled=False))
|
|
|
|
payload = await api.list_sources(limit=10)
|
|
|
|
assert payload["total"] == 2
|
|
assert payload["next_cursor"] is None
|
|
assert [source["id"] for source in payload["sources"]] == [
|
|
"alpha.personal",
|
|
"zeta.personal",
|
|
]
|
|
assert payload["sources"][0]["enabled"] is False
|
|
assert payload["sources"][1]["description"] == "zeta.personal source"
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_admin_pages_sources() -> None:
|
|
api = _api(_source("a"), _source("b"), _source("c"))
|
|
|
|
first = await api.list_sources(limit=2)
|
|
second = await api.list_sources(cursor=first["next_cursor"], limit=2)
|
|
|
|
assert [source["id"] for source in first["sources"]] == ["a", "b"]
|
|
assert first["next_cursor"] == "2"
|
|
assert [source["id"] for source in second["sources"]] == ["c"]
|
|
assert second["next_cursor"] is None
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_admin_inspects_full_source_inventory() -> None:
|
|
api = _api(_source("demo.personal"))
|
|
|
|
payload = await api.inspect_source(source_id="demo.personal")
|
|
|
|
assert payload["id"] == "demo.personal"
|
|
assert payload["kind"] == "connection"
|
|
assert payload["description"] == "demo.personal source"
|
|
assert payload["visibility"]["planner"] is True
|
|
assert payload["permissions"]["calls_upstream"] is True
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_source_admin_inspect_unknown_source_raises_clear_key_error() -> None:
|
|
api = _api(_source("demo.personal"))
|
|
|
|
with pytest.raises(KeyError, match="unknown source 'missing.source'"):
|
|
await api.inspect_source(source_id="missing.source")
|
|
```
|
|
|
|
- [ ] **Step 2: Run tests and verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_source_admin_api.py -q
|
|
```
|
|
|
|
Expected: FAIL because `WorkflowSourceAdminApi` is not exported yet.
|
|
|
|
---
|
|
|
|
### Task 2: Implement `WorkflowSourceAdminApi`
|
|
|
|
**Files:**
|
|
- Create: `src/wf_api/source_admin.py`
|
|
- Modify: `src/wf_api/__init__.py`
|
|
|
|
- [ ] **Step 1: Add source admin API**
|
|
|
|
Create `src/wf_api/source_admin.py`:
|
|
|
|
```python
|
|
from __future__ import annotations
|
|
|
|
from typing import Any
|
|
|
|
from wf_platform import page_items
|
|
|
|
from .operation_context import WorkflowOperationContext
|
|
|
|
|
|
class WorkflowSourceAdminApi:
|
|
"""Read-only protocol-neutral source inventory operations.
|
|
|
|
This is a sibling to WorkflowApi, not part of WorkflowApiSurface, because
|
|
source administration is server/platform management rather than workflow
|
|
lifecycle execution.
|
|
"""
|
|
|
|
def __init__(self, context: WorkflowOperationContext) -> None:
|
|
self.context = context
|
|
|
|
async def list_sources(
|
|
self,
|
|
*,
|
|
cursor: str | None = None,
|
|
limit: int = 50,
|
|
) -> dict[str, Any]:
|
|
summaries = [
|
|
source.as_status().model_dump(mode="json")
|
|
for source in sorted(
|
|
self.context.specs.capability_sources.values(),
|
|
key=lambda source: source.id,
|
|
)
|
|
]
|
|
page = page_items(summaries, cursor=cursor, limit=limit)
|
|
return {
|
|
"sources": list(page.items),
|
|
"next_cursor": page.next_cursor,
|
|
"total": page.total,
|
|
}
|
|
|
|
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
|
|
try:
|
|
source = self.context.specs.capability_sources[source_id]
|
|
except KeyError as exc:
|
|
raise KeyError(f"unknown source {source_id!r}") from exc
|
|
return source.as_inventory().model_dump(mode="json")
|
|
```
|
|
|
|
- [ ] **Step 2: Export from `wf_api`**
|
|
|
|
Modify `src/wf_api/__init__.py`:
|
|
|
|
```python
|
|
from .source_admin import WorkflowSourceAdminApi
|
|
```
|
|
|
|
Add `"WorkflowSourceAdminApi"` to `__all__`.
|
|
|
|
- [ ] **Step 3: Run direct tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_source_admin_api.py -q
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
### Task 3: Add sibling surface protocol
|
|
|
|
**Files:**
|
|
- Modify: `src/wf_api/surface.py`
|
|
- Modify: `src/wf_api/__init__.py`
|
|
- Test: `tests/wf_api/test_source_admin_api.py`
|
|
|
|
- [ ] **Step 1: Add protocol conformance test**
|
|
|
|
Append to `tests/wf_api/test_source_admin_api.py`:
|
|
|
|
```python
|
|
from wf_api import WorkflowSourceAdminSurface
|
|
|
|
|
|
def test_source_admin_api_satisfies_surface_protocol() -> None:
|
|
api: WorkflowSourceAdminSurface = _api(_source("demo.personal"))
|
|
|
|
assert api is not None
|
|
```
|
|
|
|
- [ ] **Step 2: Add protocol**
|
|
|
|
In `src/wf_api/surface.py`, add this class near the other surface protocols:
|
|
|
|
```python
|
|
class WorkflowSourceAdminSurface(Protocol):
|
|
"""Read-only source/admin methods exposed by platform frontends."""
|
|
|
|
async def list_sources(
|
|
self,
|
|
*,
|
|
cursor: str | None = None,
|
|
limit: int = 50,
|
|
) -> dict[str, Any]: ...
|
|
|
|
async def inspect_source(
|
|
self,
|
|
*,
|
|
source_id: str,
|
|
) -> dict[str, Any]: ...
|
|
```
|
|
|
|
Add `"WorkflowSourceAdminSurface"` to `__all__`.
|
|
|
|
Do not add it as a base class of `WorkflowApiSurface`.
|
|
|
|
- [ ] **Step 3: Export protocol**
|
|
|
|
Modify `src/wf_api/__init__.py`:
|
|
|
|
```python
|
|
from .surface import WorkflowSourceAdminSurface
|
|
```
|
|
|
|
Add `"WorkflowSourceAdminSurface"` to `__all__`.
|
|
|
|
- [ ] **Step 4: Run tests and type check**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_source_admin_api.py tests/wf_api/test_import_direction.py -q
|
|
uv run basedpyright --level error src/wf_api tests/wf_api/test_source_admin_api.py
|
|
```
|
|
|
|
Expected: tests PASS, basedpyright reports 0 errors.
|
|
|
|
---
|
|
|
|
### Task 4: Delegate MCP admin source tools through `wf_api`
|
|
|
|
**Files:**
|
|
- Modify: `src/wf_mcp/admin_surface/handlers/broker.py`
|
|
- Modify: `src/wf_mcp/admin_surface/tools.py`
|
|
- Test: `tests/wf_mcp/test_admin_surface.py`
|
|
|
|
- [ ] **Step 1: Add adapter smoke assertion**
|
|
|
|
In `tests/wf_mcp/test_admin_surface.py`, inside
|
|
`test_broker_admin_handlers_list_connections_and_events`, add:
|
|
|
|
```python
|
|
sources = _run(handlers.list_sources(limit=100))
|
|
|
|
source_ids = {source["id"] for source in sources["sources"]}
|
|
assert "wf.std" in source_ids
|
|
assert "wf.docs" in source_ids
|
|
assert sources["total"] >= 2
|
|
```
|
|
|
|
This test uses the existing `_run()` helper.
|
|
|
|
- [ ] **Step 2: Run the updated test and verify failure**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_mcp/test_admin_surface.py::test_broker_admin_handlers_list_connections_and_events -q
|
|
```
|
|
|
|
Expected before delegation: FAIL because `BrokerAdminHandlers.list_sources()`
|
|
is still sync and returns a dict, not an awaitable. This failure drives the async
|
|
source-handler cleanup.
|
|
|
|
- [ ] **Step 3: Change handler implementation**
|
|
|
|
Modify `src/wf_mcp/admin_surface/handlers/broker.py`:
|
|
|
|
```python
|
|
from wf_api import WorkflowSourceAdminApi, WorkflowSourceAdminSurface
|
|
from wf_mcp.broker.service.workflow_operation_context import context_from_service
|
|
```
|
|
|
|
Update `__init__`:
|
|
|
|
```python
|
|
def __init__(self, service: WfMcpService) -> None:
|
|
self.service = service
|
|
self.sources: WorkflowSourceAdminSurface = WorkflowSourceAdminApi(
|
|
context_from_service(service)
|
|
)
|
|
```
|
|
|
|
Update source methods:
|
|
|
|
```python
|
|
async def list_sources(
|
|
self,
|
|
*,
|
|
cursor: str | None = None,
|
|
limit: int = 50,
|
|
) -> dict[str, Any]:
|
|
return await self.sources.list_sources(cursor=cursor, limit=limit)
|
|
|
|
async def inspect_source(self, source_id: str) -> dict[str, Any]:
|
|
return await self.sources.inspect_source(source_id=source_id)
|
|
```
|
|
|
|
Do not add an `asyncio.run()` bridge. The handler is called from async MCP tools,
|
|
so source methods should be async at this boundary.
|
|
|
|
- [ ] **Step 4: Await source handler calls in MCP tools**
|
|
|
|
In `src/wf_mcp/admin_surface/tools.py`, update:
|
|
|
|
```python
|
|
return await handlers.list_sources(cursor=cursor, limit=limit)
|
|
```
|
|
|
|
and:
|
|
|
|
```python
|
|
return await handlers.inspect_source(source_id)
|
|
```
|
|
|
|
- [ ] **Step 5: Run MCP admin tests**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_mcp/test_admin_surface.py tests/wf_mcp/server/test_config.py tests/wf_mcp/test_broker_server.py -q
|
|
```
|
|
|
|
Expected: PASS.
|
|
|
|
---
|
|
|
|
### Task 5: Documentation and verification
|
|
|
|
**Files:**
|
|
- Modify: `docs/current_roadmap.md`
|
|
- Modify: `docs/superpowers/specs/2026-06-03-cli-api-alignment-notes.md`
|
|
|
|
- [ ] **Step 1: Update roadmap**
|
|
|
|
In `docs/current_roadmap.md`, under **Durable API service shape** or
|
|
**CLI/API alignment**, add:
|
|
|
|
```markdown
|
|
- Completed: read-only source inventory now has a protocol-neutral
|
|
`WorkflowSourceAdminApi` / `WorkflowSourceAdminSurface`; MCP admin source
|
|
tools delegate through it while connection/raw MCP operations remain
|
|
broker-owned.
|
|
```
|
|
|
|
- [ ] **Step 2: Update CLI/API notes**
|
|
|
|
In `docs/superpowers/specs/2026-06-03-cli-api-alignment-notes.md`, under
|
|
**Next Slices**, replace the source/admin item with:
|
|
|
|
```markdown
|
|
1. **Source/admin transport and CLI commands**
|
|
- Build JSON-RPC methods and `wf source ...` commands over
|
|
`WorkflowSourceAdminSurface`.
|
|
- Keep mutation out until the store-backed source registry is designed.
|
|
```
|
|
|
|
- [ ] **Step 3: Run verification**
|
|
|
|
Run:
|
|
|
|
```bash
|
|
uv run pytest tests/wf_api/test_source_admin_api.py tests/wf_mcp/test_admin_surface.py tests/wf_mcp/server/test_config.py tests/wf_mcp/test_broker_server.py -q
|
|
uv run ruff check src/wf_api src/wf_mcp/admin_surface tests/wf_api/test_source_admin_api.py tests/wf_mcp/test_admin_surface.py tests/wf_mcp/server/test_config.py tests/wf_mcp/test_broker_server.py
|
|
uv run ruff format --check src/wf_api src/wf_mcp/admin_surface tests/wf_api/test_source_admin_api.py tests/wf_mcp/test_admin_surface.py tests/wf_mcp/server/test_config.py tests/wf_mcp/test_broker_server.py
|
|
uv run basedpyright --level error src/wf_api src/wf_mcp/admin_surface tests/wf_api/test_source_admin_api.py tests/wf_mcp/test_admin_surface.py
|
|
```
|
|
|
|
Expected:
|
|
|
|
- pytest PASS
|
|
- ruff check PASS
|
|
- ruff format PASS
|
|
- basedpyright 0 errors
|
|
|
|
- [ ] **Step 4: Commit**
|
|
|
|
```bash
|
|
git add src/wf_api/source_admin.py src/wf_api/surface.py src/wf_api/__init__.py src/wf_mcp/admin_surface/handlers/broker.py src/wf_mcp/admin_surface/tools.py tests/wf_api/test_source_admin_api.py tests/wf_mcp/test_admin_surface.py docs/current_roadmap.md docs/superpowers/specs/2026-06-03-cli-api-alignment-notes.md
|
|
git commit -m "feat: add source admin api surface"
|
|
```
|
|
|
|
---
|
|
|
|
## Follow-Up Slices
|
|
|
|
1. **Source/admin JSON-RPC transport**
|
|
- Add fixed methods such as `workflow.sources.list` and
|
|
`workflow.sources.inspect`.
|
|
- Add a dedicated RPC source-admin client or mixin, but keep the lifecycle
|
|
`RpcWorkflowApiClient` contract clear.
|
|
|
|
2. **CLI `wf source` commands**
|
|
- Add `wf source list` and `wf source inspect`.
|
|
- Use the same target-aware context pattern as workflow lifecycle commands,
|
|
but the CLI context may need a second handler field for source admin.
|
|
|
|
3. **Store-backed source registry**
|
|
- Config remains bootstrap.
|
|
- Server-owned dynamic source changes persist through a source registry store.
|
|
- Source identity stays structural: source id, provider/account/profile, and
|
|
concrete transport details are not inferred from dotted display names.
|
|
|
|
4. **Mutable source admin**
|
|
- Add source create/update/delete only after store persistence and validation
|
|
rules exist.
|
|
- Enforce duplicate id behavior and liveness/validation diagnostics before
|
|
a source can become runnable.
|
|
|
|
## Self-Review
|
|
|
|
- Spec coverage: read-only source list/inspect, neutral surface, MCP adapter, and docs are covered.
|
|
- Placeholder scan: no TBD/TODO placeholders.
|
|
- Type consistency: `WorkflowSourceAdminApi`, `WorkflowSourceAdminSurface`, `source_id`, `cursor`, and `limit` names are consistent across tasks.
|
|
- Risk: `BrokerAdminHandlers.list_sources` / `inspect_source` become async. The plan updates the direct handler test and the MCP tool wrappers that call them.
|