279 lines
9.9 KiB
Markdown
279 lines
9.9 KiB
Markdown
# Stateful Runtime E2E Gap Audit
|
|
|
|
**Date:** 2026-06-08
|
|
**Auditor:** opencode
|
|
**Goal:** Design smallest e2e test proving JSON-RPC/CLI-backed workflow server uses shared `McpRuntimePool` instead of one-shot `McpSdkAdapter` sessions.
|
|
|
|
---
|
|
|
|
## 1. Current Call Chain for Tool/Resource/Prompt Operations
|
|
|
|
### Tool Calls (Workflow Node Execution)
|
|
|
|
```
|
|
JSON-RPC request
|
|
→ RpcWorkflowApiClient.call_capability()
|
|
→ WorkflowServer.api.call_capability()
|
|
→ SourceCatalogService.get_qualified_spec(qualified_name)
|
|
→ spec.fn(payload) # NodeSpec function
|
|
→ tool_executor_for(connection).call_tool(...)
|
|
→ UpstreamTransportService.tool_executor_for()
|
|
→ self.tool_executor (McpRuntimePool) or require_adapter()
|
|
→ McpRuntimePool.call_tool()
|
|
→ get_session() # fingerprint-based reuse
|
|
→ PersistentMcpSession.call_tool()
|
|
→ _SessionOwner.submit() # owner-task queue
|
|
```
|
|
|
|
**Key files:**
|
|
|
|
- `src/wf_transport_rpc_http/methods_capabilities.py` - RPC entry point
|
|
- `src/wf_mcp/broker/service/workflow_operation_context.py:78-86` - context_from_service
|
|
- `src/wf_mcp/broker/service/source_catalog.py:257-298` - spec_from_snapshot_entry (hydrated specs)
|
|
- `src/wf_mcp/broker/service/upstream_transport.py:86-95` - tool_executor_for()
|
|
- `src/wf_sources_mcp/runtime/pool.py:50-55` - McpRuntimePool.call_tool()
|
|
- `src/wf_sources_mcp/runtime/pool.py:36-48` - McpRuntimePool.get_session()
|
|
- `src/wf_sources_mcp/runtime/factory.py:82-160` - _SessionOwner (owner-task queue)
|
|
|
|
### Resource Reads
|
|
|
|
```
|
|
ContentAccessService.read_resource(qualified_name)
|
|
→ SourceCatalogService.get_resource(qualified_name)
|
|
→ ConnectionService.get(connection_id)
|
|
→ UpstreamTransportService.read_resource(connection, qualified_name, uri)
|
|
→ self.stateful_runtime.read_resource(...) # preferred
|
|
→ adapter.read_resource(...) # fallback
|
|
```
|
|
|
|
**Key files:**
|
|
|
|
- `src/wf_mcp/broker/service/content_access.py:30-52` - read_resource()
|
|
- `src/wf_mcp/broker/service/upstream_transport.py:97-126` - read_resource()
|
|
|
|
### Prompt Renders
|
|
|
|
```
|
|
ContentAccessService.render_prompt(qualified_name, arguments)
|
|
→ SourceCatalogService.get_prompt(qualified_name)
|
|
→ ConnectionService.get(connection_id)
|
|
→ UpstreamTransportService.render_prompt(connection, qualified_name, local_name, arguments)
|
|
→ self.stateful_runtime.get_prompt(...) # preferred
|
|
→ adapter.get_prompt(...) # fallback
|
|
```
|
|
|
|
**Key files:**
|
|
|
|
- `src/wf_mcp/broker/service/content_access.py:54-82` - render_prompt()
|
|
- `src/wf_mcp/broker/service/upstream_transport.py:128-163` - render_prompt()
|
|
|
|
---
|
|
|
|
## 2. Where Stateful Runtime Is Used vs One-Shot Adapter Fallback
|
|
|
|
### Stateful Runtime Usage (McpRuntimePool)
|
|
|
|
| Operation | Where | Condition |
|
|
|-----------|-------|-----------|
|
|
| Tool calls (workflow nodes) | `source_catalog.py:279` via `tool_executor_for()` | `tool_executor is not None` |
|
|
| Resource reads | `upstream_transport.py:117-121` | `stateful_runtime is not None` |
|
|
| Prompt renders | `upstream_transport.py:151-157` | `stateful_runtime is not None` |
|
|
| Catalog refresh specs | `upstream_transport.py:258` via `tool_executor_for()` | `tool_executor is not None` |
|
|
|
|
### One-Shot Adapter Fallback (McpSdkAdapter)
|
|
|
|
| Operation | Where | Condition |
|
|
|-----------|-------|-----------|
|
|
| Discovery (list_tools/resources/prompts) | `upstream_transport.py:256-258` | Always uses `require_adapter()` |
|
|
| Live source checks | `upstream_transport.py:292-298` | Always uses `require_adapter()` |
|
|
| Raw method invocation | `upstream_transport.py:165-178` | Always uses `require_adapter()` |
|
|
| Notifications | `upstream_transport.py:180-197` | Always uses `require_adapter()` |
|
|
| Resource reads (no pool) | `upstream_transport.py:123-124` | `stateful_runtime is None` |
|
|
| Prompt renders (no pool) | `upstream_transport.py:159-160` | `stateful_runtime is None` |
|
|
|
|
### Configuration Wiring
|
|
|
|
```python
|
|
# src/wf_mcp/broker/config.py:182-186
|
|
service = WfMcpService(
|
|
...
|
|
tool_executor=runtime_pool, # McpRuntimePool
|
|
stateful_runtime=runtime_pool, # McpRuntimePool (same instance)
|
|
)
|
|
```
|
|
|
|
---
|
|
|
|
## 3. Smallest Reliable Test Design
|
|
|
|
### Test Objective
|
|
|
|
Prove that two sequential `call_capability` RPC requests through the JSON-RPC server share the same underlying MCP session, rather than opening a new session per request.
|
|
|
|
### Test Implementation
|
|
|
|
```python
|
|
# tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py
|
|
|
|
|
|
async def test_rpc_workflow_shares_mcp_runtime_session_across_tool_calls(
|
|
tmp_path,
|
|
) -> None:
|
|
"""Prove JSON-RPC-backed workflow server uses shared McpRuntimePool."""
|
|
# 1. Build server from config (wires McpRuntimePool)
|
|
config = BrokerConfig(
|
|
store_root=tmp_path / "store",
|
|
connections=[
|
|
ConnectionConfig(
|
|
id="fixture.personal",
|
|
server="fixture",
|
|
account="personal",
|
|
metadata={
|
|
"transport": "stdio",
|
|
"command": sys.executable,
|
|
"args": [fixture_server_path()],
|
|
},
|
|
)
|
|
],
|
|
)
|
|
server = build_workflow_server_from_config(config)
|
|
app = create_rpc_app(server)
|
|
|
|
# 2. Track session creation via pool's internal state
|
|
pool = server.context.specs # Access through context
|
|
# ... or access via service.upstream.tool_executor
|
|
|
|
async with httpx.AsyncClient(
|
|
transport=httpx.ASGITransport(app=app),
|
|
base_url="http://test",
|
|
) as http_client:
|
|
client = RpcWorkflowApiClient(
|
|
url="http://test/rpc",
|
|
http_client=http_client,
|
|
)
|
|
|
|
# 3. First tool call - creates session
|
|
first = await client.call_capability(
|
|
qualified_name="fixture.personal.echo_tool",
|
|
payload={"text": "one"},
|
|
)
|
|
|
|
# 4. Second tool call - should reuse session
|
|
second = await client.call_capability(
|
|
qualified_name="fixture.personal.echo_tool",
|
|
payload={"text": "two"},
|
|
)
|
|
|
|
# 5. Verify same session was used (pool has exactly 1 session)
|
|
assert first["outcome"] == "ok"
|
|
assert second["outcome"] == "ok"
|
|
assert len(pool._sessions) == 1 # Key assertion
|
|
```
|
|
|
|
### Alternative: Session Counter Approach
|
|
|
|
If pool internals aren't accessible, use a recording factory:
|
|
|
|
```python
|
|
async def test_rpc_server_uses_shared_runtime_pool(tmp_path) -> None:
|
|
session_creations = 0
|
|
|
|
class CountingSessionFactory:
|
|
async def create(self, connection, auth):
|
|
nonlocal session_creations
|
|
session_creations += 1
|
|
# ... create real session
|
|
|
|
# Inject counting factory into pool
|
|
# ... then verify session_creations == 1 after multiple tool calls
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Blockers and Fake/Test Hooks Needed
|
|
|
|
### Blockers
|
|
|
|
1. **Pool internals not directly accessible from RPC test**
|
|
- `WorkflowServer` wraps `WfMcpService` via `context_from_service()`
|
|
- Pool is buried in `service.upstream.tool_executor`
|
|
- Need to extract pool reference for assertion
|
|
|
|
2. **Existing test is close but not JSON-RPC**
|
|
- `test_server_reuses_real_upstream_session_across_workflow_requests()` in `tests/wf_mcp/server/test_config.py:121-147` tests via MCP client, not JSON-RPC
|
|
- Needs adaptation for RPC transport
|
|
|
|
### Fake/Test Hooks Needed
|
|
|
|
1. **Session creation counter** (preferred)
|
|
- Add optional `on_session_created` callback to `McpRuntimePool`
|
|
- Or expose `_sessions` for test inspection
|
|
|
|
2. **Recording factory** (alternative)
|
|
- Create `RecordingSessionFactory` that counts invocations
|
|
- Inject into pool during test setup
|
|
|
|
3. **Pool reference accessor**
|
|
- Add `get_pool()` method to `WfMcpService` or expose via property
|
|
- Or access via `service.upstream.tool_executor`
|
|
|
|
---
|
|
|
|
## 5. Exact Files/Tests Likely to Change
|
|
|
|
### Files to Modify
|
|
|
|
| File | Change |
|
|
|------|--------|
|
|
| `tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py` | Add new e2e test |
|
|
| `src/wf_sources_mcp/runtime/pool.py` | Add optional session counter (minimal) |
|
|
|
|
### Tests to Add/Modify
|
|
|
|
| Test | Status | Purpose |
|
|
|------|--------|---------|
|
|
| `test_rpc_workflow_shares_mcp_runtime_session_across_tool_calls` | **NEW** | Prove JSON-RPC uses shared pool |
|
|
| `test_server_reuses_real_upstream_session_across_workflow_requests` | Existing | Already tests MCP client path |
|
|
|
|
### Files to Inspect (No Changes)
|
|
|
|
- `src/wf_mcp/broker/config.py:182-186` - Pool wiring (already correct)
|
|
- `src/wf_mcp/broker/service/upstream_transport.py:86-95` - tool_executor_for (already routes to pool)
|
|
- `src/wf_sources_mcp/runtime/pool.py:36-48` - get_session (fingerprint-based reuse)
|
|
- `src/wf_mcp/broker/service/source_catalog.py:279` - Hydrated spec tool call routing
|
|
|
|
---
|
|
|
|
## 6. Verification Commands
|
|
|
|
```bash
|
|
# Run existing related tests
|
|
uv run pytest tests/wf_mcp/server/test_config.py::test_server_reuses_real_upstream_session_across_workflow_requests -q
|
|
uv run pytest tests/wf_mcp/test_stateful_runtime.py -q
|
|
uv run pytest tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py -q
|
|
|
|
# After adding new test
|
|
uv run pytest tests/wf_transport_rpc_http/test_mcp_backed_server_rpc.py::test_rpc_workflow_shares_mcp_runtime_session_across_tool_calls -q
|
|
|
|
# Type checking
|
|
uv run basedpyright --level error
|
|
uv run ruff check
|
|
uv run ruff format
|
|
```
|
|
|
|
---
|
|
|
|
## 7. Summary
|
|
|
|
**Current State:** The wiring is correct. `McpRuntimePool` is configured as both `tool_executor` and `stateful_runtime` in config-built services. The pool's `get_session()` method reuses sessions based on connection fingerprint.
|
|
|
|
**Gap:** No JSON-RPC-specific test proves this reuse. Existing tests cover MCP client path and unit-level pool behavior, but not the full RPC transport stack.
|
|
|
|
**Smallest Fix:** Add one e2e test in `test_mcp_backed_server_rpc.py` that:
|
|
|
|
1. Builds server from config
|
|
2. Makes two `call_capability` RPC requests
|
|
3. Asserts pool has exactly 1 session (not 2)
|
|
|
|
**Estimated Effort:** ~30 lines of test code + optional pool counter hook.
|