feat: list source resources and prompts
This commit is contained in:
@@ -119,6 +119,9 @@ auth admin are implemented. The next work is polish, not new broad surfaces.
|
||||
- Completed `wf.source.read_resource`: resource refs are inert pass-by-value
|
||||
data using `logical_source`; explicit platform helper nodes dereference them
|
||||
through runtime/platform context with bounded output.
|
||||
- Completed source inventory CLI polish: `wf source resources` and
|
||||
`wf source prompts` list source-owned resource/prompt names without fetching
|
||||
content.
|
||||
- Active specs:
|
||||
- [`workflow config targets and sources`](superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md)
|
||||
- [`store-backed source registry`](superpowers/specs/2026-06-03-store-backed-source-registry-design.md)
|
||||
|
||||
@@ -137,6 +137,16 @@ exists, whether the auth scheme is compatible with the transport, catalog
|
||||
snapshot counts, and non-secret diagnostics. Secret payload values are never
|
||||
printed.
|
||||
|
||||
List resource and prompt names exposed by a source:
|
||||
|
||||
```bash
|
||||
wf --config wf.config.json source resources everything.default
|
||||
wf --config wf.config.json source prompts everything.default --format json
|
||||
```
|
||||
|
||||
These commands read source inventory only. They do not fetch resource content or
|
||||
render prompts, which can be large or stateful upstream operations.
|
||||
|
||||
## Output Policy
|
||||
|
||||
JSON is the default output format for every command.
|
||||
|
||||
@@ -11,11 +11,13 @@ from wf_artifacts import (
|
||||
)
|
||||
from wf_mcp.broker import WfMcpService
|
||||
from wf_mcp.capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool
|
||||
from wf_mcp.models import AuthRecord, ConnectionConfig
|
||||
from wf_mcp.models import ConnectionConfig
|
||||
from wf_mcp.sdk import ToolCallResult
|
||||
from wf_mcp.sdk.base import BackendAdapter
|
||||
from wf_mcp.storage import FileStore
|
||||
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
|
||||
from wf_sources_mcp.auth import AuthRecord
|
||||
from wf_sources_mcp.connections import McpSourceConnection
|
||||
|
||||
|
||||
class DemoEchoAdapter(BackendAdapter):
|
||||
@@ -23,7 +25,7 @@ class DemoEchoAdapter(BackendAdapter):
|
||||
|
||||
async def list_tools(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredTool]:
|
||||
return [
|
||||
@@ -57,28 +59,28 @@ class DemoEchoAdapter(BackendAdapter):
|
||||
|
||||
async def list_resources(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredResource]:
|
||||
return []
|
||||
|
||||
async def list_prompts(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
) -> list[DiscoveredPrompt]:
|
||||
return []
|
||||
|
||||
async def get_connection_metadata(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
) -> dict[str, Any]:
|
||||
return {"server": connection.server, "account": connection.account}
|
||||
|
||||
async def read_resource(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
uri: str,
|
||||
) -> dict[str, Any]:
|
||||
@@ -86,7 +88,7 @@ class DemoEchoAdapter(BackendAdapter):
|
||||
|
||||
async def get_prompt(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
prompt_name: str,
|
||||
arguments: dict[str, str] | None = None,
|
||||
@@ -95,7 +97,7 @@ class DemoEchoAdapter(BackendAdapter):
|
||||
|
||||
async def invoke_method(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
method: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
@@ -106,7 +108,7 @@ class DemoEchoAdapter(BackendAdapter):
|
||||
|
||||
async def send_notification(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
method: str,
|
||||
params: dict[str, Any] | None = None,
|
||||
@@ -115,7 +117,7 @@ class DemoEchoAdapter(BackendAdapter):
|
||||
|
||||
async def call_tool(
|
||||
self,
|
||||
connection: ConnectionConfig,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
tool_name: str,
|
||||
payload: dict[str, Any],
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from enum import StrEnum
|
||||
from typing import Annotated
|
||||
|
||||
import typer
|
||||
@@ -16,6 +17,13 @@ app = typer.Typer(
|
||||
)
|
||||
|
||||
|
||||
class SourceInventoryOutputFormat(StrEnum):
|
||||
"""Output formats for source resource and prompt names."""
|
||||
|
||||
NAMES = "names"
|
||||
JSON = "json"
|
||||
|
||||
|
||||
@app.command("list")
|
||||
def list_sources(
|
||||
ctx: typer.Context,
|
||||
@@ -58,6 +66,44 @@ def inspect_source(
|
||||
emit_json(payload)
|
||||
|
||||
|
||||
@app.command("resources")
|
||||
def list_source_resources(
|
||||
ctx: typer.Context,
|
||||
source_id: Annotated[str, typer.Argument(help="Workflow source id.")],
|
||||
output_format: Annotated[
|
||||
SourceInventoryOutputFormat,
|
||||
typer.Option("--format", help="Output format."),
|
||||
] = SourceInventoryOutputFormat.NAMES,
|
||||
) -> None:
|
||||
"""List resource names owned by one workflow source."""
|
||||
_list_source_inventory_names(
|
||||
ctx,
|
||||
source_id=source_id,
|
||||
capability_key="resources",
|
||||
output_key="resources",
|
||||
output_format=output_format,
|
||||
)
|
||||
|
||||
|
||||
@app.command("prompts")
|
||||
def list_source_prompts(
|
||||
ctx: typer.Context,
|
||||
source_id: Annotated[str, typer.Argument(help="Workflow source id.")],
|
||||
output_format: Annotated[
|
||||
SourceInventoryOutputFormat,
|
||||
typer.Option("--format", help="Output format."),
|
||||
] = SourceInventoryOutputFormat.NAMES,
|
||||
) -> None:
|
||||
"""List prompt names owned by one workflow source."""
|
||||
_list_source_inventory_names(
|
||||
ctx,
|
||||
source_id=source_id,
|
||||
capability_key="prompts",
|
||||
output_key="prompts",
|
||||
output_format=output_format,
|
||||
)
|
||||
|
||||
|
||||
@app.command("diagnose")
|
||||
def diagnose_source(
|
||||
ctx: typer.Context,
|
||||
@@ -70,3 +116,37 @@ def diagnose_source(
|
||||
context.source_admin.diagnose_source(source_id=source_id),
|
||||
)
|
||||
emit_json(payload)
|
||||
|
||||
|
||||
def _list_source_inventory_names(
|
||||
ctx: typer.Context,
|
||||
*,
|
||||
source_id: str,
|
||||
capability_key: str,
|
||||
output_key: str,
|
||||
output_format: SourceInventoryOutputFormat,
|
||||
) -> None:
|
||||
context = load_cli_context_from_typer(ctx)
|
||||
payload = run_cli_operation(
|
||||
context,
|
||||
context.source_admin.inspect_source(source_id=source_id),
|
||||
)
|
||||
names = _source_capability_names(payload, capability_key=capability_key)
|
||||
if output_format is SourceInventoryOutputFormat.JSON:
|
||||
emit_json({"source_id": source_id, output_key: names})
|
||||
return
|
||||
print("\n".join(names))
|
||||
|
||||
|
||||
def _source_capability_names(
|
||||
payload: dict[str, object],
|
||||
*,
|
||||
capability_key: str,
|
||||
) -> list[str]:
|
||||
capabilities = payload.get("capabilities")
|
||||
if not isinstance(capabilities, dict):
|
||||
raise ValueError("source inventory missing capabilities object")
|
||||
value = capabilities.get(capability_key)
|
||||
if not isinstance(value, list):
|
||||
raise ValueError(f"source inventory capabilities.{capability_key} must be a list")
|
||||
return [str(item) for item in value]
|
||||
|
||||
@@ -473,7 +473,7 @@ def test_builder_adds_explicit_end_node() -> None:
|
||||
assert builder.compile().outcomes == ["ok", "error"]
|
||||
|
||||
|
||||
class _StructuralKeyMap(Mapping[object, object]):
|
||||
class _StructuralKeyMap:
|
||||
def __getitem__(self, key: object) -> object:
|
||||
raise KeyError(key)
|
||||
|
||||
@@ -484,6 +484,8 @@ class _StructuralKeyMap(Mapping[object, object]):
|
||||
return 1
|
||||
|
||||
def items(self) -> list[tuple[dict[str, object], str]]:
|
||||
# Deliberately violates Mapping's item-view contract to exercise the
|
||||
# runtime guard for structural dict keys from malformed mappings.
|
||||
return [
|
||||
(
|
||||
{"root": "input", "parts": ["email.address"]},
|
||||
@@ -494,4 +496,4 @@ class _StructuralKeyMap(Mapping[object, object]):
|
||||
|
||||
def test_input_map_rejects_structural_dict_keys_with_clear_message() -> None:
|
||||
with pytest.raises(TypeError, match="structural path dicts cannot be map keys"):
|
||||
normalize_input_mapping(_StructuralKeyMap())
|
||||
normalize_input_mapping(cast(Mapping[object, object], _StructuralKeyMap()))
|
||||
|
||||
@@ -34,6 +34,34 @@ class BrokenSourceAdmin:
|
||||
raise RuntimeError(f"broken source admin for {source_id}")
|
||||
|
||||
|
||||
class InventorySourceAdmin:
|
||||
async def list_sources(
|
||||
self,
|
||||
*,
|
||||
cursor: str | None = None,
|
||||
limit: int = 50,
|
||||
) -> dict[str, Any]:
|
||||
return {"sources": [], "next_cursor": None, "total": 0}
|
||||
|
||||
async def inspect_source(self, *, source_id: str) -> dict[str, Any]:
|
||||
return {
|
||||
"id": source_id,
|
||||
"capabilities": {
|
||||
"resources": [
|
||||
f"{source_id}.architecture.md",
|
||||
f"{source_id}.startup.md",
|
||||
],
|
||||
"prompts": [
|
||||
f"{source_id}.simple-prompt",
|
||||
f"{source_id}.args-prompt",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
async def diagnose_source(self, *, source_id: str) -> dict[str, Any]:
|
||||
return {"source_id": source_id, "status": "ok"}
|
||||
|
||||
|
||||
def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None:
|
||||
config_path = tmp_path / "wf.json"
|
||||
config_path.write_text(
|
||||
@@ -408,6 +436,64 @@ def test_wf_source_commands_use_rpc_url_override(monkeypatch, tmp_path) -> None:
|
||||
assert '"id": "wf.std"' in inspected.output
|
||||
|
||||
|
||||
def test_wf_source_resources_and_prompts_render_names(monkeypatch, tmp_path) -> None:
|
||||
fake_context = CliContext(
|
||||
config_path=Path("dummy"),
|
||||
service=cast(Any, object()),
|
||||
handlers=build_local_static_workflow_server(tmp_path / "store").api,
|
||||
source_admin=InventorySourceAdmin(),
|
||||
admin=cast(Any, object()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"wf_cli.commands.sources.load_cli_context_from_typer",
|
||||
lambda _ctx: fake_context,
|
||||
)
|
||||
runner = CliRunner()
|
||||
|
||||
resources = runner.invoke(app, ["source", "resources", "everything.default"])
|
||||
prompts = runner.invoke(app, ["source", "prompts", "everything.default"])
|
||||
|
||||
assert resources.exit_code == 0, resources.output
|
||||
assert resources.output.splitlines() == [
|
||||
"everything.default.architecture.md",
|
||||
"everything.default.startup.md",
|
||||
]
|
||||
assert prompts.exit_code == 0, prompts.output
|
||||
assert prompts.output.splitlines() == [
|
||||
"everything.default.simple-prompt",
|
||||
"everything.default.args-prompt",
|
||||
]
|
||||
|
||||
|
||||
def test_wf_source_resources_json_format(monkeypatch, tmp_path) -> None:
|
||||
fake_context = CliContext(
|
||||
config_path=Path("dummy"),
|
||||
service=cast(Any, object()),
|
||||
handlers=build_local_static_workflow_server(tmp_path / "store").api,
|
||||
source_admin=InventorySourceAdmin(),
|
||||
admin=cast(Any, object()),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"wf_cli.commands.sources.load_cli_context_from_typer",
|
||||
lambda _ctx: fake_context,
|
||||
)
|
||||
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["source", "resources", "everything.default", "--format", "json"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
payload = json.loads(result.output)
|
||||
assert payload == {
|
||||
"source_id": "everything.default",
|
||||
"resources": [
|
||||
"everything.default.architecture.md",
|
||||
"everything.default.startup.md",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_wf_remote_source_inspect_formats_expected_rpc_error(
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
|
||||
@@ -98,6 +98,7 @@ def test_file_store_saves_and_loads_neutral_auth_record(tmp_path: Path) -> None:
|
||||
|
||||
loaded = store.load_auth_record("github.work")
|
||||
assert loaded is not None
|
||||
assert isinstance(loaded, NeutralAuthRecord)
|
||||
assert loaded.id == record.id
|
||||
assert loaded.scheme == record.scheme
|
||||
assert loaded.payload == record.payload
|
||||
|
||||
@@ -102,6 +102,7 @@ def test_file_store_accepts_neutral_auth_ref_without_connection_shape(
|
||||
|
||||
loaded = store.load_auth_record("api_ci-1")
|
||||
assert loaded is not None
|
||||
assert isinstance(loaded, NeutralAuthRecord)
|
||||
assert loaded.id == "api_ci-1"
|
||||
assert loaded.scheme == "bearer"
|
||||
assert loaded.payload == {"token": "secret"}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from contextlib import asynccontextmanager
|
||||
from typing import Any
|
||||
|
||||
import pytest
|
||||
@@ -156,17 +158,29 @@ class _FakeAdapter(McpSdkAdapter):
|
||||
def __init__(self, session: _FakeSession) -> None:
|
||||
self.fake_session = session
|
||||
|
||||
def _client(self, connection: McpSourceConnection, auth: object | None):
|
||||
@asynccontextmanager
|
||||
async def _client(
|
||||
self,
|
||||
connection: McpSourceConnection,
|
||||
auth: object | None,
|
||||
) -> AsyncIterator[McpSourceClient]:
|
||||
assert connection.id == "demo.personal"
|
||||
assert auth is None
|
||||
return _ClientContext(
|
||||
async with _ClientContext(
|
||||
McpSourceClient(session=self.fake_session, connection=connection)
|
||||
)
|
||||
) as client:
|
||||
yield client
|
||||
|
||||
|
||||
class _ExplodingClientAdapter(McpSdkAdapter):
|
||||
def _client(self, connection: McpSourceConnection, auth: object | None):
|
||||
@asynccontextmanager
|
||||
async def _client(
|
||||
self,
|
||||
connection: McpSourceConnection,
|
||||
auth: object | None,
|
||||
) -> AsyncIterator[McpSourceClient]:
|
||||
raise AssertionError("metadata lookup must not open an MCP session")
|
||||
yield # pragma: no cover
|
||||
|
||||
|
||||
def test_mcp_sdk_adapter_implements_backend_protocol() -> None:
|
||||
|
||||
Reference in New Issue
Block a user