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
|
- 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)
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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()))
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"}
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
Reference in New Issue
Block a user