feat: prefer stateful runtime for mcp content access
This commit is contained in:
@@ -253,6 +253,9 @@ implementation state.
|
||||
- Completed: persistent MCP runtime can now route `get_prompt` through
|
||||
the owner-task queue and `McpSourceClient`. This keeps prompt reads
|
||||
stateful without adding raw method invocation or notification support.
|
||||
- Completed: broker content access now prefers a configured stateful MCP
|
||||
runtime for `read_resource` and `get_prompt`, with one-shot adapter fallback.
|
||||
Catalog refresh/discovery remains one-shot by policy.
|
||||
- Auth/source secrets boundary: keep registry desired state separate from
|
||||
upstream credentials, and surface missing auth as validation diagnostics.
|
||||
The contract is now specified in
|
||||
|
||||
+5
@@ -1,5 +1,10 @@
|
||||
# Content Access Stateful Runtime Routing Implementation Plan
|
||||
|
||||
> **Historical:** This plan has been implemented. The `StatefulMcpRuntime` protocol
|
||||
> is in `wf_sources_mcp.sdk`, upstream transport prefers it for content reads,
|
||||
> and `WfMcpService` wires the configured runtime pool as both `tool_executor`
|
||||
> and `stateful_runtime`.
|
||||
|
||||
> **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:** Route broker content reads (`read_resource`, `render_prompt`) through a stateful MCP runtime when one is configured, while preserving one-shot adapter fallback.
|
||||
@@ -120,7 +120,10 @@ First slices should move leaf modules only and leave `wf_mcp` re-export shims:
|
||||
12. Complete: persistent MCP runtime can route `get_prompt` through the
|
||||
owner-task queue and `McpSourceClient`. Runtime still does not expose raw
|
||||
method invocation, notifications, or discovery list operations.
|
||||
13. Upstream transport/discovery/session services.
|
||||
13. Complete: broker content access now prefers configured `StatefulMcpRuntime`
|
||||
for resource and prompt reads, falling back to the one-shot adapter when no
|
||||
stateful runtime is configured. Catalog refresh/discovery remains one-shot.
|
||||
14. Upstream transport/discovery/session services.
|
||||
|
||||
Each slice should add import-direction tests so the new source-provider package
|
||||
does not depend on `wf_mcp.workflow_surface`, `wf_mcp.admin_surface`,
|
||||
|
||||
@@ -168,6 +168,10 @@ def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
||||
# focused services receive role-specific stores.
|
||||
auth_store = FileAuthStore(store_roots.auth_root)
|
||||
catalog_store = FileCatalogStore(store_roots.catalog_cache_root)
|
||||
# Discovery can use short-lived SDK sessions. Workflow execution needs
|
||||
# a persistent runtime so stateful MCP servers keep session/page state
|
||||
# across sequential workflow nodes.
|
||||
runtime_pool = McpRuntimePool(runtime_factory.create)
|
||||
service = WfMcpService(
|
||||
store=FileStore(store_roots.auth_root),
|
||||
auth_store=auth_store,
|
||||
@@ -178,7 +182,8 @@ def build_service_from_config(config: BrokerConfig) -> WfMcpService:
|
||||
# Discovery can use short-lived SDK sessions. Workflow execution needs
|
||||
# a persistent runtime so stateful MCP servers keep session/page state
|
||||
# across sequential workflow nodes.
|
||||
tool_executor=McpRuntimePool(runtime_factory.create),
|
||||
tool_executor=runtime_pool,
|
||||
stateful_runtime=runtime_pool,
|
||||
)
|
||||
source_registry_store = FileSourceRegistryStore(store_roots.source_registry_root)
|
||||
service.sync_connections_from_config(
|
||||
|
||||
@@ -26,7 +26,7 @@ from wf_sources_mcp.catalog import (
|
||||
CatalogPromptEntry,
|
||||
CatalogResourceEntry,
|
||||
)
|
||||
from wf_sources_mcp.sdk import BackendAdapter, ToolExecutor
|
||||
from wf_sources_mcp.sdk import BackendAdapter, StatefulMcpRuntime, ToolExecutor
|
||||
from wf_sources_mcp.source_registry import SourceRegistryStore
|
||||
from wf_sources_mcp.storage import AuthStore, CatalogStore, Store
|
||||
|
||||
@@ -68,6 +68,7 @@ class WfMcpService:
|
||||
draft_workspace_store: DraftWorkspaceStore | None = None
|
||||
run_store: RunStore | None = None
|
||||
tool_executor: ToolExecutor | None = None
|
||||
stateful_runtime: StatefulMcpRuntime | None = None
|
||||
events: BrokerEventRecorder = field(init=False)
|
||||
connection_service: ConnectionService = field(init=False)
|
||||
upstream: UpstreamTransportService = field(init=False)
|
||||
@@ -91,6 +92,7 @@ class WfMcpService:
|
||||
catalog_store=catalog_store,
|
||||
event_sink=self.events.record_event,
|
||||
tool_executor=self.tool_executor,
|
||||
stateful_runtime=self.stateful_runtime,
|
||||
)
|
||||
self.source_catalog = SourceCatalogService(
|
||||
store=catalog_store,
|
||||
|
||||
@@ -28,10 +28,9 @@ from wf_mcp.shared.errors import error_payload
|
||||
from wf_sources_mcp.auth import AuthRecord, connection_auth_diagnostic
|
||||
from wf_sources_mcp.catalog.models import CatalogSnapshot
|
||||
from wf_sources_mcp.connections import (
|
||||
McpSourceConnection,
|
||||
mcp_source_connection_from_connection_config,
|
||||
)
|
||||
from wf_sources_mcp.sdk import BackendAdapter, ToolExecutor
|
||||
from wf_sources_mcp.sdk import BackendAdapter, StatefulMcpRuntime, ToolExecutor
|
||||
from wf_sources_mcp.storage import AuthStore, CatalogStore
|
||||
|
||||
from .adapters import require_adapter
|
||||
@@ -53,6 +52,7 @@ class UpstreamTransportService:
|
||||
event_sink: EventSink
|
||||
adapters: dict[str, BackendAdapter] = field(default_factory=dict)
|
||||
tool_executor: ToolExecutor | None = None
|
||||
stateful_runtime: StatefulMcpRuntime | None = None
|
||||
|
||||
def register_adapter(self, server: str, adapter: BackendAdapter) -> None:
|
||||
self.adapters[server] = adapter
|
||||
@@ -103,7 +103,6 @@ class UpstreamTransportService:
|
||||
qualified_name: str,
|
||||
uri: str,
|
||||
) -> dict[str, Any]:
|
||||
adapter = require_adapter(connection, self.adapters)
|
||||
auth = self.load_connection_auth(connection)
|
||||
# Compatibility boundary: broker callers still pass ConnectionConfig.
|
||||
source_connection = mcp_source_connection_from_connection_config(connection)
|
||||
@@ -115,6 +114,14 @@ class UpstreamTransportService:
|
||||
payload={"uri": uri},
|
||||
)
|
||||
)
|
||||
if self.stateful_runtime is not None:
|
||||
result = await self.stateful_runtime.read_resource(
|
||||
source_connection,
|
||||
auth,
|
||||
uri,
|
||||
)
|
||||
else:
|
||||
adapter = require_adapter(connection, self.adapters)
|
||||
result = await adapter.read_resource(source_connection, auth, uri)
|
||||
self.event_sink(
|
||||
make_event(
|
||||
@@ -133,7 +140,6 @@ class UpstreamTransportService:
|
||||
local_name: str,
|
||||
arguments: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
adapter = require_adapter(connection, self.adapters)
|
||||
auth = self.load_connection_auth(connection)
|
||||
# Compatibility boundary: broker callers still pass ConnectionConfig.
|
||||
source_connection = mcp_source_connection_from_connection_config(connection)
|
||||
@@ -145,6 +151,15 @@ class UpstreamTransportService:
|
||||
payload={"argument_keys": sorted((arguments or {}).keys())},
|
||||
)
|
||||
)
|
||||
if self.stateful_runtime is not None:
|
||||
result = await self.stateful_runtime.get_prompt(
|
||||
source_connection,
|
||||
auth,
|
||||
local_name,
|
||||
arguments,
|
||||
)
|
||||
else:
|
||||
adapter = require_adapter(connection, self.adapters)
|
||||
result = await adapter.get_prompt(source_connection, auth, local_name, arguments)
|
||||
self.event_sink(
|
||||
make_event(
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
from wf_sources_mcp.sdk import BackendAdapter, McpSdkAdapter, ToolCallResult
|
||||
from wf_sources_mcp.sdk import (
|
||||
BackendAdapter,
|
||||
McpSdkAdapter,
|
||||
StatefulMcpRuntime,
|
||||
ToolCallResult,
|
||||
)
|
||||
|
||||
__all__ = ["BackendAdapter", "McpSdkAdapter", "ToolCallResult"]
|
||||
__all__ = ["BackendAdapter", "McpSdkAdapter", "StatefulMcpRuntime", "ToolCallResult"]
|
||||
|
||||
@@ -5,9 +5,10 @@ Canonical implementation lives in `wf_sources_mcp.sdk`.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult
|
||||
from wf_sources_mcp.sdk import BackendAdapter, StatefulMcpRuntime, ToolCallResult
|
||||
|
||||
__all__ = [
|
||||
"BackendAdapter",
|
||||
"StatefulMcpRuntime",
|
||||
"ToolCallResult",
|
||||
]
|
||||
|
||||
@@ -8,11 +8,12 @@ from .converters import (
|
||||
tool_to_discovered,
|
||||
workflow_output_schema_from_mcp_tool_schema,
|
||||
)
|
||||
from .protocols import BackendAdapter, ToolCallResult, ToolExecutor
|
||||
from .protocols import BackendAdapter, StatefulMcpRuntime, ToolCallResult, ToolExecutor
|
||||
|
||||
__all__ = [
|
||||
"BackendAdapter",
|
||||
"McpSdkAdapter",
|
||||
"StatefulMcpRuntime",
|
||||
"ToolCallResult",
|
||||
"ToolExecutor",
|
||||
"prompt_to_discovered",
|
||||
|
||||
@@ -99,8 +99,32 @@ class ToolExecutor(Protocol):
|
||||
) -> ToolCallResult: ...
|
||||
|
||||
|
||||
class StatefulMcpRuntime(ToolExecutor, Protocol):
|
||||
"""Stateful execution/read boundary for configured MCP sources.
|
||||
|
||||
Implementations keep source session state across calls. Discovery/catalog
|
||||
refresh may still use one-shot adapters by policy.
|
||||
"""
|
||||
|
||||
async def read_resource(
|
||||
self,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
uri: str,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
async def get_prompt(
|
||||
self,
|
||||
connection: McpSourceConnection,
|
||||
auth: AuthRecord | None,
|
||||
prompt_name: str,
|
||||
arguments: dict[str, str] | None = None,
|
||||
) -> dict[str, Any]: ...
|
||||
|
||||
|
||||
__all__ = [
|
||||
"BackendAdapter",
|
||||
"StatefulMcpRuntime",
|
||||
"ToolCallResult",
|
||||
"ToolExecutor",
|
||||
]
|
||||
|
||||
@@ -156,3 +156,59 @@ async def test_content_access_raises_on_unknown_resource() -> None:
|
||||
|
||||
with pytest.raises(KeyError):
|
||||
await content_access.read_resource("nonexistent.resource")
|
||||
|
||||
|
||||
class _StatefulRuntime:
|
||||
def __init__(self) -> None:
|
||||
self.resources: list[str] = []
|
||||
self.prompts: list[str] = []
|
||||
|
||||
async def call_tool(self, connection, auth, tool_name, payload):
|
||||
raise AssertionError("not used")
|
||||
|
||||
async def read_resource(self, connection, auth, uri: str):
|
||||
self.resources.append(uri)
|
||||
return {"contents": [{"uri": uri, "text": "stateful resource"}]}
|
||||
|
||||
async def get_prompt(self, connection, auth, prompt_name, arguments=None):
|
||||
self.prompts.append(prompt_name)
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {"type": "text", "text": "stateful prompt"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
async def test_content_access_uses_stateful_runtime_for_upstream_content() -> None:
|
||||
runtime = _StatefulRuntime()
|
||||
service = WfMcpService(
|
||||
store=FileStore(local_temp_root() / "content_stateful_runtime"),
|
||||
tool_executor=runtime,
|
||||
stateful_runtime=runtime,
|
||||
)
|
||||
service.register_connection(
|
||||
ConnectionConfig(
|
||||
id="demo.personal",
|
||||
server="demo",
|
||||
account="personal",
|
||||
metadata={"transport": "stdio", "command": "fake-mcp-server"},
|
||||
)
|
||||
)
|
||||
service.register_adapter("demo", FakeAdapter())
|
||||
await service.refresh_connection_catalog("demo.personal")
|
||||
|
||||
resource = await service.content_access.read_resource(
|
||||
"demo.personal.resource.welcome"
|
||||
)
|
||||
prompt = await service.content_access.render_prompt(
|
||||
"demo.personal.prompt.summarize",
|
||||
arguments={"text": "hello"},
|
||||
)
|
||||
|
||||
assert resource["contents"][0]["text"] == "stateful resource"
|
||||
assert prompt["messages"][0]["content"]["text"] == "stateful prompt"
|
||||
assert runtime.resources == ["demo://docs/welcome"]
|
||||
assert runtime.prompts == ["prompt.summarize"]
|
||||
|
||||
@@ -351,3 +351,110 @@ def test_upstream_transport_uses_separate_auth_and_catalog_stores(
|
||||
|
||||
assert (tmp_path / "auth" / "auth" / "demo.personal.json").exists()
|
||||
assert (tmp_path / "catalog" / "catalog" / "demo.personal.json").exists()
|
||||
|
||||
|
||||
class _StatefulRuntime:
|
||||
def __init__(self) -> None:
|
||||
self.resources: list[tuple[str, str]] = []
|
||||
self.prompts: list[tuple[str, str, dict[str, str] | None]] = []
|
||||
|
||||
async def call_tool(self, connection, auth, tool_name, payload):
|
||||
raise AssertionError("not used by these tests")
|
||||
|
||||
async def read_resource(self, connection, auth, uri: str):
|
||||
self.resources.append((connection.id, uri))
|
||||
return {"contents": [{"uri": uri, "text": "stateful resource"}]}
|
||||
|
||||
async def get_prompt(
|
||||
self,
|
||||
connection,
|
||||
auth,
|
||||
prompt_name: str,
|
||||
arguments: dict[str, str] | None = None,
|
||||
):
|
||||
self.prompts.append((connection.id, prompt_name, arguments))
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"role": "user",
|
||||
"content": {"type": "text", "text": "stateful prompt"},
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
class _ExplodingContentAdapter(FakeAdapter):
|
||||
async def read_resource(self, connection, auth, uri):
|
||||
raise AssertionError("adapter read_resource should not be used")
|
||||
|
||||
async def get_prompt(self, connection, auth, prompt_name, arguments=None):
|
||||
raise AssertionError("adapter get_prompt should not be used")
|
||||
|
||||
|
||||
async def test_upstream_transport_prefers_stateful_runtime_for_resource_reads(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[McpEvent] = []
|
||||
runtime = _StatefulRuntime()
|
||||
transport = UpstreamTransportService(
|
||||
auth_store=FileStore(tmp_path),
|
||||
catalog_store=FileStore(tmp_path),
|
||||
event_sink=events.append,
|
||||
stateful_runtime=runtime,
|
||||
)
|
||||
transport.register_adapter("demo", _ExplodingContentAdapter())
|
||||
connection = ConnectionConfig(
|
||||
id="demo.personal",
|
||||
server="demo",
|
||||
account="personal",
|
||||
metadata=_fake_transport_metadata(),
|
||||
)
|
||||
|
||||
result = await transport.read_resource(
|
||||
connection,
|
||||
"demo.personal.resource.welcome",
|
||||
"fixture://docs/welcome",
|
||||
)
|
||||
|
||||
assert result["contents"][0]["text"] == "stateful resource"
|
||||
assert runtime.resources == [("demo.personal", "fixture://docs/welcome")]
|
||||
assert [event.kind for event in events] == [
|
||||
"resource_read_started",
|
||||
"resource_read_completed",
|
||||
]
|
||||
|
||||
|
||||
async def test_upstream_transport_prefers_stateful_runtime_for_prompts(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
events: list[McpEvent] = []
|
||||
runtime = _StatefulRuntime()
|
||||
transport = UpstreamTransportService(
|
||||
auth_store=FileStore(tmp_path),
|
||||
catalog_store=FileStore(tmp_path),
|
||||
event_sink=events.append,
|
||||
stateful_runtime=runtime,
|
||||
)
|
||||
transport.register_adapter("demo", _ExplodingContentAdapter())
|
||||
connection = ConnectionConfig(
|
||||
id="demo.personal",
|
||||
server="demo",
|
||||
account="personal",
|
||||
metadata=_fake_transport_metadata(),
|
||||
)
|
||||
|
||||
result = await transport.render_prompt(
|
||||
connection,
|
||||
"demo.personal.prompt.summarize",
|
||||
"prompt.summarize",
|
||||
{"text": "hello"},
|
||||
)
|
||||
|
||||
assert result["messages"][0]["content"]["text"] == "stateful prompt"
|
||||
assert runtime.prompts == [
|
||||
("demo.personal", "prompt.summarize", {"text": "hello"})
|
||||
]
|
||||
assert [event.kind for event in events] == [
|
||||
"prompt_get_started",
|
||||
"prompt_get_completed",
|
||||
]
|
||||
|
||||
@@ -106,15 +106,19 @@ def test_wf_mcp_catalog_models_shim_reexports_wf_sources_mcp_catalog_models() ->
|
||||
|
||||
def test_wf_mcp_sdk_protocol_shims_reexport_wf_sources_mcp_sdk() -> None:
|
||||
from wf_mcp.sdk import BackendAdapter as CompatBackendAdapter
|
||||
from wf_mcp.sdk import StatefulMcpRuntime as CompatStatefulMcpRuntime
|
||||
from wf_mcp.sdk import ToolCallResult as CompatToolCallResult
|
||||
from wf_mcp.sdk.base import BackendAdapter as CompatBaseBackendAdapter
|
||||
from wf_mcp.sdk.base import StatefulMcpRuntime as CompatBaseStatefulMcpRuntime
|
||||
from wf_mcp.sdk.base import ToolCallResult as CompatBaseToolCallResult
|
||||
from wf_sources_mcp.sdk import BackendAdapter, ToolCallResult
|
||||
from wf_sources_mcp.sdk import BackendAdapter, StatefulMcpRuntime, ToolCallResult
|
||||
|
||||
assert CompatBackendAdapter is BackendAdapter
|
||||
assert CompatToolCallResult is ToolCallResult
|
||||
assert CompatStatefulMcpRuntime is StatefulMcpRuntime
|
||||
assert CompatBaseBackendAdapter is BackendAdapter
|
||||
assert CompatBaseToolCallResult is ToolCallResult
|
||||
assert CompatBaseStatefulMcpRuntime is StatefulMcpRuntime
|
||||
|
||||
|
||||
def test_wf_mcp_runtime_protocol_shim_reexports_wf_sources_mcp_tool_executor() -> None:
|
||||
|
||||
@@ -75,3 +75,10 @@ async def test_tool_executor_protocol_can_describe_tool_calls() -> None:
|
||||
|
||||
assert result.outcome == "ok"
|
||||
assert result.output == {"echoed": {"message": "hello"}}
|
||||
|
||||
|
||||
def test_stateful_mcp_runtime_protocol_shape() -> None:
|
||||
from wf_sources_mcp.sdk import StatefulMcpRuntime, ToolExecutor
|
||||
|
||||
assert StatefulMcpRuntime.__name__ == "StatefulMcpRuntime"
|
||||
assert ToolExecutor.__name__ == "ToolExecutor"
|
||||
|
||||
Reference in New Issue
Block a user