feat: add stateful runtime listings

This commit is contained in:
lda
2026-06-07 21:22:53 +07:00 Verified
parent 9fea19c550
commit 8ac44fdf2d
13 changed files with 258 additions and 25 deletions
@@ -0,0 +1,638 @@
# Stateful Runtime List Resources Prompts 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:** Complete the stateful MCP runtime's safe client surface by adding persistent `list_resources()` and `list_prompts()`, and split runtime protocols into tool/resource/prompt slices.
**Architecture:** `McpSourceClient` already wraps initialized MCP client operations for tools, resources, and prompts. `McpRuntimePool` should be able to route the same safe resource/prompt listing operations through its owner-task queue so session-scoped listings can observe runtime state. Protocols should describe the slices explicitly: tool execution, resource operations, prompt operations, and their combined stateful runtime.
**Tech Stack:** Python 3.14, MCP Python SDK, pytest/pytest-asyncio, ruff, basedpyright.
---
## File Structure
- Modify `src/wf_sources_mcp/sdk/protocols.py`
- Add `ToolRuntime`, `ResourceRuntime`, `PromptRuntime`.
- Make `StatefulMcpRuntime(ToolRuntime, ResourceRuntime, PromptRuntime, Protocol)`.
- Keep `ToolExecutor` as a compatibility alias/subprotocol for workflow node execution.
- Modify `src/wf_sources_mcp/sdk/__init__.py`
- Export new protocol slices.
- Modify compatibility shims:
- `src/wf_mcp/sdk/base.py`
- `src/wf_mcp/sdk/__init__.py`
- Modify runtime:
- `src/wf_sources_mcp/runtime/session.py`
- `src/wf_sources_mcp/runtime/factory.py`
- `src/wf_sources_mcp/runtime/pool.py`
- Modify tests:
- `tests/wf_sources_mcp/test_sdk_protocols.py`
- `tests/wf_sources_mcp/test_runtime.py`
- `tests/wf_mcp/test_compat_imports.py`
- Update docs:
- `docs/current_roadmap.md`
- `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`
## Hard Boundaries
- Do not add raw `invoke_method` or `send_notification`.
- Do not add runtime `list_tools` in this slice.
- Do not use runtime `isinstance` checks or `@runtime_checkable` protocols.
- Do not route catalog refresh/discovery through stateful runtime in this slice.
- Do not assume resource/prompt listings are stateless; these new methods exist because they can be session-scoped.
---
### Task 1: Split Protocol Slices
**Files:**
- Modify: `src/wf_sources_mcp/sdk/protocols.py`
- Modify: `src/wf_sources_mcp/sdk/__init__.py`
- Modify: `tests/wf_sources_mcp/test_sdk_protocols.py`
- [ ] **Step 1: Add protocol shape tests**
Append to `tests/wf_sources_mcp/test_sdk_protocols.py`:
```python
def test_stateful_runtime_protocol_slices_export() -> None:
from wf_sources_mcp.sdk import (
PromptRuntime,
ResourceRuntime,
StatefulMcpRuntime,
ToolExecutor,
ToolRuntime,
)
assert ToolRuntime.__name__ == "ToolRuntime"
assert ResourceRuntime.__name__ == "ResourceRuntime"
assert PromptRuntime.__name__ == "PromptRuntime"
assert ToolExecutor.__name__ == "ToolExecutor"
assert StatefulMcpRuntime.__name__ == "StatefulMcpRuntime"
```
- [ ] **Step 2: Add protocol slices**
In `src/wf_sources_mcp/sdk/protocols.py`, replace the current `ToolExecutor` / `StatefulMcpRuntime` section with:
```python
class ToolRuntime(Protocol):
"""Runtime boundary for executing MCP tools from workflow nodes."""
async def call_tool(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult: ...
class ToolExecutor(ToolRuntime, Protocol):
"""Compatibility name for workflow-node tool execution."""
class ResourceRuntime(Protocol):
"""Stateful resource operations for configured MCP sources."""
async def list_resources(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredResource]: ...
async def read_resource(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
uri: str,
) -> dict[str, Any]: ...
class PromptRuntime(Protocol):
"""Stateful prompt operations for configured MCP sources."""
async def list_prompts(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]: ...
async def get_prompt(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
prompt_name: str,
arguments: dict[str, str] | None = None,
) -> dict[str, Any]: ...
class StatefulMcpRuntime(ToolRuntime, ResourceRuntime, PromptRuntime, Protocol):
"""Stateful execution/read/list boundary for configured MCP sources.
Implementations keep source session state across calls. Catalog refresh may
still use one-shot adapters by policy.
"""
```
Update `__all__`:
```python
__all__ = [
"BackendAdapter",
"PromptRuntime",
"ResourceRuntime",
"StatefulMcpRuntime",
"ToolCallResult",
"ToolExecutor",
"ToolRuntime",
]
```
- [ ] **Step 3: Export slices from package root**
In `src/wf_sources_mcp/sdk/__init__.py`, import/export the new symbols:
```python
from .protocols import (
BackendAdapter,
PromptRuntime,
ResourceRuntime,
StatefulMcpRuntime,
ToolCallResult,
ToolExecutor,
ToolRuntime,
)
```
Include `"PromptRuntime"`, `"ResourceRuntime"`, and `"ToolRuntime"` in `__all__`.
- [ ] **Step 4: Run protocol tests**
Run:
```bash
uv run pytest tests/wf_sources_mcp/test_sdk_protocols.py -q
```
Expected: pass.
---
### Task 2: Preserve Compatibility Exports
**Files:**
- Modify: `src/wf_mcp/sdk/base.py`
- Modify: `src/wf_mcp/sdk/__init__.py`
- Modify: `tests/wf_mcp/test_compat_imports.py`
- [ ] **Step 1: Update compatibility shims**
In `src/wf_mcp/sdk/base.py`, re-export all protocol slices:
```python
from wf_sources_mcp.sdk import (
BackendAdapter,
PromptRuntime,
ResourceRuntime,
StatefulMcpRuntime,
ToolCallResult,
ToolRuntime,
)
__all__ = [
"BackendAdapter",
"PromptRuntime",
"ResourceRuntime",
"StatefulMcpRuntime",
"ToolCallResult",
"ToolRuntime",
]
```
In `src/wf_mcp/sdk/__init__.py`, update imports/exports similarly while keeping `McpSdkAdapter`:
```python
from wf_sources_mcp.sdk import (
BackendAdapter,
McpSdkAdapter,
PromptRuntime,
ResourceRuntime,
StatefulMcpRuntime,
ToolCallResult,
ToolRuntime,
)
__all__ = [
"BackendAdapter",
"McpSdkAdapter",
"PromptRuntime",
"ResourceRuntime",
"StatefulMcpRuntime",
"ToolCallResult",
"ToolRuntime",
]
```
- [ ] **Step 2: Extend compatibility test**
In `tests/wf_mcp/test_compat_imports.py`, extend `test_wf_mcp_sdk_protocol_shims_reexport_wf_sources_mcp_sdk`:
```python
from wf_mcp.sdk import PromptRuntime as CompatPromptRuntime
from wf_mcp.sdk import ResourceRuntime as CompatResourceRuntime
from wf_mcp.sdk import ToolRuntime as CompatToolRuntime
from wf_mcp.sdk.base import PromptRuntime as CompatBasePromptRuntime
from wf_mcp.sdk.base import ResourceRuntime as CompatBaseResourceRuntime
from wf_mcp.sdk.base import ToolRuntime as CompatBaseToolRuntime
from wf_sources_mcp.sdk import PromptRuntime, ResourceRuntime, ToolRuntime
assert CompatPromptRuntime is PromptRuntime
assert CompatResourceRuntime is ResourceRuntime
assert CompatToolRuntime is ToolRuntime
assert CompatBasePromptRuntime is PromptRuntime
assert CompatBaseResourceRuntime is ResourceRuntime
assert CompatBaseToolRuntime is ToolRuntime
```
- [ ] **Step 3: Run compatibility test**
Run:
```bash
uv run pytest tests/wf_mcp/test_compat_imports.py -q
```
Expected: pass.
---
### Task 3: Add Persistent Resource/Prompt Listing Methods
**Files:**
- Modify: `src/wf_sources_mcp/runtime/session.py`
- Modify: `src/wf_sources_mcp/runtime/factory.py`
- Modify: `src/wf_sources_mcp/runtime/pool.py`
- Modify: `tests/wf_sources_mcp/test_runtime.py`
- [ ] **Step 1: Extend fake client with list methods**
In `tests/wf_sources_mcp/test_runtime.py`, add imports:
```python
from mcp.types import ListPromptsResult, ListResourcesResult, Prompt, Resource
```
Update `_FakeClient` inside `_FakeFactory._create_with_stack()`:
```python
async def list_resources(self) -> ListResourcesResult:
return ListResourcesResult(
resources=[
Resource(
uri=AnyUrl("fixture://docs/runtime"),
name="resource.runtime",
title="Runtime Resource",
description="Runtime-scoped resource.",
mimeType="text/plain",
)
]
)
async def list_prompts(self) -> ListPromptsResult:
return ListPromptsResult(
prompts=[
Prompt(
name="prompt.runtime",
title="Runtime Prompt",
description="Runtime-scoped prompt.",
arguments=[],
)
]
)
```
- [ ] **Step 2: Add runtime tests**
Append to `tests/wf_sources_mcp/test_runtime.py`:
```python
@pytest.mark.asyncio
async def test_persistent_session_factory_routes_resource_and_prompt_lists() -> None:
factory = _FakeFactory()
session = await factory.create(_connection(), None)
resources = await session.list_resources()
prompts = await session.list_prompts()
await session.close()
assert resources[0].name == "resource.runtime"
assert resources[0].uri == "fixture://docs/runtime"
assert prompts[0].name == "prompt.runtime"
@pytest.mark.asyncio
async def test_runtime_pool_reuses_session_for_resource_and_prompt_lists() -> None:
factory = _FakeFactory()
pool = McpRuntimePool(factory.create)
connection = _connection()
resources = await pool.list_resources(connection, None)
prompts = await pool.list_prompts(connection, None)
await pool.close_all()
assert resources[0].name == "resource.runtime"
assert prompts[0].name == "prompt.runtime"
assert factory.created_connections == [connection]
```
Update public surface test:
```python
assert "list_resources" in public_operations
assert "list_prompts" in public_operations
assert "invoke_method" not in public_operations
assert "send_notification" not in public_operations
```
- [ ] **Step 3: Add session callback types and methods**
In `src/wf_sources_mcp/runtime/session.py`, import DTOs:
```python
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource
```
Add callback types:
```python
RawResourceLister = Callable[[], Awaitable[list[DiscoveredResource]]]
RawPromptLister = Callable[[], Awaitable[list[DiscoveredPrompt]]]
```
Add fields:
```python
list_resources_callback: RawResourceLister | None = None
list_prompts_callback: RawPromptLister | None = None
```
Add methods:
```python
async def list_resources(self) -> list[DiscoveredResource]:
"""List MCP resources through the owner task or injected session."""
if self.list_resources_callback is not None:
return await self.list_resources_callback()
if self.client is not None:
from wf_sources_mcp.sdk.converters import resource_to_discovered
result = await self.client.list_resources()
return [resource_to_discovered(resource) for resource in result.resources]
raise RuntimeError("persistent MCP session has no resource list transport")
async def list_prompts(self) -> list[DiscoveredPrompt]:
"""List MCP prompts through the owner task or injected session."""
if self.list_prompts_callback is not None:
return await self.list_prompts_callback()
if self.client is not None:
from wf_sources_mcp.sdk.converters import prompt_to_discovered
result = await self.client.list_prompts()
return [prompt_to_discovered(prompt) for prompt in result.prompts]
raise RuntimeError("persistent MCP session has no prompt list transport")
```
- [ ] **Step 4: Wire owner methods**
In `src/wf_sources_mcp/runtime/factory.py`, add callbacks in `PersistentSessionFactory.create()`:
```python
list_resources_callback=owner.list_resources,
list_prompts_callback=owner.list_prompts,
```
Add owner methods:
```python
async def list_resources(self) -> list[DiscoveredResource]:
"""Submit resource listing through the generic owner-task operation queue."""
return await self.submit(
operation="list_resources",
run=lambda client: client.list_resources(),
)
async def list_prompts(self) -> list[DiscoveredPrompt]:
"""Submit prompt listing through the generic owner-task operation queue."""
return await self.submit(
operation="list_prompts",
run=lambda client: client.list_prompts(),
)
```
Import DTOs:
```python
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource
```
- [ ] **Step 5: Add pool methods**
In `src/wf_sources_mcp/runtime/pool.py`, import DTOs:
```python
from wf_sources_mcp.catalog import DiscoveredPrompt, DiscoveredResource
```
Add below `get_prompt()`:
```python
async def list_resources(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredResource]:
session = await self.get_session(connection, auth)
return await session.list_resources()
async def list_prompts(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> list[DiscoveredPrompt]:
session = await self.get_session(connection, auth)
return await session.list_prompts()
```
- [ ] **Step 6: Run runtime tests**
Run:
```bash
uv run pytest tests/wf_sources_mcp/test_runtime.py -q
```
Expected: pass.
---
### Task 4: Static Protocol Conformance Tests
**Files:**
- Modify: `tests/wf_sources_mcp/test_runtime.py`
- [ ] **Step 1: Add assignment-based type conformance test**
Append:
```python
def test_runtime_pool_satisfies_stateful_protocol_static_shape() -> None:
from wf_sources_mcp.sdk import (
PromptRuntime,
ResourceRuntime,
StatefulMcpRuntime,
ToolRuntime,
)
factory = _FakeFactory()
pool = McpRuntimePool(factory.create)
tool_runtime: ToolRuntime = pool
resource_runtime: ResourceRuntime = pool
prompt_runtime: PromptRuntime = pool
stateful_runtime: StatefulMcpRuntime = pool
assert tool_runtime is pool
assert resource_runtime is pool
assert prompt_runtime is pool
assert stateful_runtime is pool
```
This is a static typecheck target. Do not add `isinstance` runtime checks.
- [ ] **Step 2: Run focused typecheck**
Run:
```bash
uv run basedpyright --level error tests/wf_sources_mcp/test_runtime.py
```
Expected: `0 errors, 0 warnings, 0 notes`.
---
### Task 5: Update Docs and Archive Plan
**Files:**
- Modify: `docs/current_roadmap.md`
- Modify: `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`
- Move after completion: `docs/superpowers/plans/2026-06-07-stateful-runtime-list-resources-prompts.md` to `docs/historical/superpowers/plans/2026-06-07-stateful-runtime-list-resources-prompts.md`
- [ ] **Step 1: Update roadmap**
In `docs/current_roadmap.md`, under the MCP upstream source runtime cleanup bullets, add:
```markdown
- Completed: stateful MCP runtime now has protocol slices for tools,
resources, and prompts, and can route session-scoped `list_resources` and
`list_prompts` through the owner task. Catalog refresh still uses one-shot
adapter policy.
```
- [ ] **Step 2: Update long-lived API boundary spec**
In `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`, add after the content-access routing item:
```markdown
14. Complete: stateful MCP runtime protocols split into tool/resource/prompt
slices. Runtime can route `list_resources` and `list_prompts` through the
owner task for session-scoped listings; catalog refresh remains one-shot.
```
Renumber following item if needed.
- [ ] **Step 3: Archive completed plan after implementation**
Run only after all code/tests pass:
```bash
git mv docs/superpowers/plans/2026-06-07-stateful-runtime-list-resources-prompts.md docs/historical/superpowers/plans/2026-06-07-stateful-runtime-list-resources-prompts.md
```
---
### Task 6: Final Verification
**Files:**
- All changed files.
- [ ] **Step 1: Run focused tests**
Run:
```bash
uv run pytest tests/wf_sources_mcp tests/wf_mcp/test_compat_imports.py -q
```
Expected: pass.
- [ ] **Step 2: Run source typecheck**
Run:
```bash
uv run basedpyright --level error src
```
Expected: `0 errors, 0 warnings, 0 notes`.
- [ ] **Step 3: Run focused test typecheck**
Run:
```bash
uv run basedpyright --level error tests/wf_sources_mcp/test_runtime.py tests/wf_sources_mcp/test_sdk_protocols.py tests/wf_mcp/test_compat_imports.py
```
Expected: `0 errors, 0 warnings, 0 notes`.
- [ ] **Step 4: Run focused lint**
Run:
```bash
uv run ruff check src/wf_sources_mcp/sdk src/wf_sources_mcp/runtime src/wf_mcp/sdk tests/wf_sources_mcp/test_runtime.py tests/wf_sources_mcp/test_sdk_protocols.py tests/wf_mcp/test_compat_imports.py
```
Expected: `All checks passed!`
- [ ] **Step 5: Check raw runtime methods remain absent**
Run:
```bash
rg -n "def (invoke_method|send_notification)" src/wf_sources_mcp/runtime
```
Expected: no matches.
- [ ] **Step 6: Check whitespace**
Run:
```bash
git diff --check
```
Expected: no whitespace errors. CRLF warnings are acceptable on Windows.
---
## Self-Review
- Spec coverage: The plan adds session-scoped resource/prompt listings and splits protocols by capability without expanding raw MCP method support.
- Placeholder scan: No placeholder steps remain.
- Type consistency: resource/prompt list methods return `list[DiscoveredResource]` and `list[DiscoveredPrompt]`.
- Policy boundary: catalog refresh/discovery policy remains separate and one-shot.