feat: list source resources and prompts

This commit is contained in:
lda
2026-06-14 00:16:56 +07:00 Verified
parent ba8c59e81a
commit 2609679b87
9 changed files with 215 additions and 16 deletions
+3
View File
@@ -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 - Completed `wf.source.read_resource`: resource refs are inert pass-by-value
data using `logical_source`; explicit platform helper nodes dereference them data using `logical_source`; explicit platform helper nodes dereference them
through runtime/platform context with bounded output. 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: - Active specs:
- [`workflow config targets and sources`](superpowers/specs/2026-06-03-workflow-config-targets-and-sources.md) - [`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) - [`store-backed source registry`](superpowers/specs/2026-06-03-store-backed-source-registry-design.md)
+10
View File
@@ -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 snapshot counts, and non-secret diagnostics. Secret payload values are never
printed. 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 ## Output Policy
JSON is the default output format for every command. JSON is the default output format for every command.
+12 -10
View File
@@ -11,11 +11,13 @@ from wf_artifacts import (
) )
from wf_mcp.broker import WfMcpService from wf_mcp.broker import WfMcpService
from wf_mcp.capabilities import DiscoveredPrompt, DiscoveredResource, DiscoveredTool 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 import ToolCallResult
from wf_mcp.sdk.base import BackendAdapter from wf_mcp.sdk.base import BackendAdapter
from wf_mcp.storage import FileStore from wf_mcp.storage import FileStore
from wf_mcp.workflow_surface import WorkflowSurfaceHandlers from wf_mcp.workflow_surface import WorkflowSurfaceHandlers
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.connections import McpSourceConnection
class DemoEchoAdapter(BackendAdapter): class DemoEchoAdapter(BackendAdapter):
@@ -23,7 +25,7 @@ class DemoEchoAdapter(BackendAdapter):
async def list_tools( async def list_tools(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredTool]: ) -> list[DiscoveredTool]:
return [ return [
@@ -57,28 +59,28 @@ class DemoEchoAdapter(BackendAdapter):
async def list_resources( async def list_resources(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredResource]: ) -> list[DiscoveredResource]:
return [] return []
async def list_prompts( async def list_prompts(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> list[DiscoveredPrompt]: ) -> list[DiscoveredPrompt]:
return [] return []
async def get_connection_metadata( async def get_connection_metadata(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> dict[str, Any]: ) -> dict[str, Any]:
return {"server": connection.server, "account": connection.account} return {"server": connection.server, "account": connection.account}
async def read_resource( async def read_resource(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
uri: str, uri: str,
) -> dict[str, Any]: ) -> dict[str, Any]:
@@ -86,7 +88,7 @@ class DemoEchoAdapter(BackendAdapter):
async def get_prompt( async def get_prompt(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
prompt_name: str, prompt_name: str,
arguments: dict[str, str] | None = None, arguments: dict[str, str] | None = None,
@@ -95,7 +97,7 @@ class DemoEchoAdapter(BackendAdapter):
async def invoke_method( async def invoke_method(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
@@ -106,7 +108,7 @@ class DemoEchoAdapter(BackendAdapter):
async def send_notification( async def send_notification(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
method: str, method: str,
params: dict[str, Any] | None = None, params: dict[str, Any] | None = None,
@@ -115,7 +117,7 @@ class DemoEchoAdapter(BackendAdapter):
async def call_tool( async def call_tool(
self, self,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
tool_name: str, tool_name: str,
payload: dict[str, Any], payload: dict[str, Any],
+80
View File
@@ -1,5 +1,6 @@
from __future__ import annotations from __future__ import annotations
from enum import StrEnum
from typing import Annotated from typing import Annotated
import typer 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") @app.command("list")
def list_sources( def list_sources(
ctx: typer.Context, ctx: typer.Context,
@@ -58,6 +66,44 @@ def inspect_source(
emit_json(payload) 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") @app.command("diagnose")
def diagnose_source( def diagnose_source(
ctx: typer.Context, ctx: typer.Context,
@@ -70,3 +116,37 @@ def diagnose_source(
context.source_admin.diagnose_source(source_id=source_id), context.source_admin.diagnose_source(source_id=source_id),
) )
emit_json(payload) 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]
+4 -2
View File
@@ -473,7 +473,7 @@ def test_builder_adds_explicit_end_node() -> None:
assert builder.compile().outcomes == ["ok", "error"] assert builder.compile().outcomes == ["ok", "error"]
class _StructuralKeyMap(Mapping[object, object]): class _StructuralKeyMap:
def __getitem__(self, key: object) -> object: def __getitem__(self, key: object) -> object:
raise KeyError(key) raise KeyError(key)
@@ -484,6 +484,8 @@ class _StructuralKeyMap(Mapping[object, object]):
return 1 return 1
def items(self) -> list[tuple[dict[str, object], str]]: 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 [ return [
( (
{"root": "input", "parts": ["email.address"]}, {"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: def test_input_map_rejects_structural_dict_keys_with_clear_message() -> None:
with pytest.raises(TypeError, match="structural path dicts cannot be map keys"): with pytest.raises(TypeError, match="structural path dicts cannot be map keys"):
normalize_input_mapping(_StructuralKeyMap()) normalize_input_mapping(cast(Mapping[object, object], _StructuralKeyMap()))
+86
View File
@@ -34,6 +34,34 @@ class BrokenSourceAdmin:
raise RuntimeError(f"broken source admin for {source_id}") 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: def test_load_cli_context_uses_rpc_client_for_rpc_http_target(tmp_path) -> None:
config_path = tmp_path / "wf.json" config_path = tmp_path / "wf.json"
config_path.write_text( 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 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( def test_wf_remote_source_inspect_formats_expected_rpc_error(
monkeypatch, monkeypatch,
tmp_path, tmp_path,
+1
View File
@@ -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") loaded = store.load_auth_record("github.work")
assert loaded is not None assert loaded is not None
assert isinstance(loaded, NeutralAuthRecord)
assert loaded.id == record.id assert loaded.id == record.id
assert loaded.scheme == record.scheme assert loaded.scheme == record.scheme
assert loaded.payload == record.payload assert loaded.payload == record.payload
+1
View File
@@ -102,6 +102,7 @@ def test_file_store_accepts_neutral_auth_ref_without_connection_shape(
loaded = store.load_auth_record("api_ci-1") loaded = store.load_auth_record("api_ci-1")
assert loaded is not None assert loaded is not None
assert isinstance(loaded, NeutralAuthRecord)
assert loaded.id == "api_ci-1" assert loaded.id == "api_ci-1"
assert loaded.scheme == "bearer" assert loaded.scheme == "bearer"
assert loaded.payload == {"token": "secret"} assert loaded.payload == {"token": "secret"}
+18 -4
View File
@@ -1,5 +1,7 @@
from __future__ import annotations from __future__ import annotations
from collections.abc import AsyncIterator
from contextlib import asynccontextmanager
from typing import Any from typing import Any
import pytest import pytest
@@ -156,17 +158,29 @@ class _FakeAdapter(McpSdkAdapter):
def __init__(self, session: _FakeSession) -> None: def __init__(self, session: _FakeSession) -> None:
self.fake_session = session 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 connection.id == "demo.personal"
assert auth is None assert auth is None
return _ClientContext( async with _ClientContext(
McpSourceClient(session=self.fake_session, connection=connection) McpSourceClient(session=self.fake_session, connection=connection)
) ) as client:
yield client
class _ExplodingClientAdapter(McpSdkAdapter): 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") raise AssertionError("metadata lookup must not open an MCP session")
yield # pragma: no cover
def test_mcp_sdk_adapter_implements_backend_protocol() -> None: def test_mcp_sdk_adapter_implements_backend_protocol() -> None: