refactor: move mcp runtime to wf_sources_mcp

This commit is contained in:
lda
2026-06-07 16:40:04 +07:00 Verified
parent 008cc6d139
commit b4fd4a6f54
15 changed files with 1126 additions and 376 deletions
+7 -5
View File
@@ -227,11 +227,13 @@ implementation state.
[2026-06-07 MCP source connection seam](./historical/superpowers/plans/2026-06-07-mcp-source-connection-seam.md). [2026-06-07 MCP source connection seam](./historical/superpowers/plans/2026-06-07-mcp-source-connection-seam.md).
- Completed: shared MCP session opener exists in `wf_sources_mcp.client`. - Completed: shared MCP session opener exists in `wf_sources_mcp.client`.
One-shot adapter (`McpSdkAdapter`) and persistent runtime One-shot adapter (`McpSdkAdapter`) and persistent runtime
(`PersistentSessionFactory`) both use it. Runtime files remain in (`PersistentSessionFactory`) both use it.
`wf_mcp` for compatibility. Next slice can move `PersistentSessionFactory`, - Completed: persistent MCP runtime moved to `wf_sources_mcp.runtime`.
`PersistentMcpSession`, and `McpRuntimePool` to `PersistentMcpSession`, `PersistentSessionFactory`, `McpRuntimePool`,
`wf_sources_mcp.runtime`. The completed plan was and `connection_runtime_fingerprint` are now canonical in
[2026-06-07 MCP client session opener](./historical/superpowers/plans/2026-06-07-mcp-client-session-opener.md). `wf_sources_mcp.runtime`; `wf_mcp.runtime.*` are compatibility shims.
Runtime remains tool-call-only. The completed plan was
[2026-06-07 MCP runtime package move](./historical/superpowers/plans/2026-06-07-mcp-runtime-package-move.md).
- Auth/source secrets boundary: keep registry desired state separate from - Auth/source secrets boundary: keep registry desired state separate from
upstream credentials, and surface missing auth as validation diagnostics. upstream credentials, and surface missing auth as validation diagnostics.
The contract is now specified in The contract is now specified in
@@ -0,0 +1,588 @@
# MCP Runtime Package Move 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:** Move persistent MCP runtime ownership from `wf_mcp.runtime` to `wf_sources_mcp.runtime` while preserving old imports as compatibility shims.
**Architecture:** The typed `McpSourceConnection` seam and shared `open_mcp_session()` now exist. This slice makes `wf_sources_mcp.runtime` canonical for persistent MCP sessions, pool reuse, and connection fingerprinting. `wf_mcp.runtime.*` should become thin re-export shims only; behavior should not change and persistent runtime remains tool-call-only.
**Tech Stack:** Python 3.14, dataclasses, asyncio actor/queue pattern, MCP Python SDK `ClientSession`, pytest, ruff, basedpyright.
---
## Current State
Canonical source-provider code already exists:
- `wf_sources_mcp.connections.McpSourceConnection`
- `wf_sources_mcp.client.open_mcp_session`
- `wf_sources_mcp.sdk.ToolCallResult`
- `wf_sources_mcp.sdk.converters.tool_result_to_call_result`
Old runtime files still live in `wf_mcp`:
- `src/wf_mcp/runtime/factory.py`
- `src/wf_mcp/runtime/session.py`
- `src/wf_mcp/runtime/pool.py`
The current `McpRuntimePool` has temporary compatibility glue:
```text
McpSourceConnection -> _legacy_connection_config() -> PersistentSessionFactory
```
After this plan, that back-conversion should disappear. The canonical runtime factory should accept `McpSourceConnection` directly.
---
## Non-Goals
- Do not broaden persistent runtime beyond `call_tool`.
- Do not add persistent `read_resource`, `get_prompt`, `invoke_method`, or `send_notification`.
- Do not move `McpSdkAdapter`.
- Do not touch MCP proxy/frontend transport.
- Do not change workflow runtime semantics.
- Do not change on-disk auth/catalog/source registry formats.
---
## Target File Structure
Create:
- `src/wf_sources_mcp/runtime/__init__.py`
- `src/wf_sources_mcp/runtime/session.py`
- `src/wf_sources_mcp/runtime/factory.py`
- `src/wf_sources_mcp/runtime/pool.py`
- `tests/wf_sources_mcp/test_runtime.py`
Modify:
- `src/wf_mcp/runtime/__init__.py` -> re-export shim
- `src/wf_mcp/runtime/session.py` -> re-export shim
- `src/wf_mcp/runtime/factory.py` -> re-export shim
- `src/wf_mcp/runtime/pool.py` -> re-export shim
- `src/wf_mcp/broker/config.py` -> canonical import from `wf_sources_mcp.runtime`
- any other production imports found by `rg 'wf_mcp\\.runtime' src`
- `tests/wf_mcp/test_compat_imports.py` -> shim identity tests
- `docs/current_roadmap.md`
- `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`
---
## Task 1: Create Canonical Runtime Session
**Files:**
- Create: `src/wf_sources_mcp/runtime/session.py`
- Test: `tests/wf_sources_mcp/test_runtime.py`
- [ ] **Step 1: Add session tests**
Create `tests/wf_sources_mcp/test_runtime.py` with:
```python
from __future__ import annotations
from dataclasses import dataclass
from typing import Any
import pytest
from mcp.types import CallToolResult, TextContent
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.runtime import PersistentMcpSession
from wf_sources_mcp.transports import StdioSourceTransport
def _connection() -> McpSourceConnection:
return McpSourceConnection(
id="demo.personal",
provider="demo",
account="personal",
transport=StdioSourceTransport(command="fake"),
)
@pytest.mark.asyncio
async def test_persistent_session_call_callback_normalizes_tool_result() -> None:
async def call_tool(tool_name: str, payload: dict[str, Any]) -> CallToolResult:
assert tool_name == "echo"
assert payload == {"text": "hi"}
return CallToolResult(
content=[TextContent(type="text", text="ok")],
structuredContent={"echoed": "hi"},
)
session = PersistentMcpSession(
connection=_connection(),
auth=AuthRecord(connection_id="demo.personal", scheme="none"),
call_callback=call_tool,
)
result = await session.call_tool("echo", {"text": "hi"})
assert result.outcome == "ok"
assert result.output == {"echoed": "hi"}
@pytest.mark.asyncio
async def test_persistent_session_raises_without_transport() -> None:
session = PersistentMcpSession(connection=_connection(), auth=None)
with pytest.raises(RuntimeError, match="no tool call transport"):
await session.call_tool("echo", {})
```
- [ ] **Step 2: Run failing tests**
Run:
```bash
uv run pytest tests/wf_sources_mcp/test_runtime.py -q
```
Expected: fail because `wf_sources_mcp.runtime` does not exist.
- [ ] **Step 3: Implement canonical session**
Create `src/wf_sources_mcp/runtime/session.py` by moving the implementation from `src/wf_mcp/runtime/session.py`, but change imports/types:
- Import `AuthRecord` from `wf_sources_mcp.auth`.
- Import `McpSourceConnection` from `wf_sources_mcp.connections`.
- Import `ToolCallResult` and `tool_result_to_call_result` from `wf_sources_mcp`.
- `PersistentMcpSession.connection` must be `McpSourceConnection`, not `ConnectionConfig`.
Keep:
- `RawToolCaller`
- `client` injection path
- `call_callback` path
- `close_callback`
- error message `"persistent MCP session has no tool call transport"`
- [ ] **Step 4: Run session tests**
Run:
```bash
uv run pytest tests/wf_sources_mcp/test_runtime.py -q
uv run basedpyright --level error src/wf_sources_mcp/runtime
```
Expected: pass.
---
## Task 2: Create Canonical Runtime Factory
**Files:**
- Create: `src/wf_sources_mcp/runtime/factory.py`
- Modify: `tests/wf_sources_mcp/test_runtime.py`
- [ ] **Step 1: Add factory owner-task tests**
Append to `tests/wf_sources_mcp/test_runtime.py`:
```python
from wf_sources_mcp.runtime.factory import PersistentSessionFactory
class _FakeFactory(PersistentSessionFactory):
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, object]]] = []
self.closed = False
async def _call_tool(self, tool_name: str, payload: dict[str, object]):
self.calls.append((tool_name, payload))
return CallToolResult(
content=[TextContent(type="text", text="ok")],
structuredContent={"echoed": payload["text"]},
)
async def _close(self) -> None:
self.closed = True
async def _create_with_stack(self, stack, connection, auth):
class _FakeClient:
async def call_tool(self, tool_name, payload):
return await self_factory._call_tool(tool_name, payload)
self_factory = self
return _FakeClient()
@pytest.mark.asyncio
async def test_persistent_session_factory_serializes_tool_calls() -> None:
factory = _FakeFactory()
session = await factory.create(_connection(), None)
first = await session.call_tool("echo", {"text": "one"})
second = await session.call_tool("echo", {"text": "two"})
await session.close()
assert first.output == {"echoed": "one"}
assert second.output == {"echoed": "two"}
assert factory.calls == [
("echo", {"text": "one"}),
("echo", {"text": "two"}),
]
```
- [ ] **Step 2: Implement factory**
Create `src/wf_sources_mcp/runtime/factory.py` by moving the implementation from `src/wf_mcp/runtime/factory.py`, but change types/imports:
- `PersistentSessionFactory.create(connection: McpSourceConnection, auth: AuthRecord | None)`.
- `_SessionOwner.connection: McpSourceConnection`.
- `_create_with_stack(stack, connection: McpSourceConnection, auth)` uses:
```python
session = await stack.enter_async_context(open_mcp_session(connection, auth))
return session
```
- Remove any `ConnectionConfig` import.
- Keep `_ToolCallRequest` and `_SessionOwner` private.
- Keep actor/queue behavior unchanged.
- [ ] **Step 3: Run factory tests**
Run:
```bash
uv run pytest tests/wf_sources_mcp/test_runtime.py -q
uv run basedpyright --level error src/wf_sources_mcp/runtime
```
Expected: pass.
---
## Task 3: Create Canonical Runtime Pool
**Files:**
- Create: `src/wf_sources_mcp/runtime/pool.py`
- Modify: `src/wf_sources_mcp/runtime/__init__.py`
- Modify: `tests/wf_sources_mcp/test_runtime.py`
- [ ] **Step 1: Add pool tests**
Append to `tests/wf_sources_mcp/test_runtime.py`:
```python
from wf_sources_mcp.runtime import McpRuntimePool, connection_runtime_fingerprint
@pytest.mark.asyncio
async def test_runtime_pool_reuses_unchanged_connection() -> None:
created: list[McpSourceConnection] = []
async def create_session(connection: McpSourceConnection, auth: AuthRecord | None):
created.append(connection)
return PersistentMcpSession(
connection=connection,
auth=auth,
call_callback=lambda tool_name, payload: CallToolResult(
content=[TextContent(type="text", text="ok")],
structuredContent={"echoed": payload["text"]},
),
)
pool = McpRuntimePool(session_factory=create_session)
connection = _connection()
await pool.call_tool(connection, None, "echo", {"text": "one"})
await pool.call_tool(connection, None, "echo", {"text": "two"})
assert created == [connection]
def test_runtime_fingerprint_changes_when_transport_changes() -> None:
original = _connection()
changed = McpSourceConnection(
id="demo.personal",
provider="demo",
account="personal",
transport=StdioSourceTransport(command="changed"),
)
assert connection_runtime_fingerprint(original) != connection_runtime_fingerprint(
changed
)
```
- [ ] **Step 2: Implement pool**
Create `src/wf_sources_mcp/runtime/pool.py` by moving the implementation from `src/wf_mcp/runtime/pool.py`, but make it canonical:
- `RuntimeConnection` should be `McpSourceConnection`.
- `SessionFactory` should accept `McpSourceConnection`, not `ConnectionConfig`.
- Remove `_legacy_connection_config`.
- Remove imports from `wf_mcp`.
- `connection_runtime_fingerprint` should accept `McpSourceConnection`.
- Keep reuse/close behavior unchanged.
Create `src/wf_sources_mcp/runtime/__init__.py`:
```python
from .factory import PersistentSessionFactory
from .pool import McpRuntimePool, connection_runtime_fingerprint
from .session import PersistentMcpSession
__all__ = [
"McpRuntimePool",
"PersistentMcpSession",
"PersistentSessionFactory",
"connection_runtime_fingerprint",
]
```
- [ ] **Step 3: Run runtime tests**
Run:
```bash
uv run pytest tests/wf_sources_mcp/test_runtime.py -q
uv run basedpyright --level error src/wf_sources_mcp/runtime
```
Expected: pass.
---
## Task 4: Turn `wf_mcp.runtime` Into Compatibility Shims
**Files:**
- Replace: `src/wf_mcp/runtime/session.py`
- Replace: `src/wf_mcp/runtime/factory.py`
- Replace: `src/wf_mcp/runtime/pool.py`
- Modify: `src/wf_mcp/runtime/__init__.py`
- Test: `tests/wf_mcp/test_compat_imports.py`
- [ ] **Step 1: Add shim identity tests**
In `tests/wf_mcp/test_compat_imports.py`, add:
```python
def test_runtime_shims_reexport_wf_sources_mcp_runtime() -> None:
from wf_mcp.runtime import (
McpRuntimePool as OldMcpRuntimePool,
PersistentMcpSession as OldPersistentMcpSession,
PersistentSessionFactory as OldPersistentSessionFactory,
connection_runtime_fingerprint as old_connection_runtime_fingerprint,
)
from wf_sources_mcp.runtime import (
McpRuntimePool,
PersistentMcpSession,
PersistentSessionFactory,
connection_runtime_fingerprint,
)
assert OldMcpRuntimePool is McpRuntimePool
assert OldPersistentMcpSession is PersistentMcpSession
assert OldPersistentSessionFactory is PersistentSessionFactory
assert old_connection_runtime_fingerprint is connection_runtime_fingerprint
```
- [ ] **Step 2: Replace old runtime files with shims**
`src/wf_mcp/runtime/session.py`:
```python
"""Compatibility shim for the canonical MCP source runtime session."""
from wf_sources_mcp.runtime.session import PersistentMcpSession, RawToolCaller
__all__ = ["PersistentMcpSession", "RawToolCaller"]
```
`src/wf_mcp/runtime/factory.py`:
```python
"""Compatibility shim for the canonical MCP source runtime factory."""
from wf_sources_mcp.runtime.factory import PersistentSessionFactory
__all__ = ["PersistentSessionFactory"]
```
`src/wf_mcp/runtime/pool.py`:
```python
"""Compatibility shim for the canonical MCP source runtime pool."""
from wf_sources_mcp.runtime.pool import (
McpRuntimePool,
SessionFactory,
connection_runtime_fingerprint,
)
__all__ = [
"McpRuntimePool",
"SessionFactory",
"connection_runtime_fingerprint",
]
```
Keep `src/wf_mcp/runtime/protocols.py` as-is if it already shims `ToolExecutor`.
Update `src/wf_mcp/runtime/__init__.py` to re-export from `wf_sources_mcp.runtime` plus `ToolExecutor`:
```python
from wf_sources_mcp.runtime import (
McpRuntimePool,
PersistentMcpSession,
PersistentSessionFactory,
connection_runtime_fingerprint,
)
from .protocols import ToolExecutor
__all__ = [
"McpRuntimePool",
"PersistentMcpSession",
"PersistentSessionFactory",
"ToolExecutor",
"connection_runtime_fingerprint",
]
```
- [ ] **Step 3: Run shim tests**
Run:
```bash
uv run pytest tests/wf_mcp/test_compat_imports.py tests/wf_sources_mcp/test_runtime.py -q
```
Expected: pass.
---
## Task 5: Update Production Imports To Canonical Runtime
**Files:**
- Modify: `src/wf_mcp/broker/config.py`
- Search all source files
- [ ] **Step 1: Find old runtime imports**
Run:
```bash
rg -n 'wf_mcp\.runtime|from \.\.runtime|from \.runtime' src tests
```
- [ ] **Step 2: Update production imports**
For production code outside shim files, import canonical runtime from `wf_sources_mcp.runtime`.
Likely file:
`src/wf_mcp/broker/config.py`
Replace:
```python
from wf_mcp.runtime import McpRuntimePool, PersistentSessionFactory
```
or relative equivalents with:
```python
from wf_sources_mcp.runtime import McpRuntimePool, PersistentSessionFactory
```
Do not update tests that intentionally verify compatibility shims.
- [ ] **Step 3: Run focused production tests**
Run:
```bash
uv run pytest tests/wf_mcp/test_stateful_runtime.py tests/wf_mcp/server/test_config.py::test_server_reuses_real_upstream_session_across_workflow_requests -q
uv run basedpyright --level error src
```
Expected: pass.
---
## Task 6: Preserve Existing Stateful Runtime Tests
**Files:**
- Modify: `tests/wf_mcp/test_stateful_runtime.py` only if required
- Test: `tests/wf_mcp/test_stateful_runtime.py`
- [ ] **Step 1: Run existing tests unchanged first**
Run:
```bash
uv run pytest tests/wf_mcp/test_stateful_runtime.py -q
```
Expected: should pass through shims. If it fails only because helper subclasses still type `ConnectionConfig`, update the tests to import canonical runtime but keep behavior assertions unchanged.
- [ ] **Step 2: Do not weaken behavior assertions**
The following behavior must remain tested:
- pool reuses unchanged connection fingerprint
- pool replaces changed connection fingerprint
- session owner serializes calls through one owner task
- closed sessions are closed via callback
- crashing factory surfaces errors to queued calls
If any of these tests need edits, preserve the same assertions and explain why in final report.
---
## Task 7: Documentation And Verification
**Files:**
- Modify: `docs/current_roadmap.md`
- Modify: `docs/superpowers/specs/2026-06-03-long-lived-workflow-api-boundary.md`
- [ ] **Step 1: Update docs**
Roadmap/spec should say:
- persistent MCP runtime moved to `wf_sources_mcp.runtime`
- `wf_mcp.runtime.*` are compatibility shims
- runtime remains tool-call-only
- next slice is moving `McpSdkAdapter` to `wf_sources_mcp.sdk.adapter`
- [ ] **Step 2: Final verification**
Run:
```bash
uv run pytest tests/wf_sources_mcp tests/wf_mcp/test_stateful_runtime.py tests/wf_mcp/test_compat_imports.py tests/wf_mcp/server/test_config.py::test_server_reuses_real_upstream_session_across_workflow_requests -q
uv run ruff check src tests
uv run basedpyright --level error src
git diff --check
```
Expected:
- focused tests pass
- ruff passes
- basedpyright has 0 errors
- no whitespace errors
If `ruff check src tests` finds unrelated pre-existing errors, do not fix unrelated files in this slice. Report exact files/errors and run `ruff check` on changed files instead.
---
## Final Report Requirements
The final report must state:
- runtime files moved or shimmed
- no behavior expansion beyond `call_tool`
- `wf_mcp.runtime` compatibility status
- whether any tests had to change and why
- exact verification commands and outputs
@@ -95,11 +95,14 @@ First slices should move leaf modules only and leave `wf_mcp` re-export shims:
5. Complete: MCP SDK conversion helpers moved to `wf_sources_mcp.sdk.converters`, with `wf_mcp.sdk.converters` retained as a shim. 5. Complete: MCP SDK conversion helpers moved to `wf_sources_mcp.sdk.converters`, with `wf_mcp.sdk.converters` retained as a shim.
6. Complete: shared MCP session opener in `wf_sources_mcp.client`. One-shot 6. Complete: shared MCP session opener in `wf_sources_mcp.client`. One-shot
adapter (`McpSdkAdapter`) and persistent runtime adapter (`McpSdkAdapter`) and persistent runtime
(`PersistentSessionFactory`) both use `open_mcp_session`. Runtime files (`PersistentSessionFactory`) both use `open_mcp_session`.
remain in `wf_mcp` for compatibility; next slice moves 7. Complete: persistent MCP runtime (`PersistentMcpSession`,
`PersistentSessionFactory`, `PersistentMcpSession`, and `McpRuntimePool` `PersistentSessionFactory`, `McpRuntimePool`,
to `wf_sources_mcp.runtime`. `connection_runtime_fingerprint`) moved to `wf_sources_mcp.runtime`, with
7. Upstream transport/discovery/session services. `wf_mcp.runtime.*` retained as compatibility shims. Runtime remains
tool-call-only. Next slice is moving `McpSdkAdapter` to
`wf_sources_mcp.sdk.adapter`.
8. Upstream transport/discovery/session services.
Each slice should add import-direction tests so the new source-provider package 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`, does not depend on `wf_mcp.workflow_surface`, `wf_mcp.admin_surface`,
+1 -1
View File
@@ -6,6 +6,7 @@ from pathlib import Path
from wf_api import file_workflow_stores from wf_api import file_workflow_stores
from wf_config import WorkflowConfigFile from wf_config import WorkflowConfigFile
from wf_config.models import FilesystemStoreConfig, McpSourceConfig, ServerConfig from wf_config.models import FilesystemStoreConfig, McpSourceConfig, ServerConfig
from wf_sources_mcp.runtime import McpRuntimePool, PersistentSessionFactory
from wf_sources_mcp.source_registry import ( from wf_sources_mcp.source_registry import (
FileSourceRegistryStore, FileSourceRegistryStore,
workflow_mcp_source_to_connection_config, workflow_mcp_source_to_connection_config,
@@ -14,7 +15,6 @@ from wf_sources_mcp.storage import FileAuthStore, FileCatalogStore, FileStore
from ..control import BrokerConfigFile, ConnectionConfigFile from ..control import BrokerConfigFile, ConnectionConfigFile
from ..models import BrokerConfig from ..models import BrokerConfig
from ..runtime import McpRuntimePool, PersistentSessionFactory
from ..sdk import McpSdkAdapter from ..sdk import McpSdkAdapter
from .models import BrokerStoreRoots from .models import BrokerStoreRoots
from .service import WfMcpService from .service import WfMcpService
+7 -3
View File
@@ -1,7 +1,11 @@
from .factory import PersistentSessionFactory from wf_sources_mcp.runtime import (
from .pool import McpRuntimePool, connection_runtime_fingerprint McpRuntimePool,
PersistentMcpSession,
PersistentSessionFactory,
connection_runtime_fingerprint,
)
from .protocols import ToolExecutor from .protocols import ToolExecutor
from .session import PersistentMcpSession
__all__ = [ __all__ = [
"McpRuntimePool", "McpRuntimePool",
+3 -154
View File
@@ -1,156 +1,5 @@
from __future__ import annotations """Compatibility shim for the canonical MCP source runtime factory."""
import asyncio from wf_sources_mcp.runtime.factory import PersistentSessionFactory
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from mcp.client.session import ClientSession __all__ = ["PersistentSessionFactory"]
from mcp.types import CallToolResult
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.client import open_mcp_session
from wf_sources_mcp.connections import mcp_source_connection_from_connection_config
from ..models import ConnectionConfig
from .session import PersistentMcpSession
@dataclass(slots=True)
class PersistentSessionFactory:
"""Create initialized persistent MCP sessions for configured connections.
Input connection metadata must describe either stdio transport
(`command`, optional `args`/`env`/`cwd`) or streamable HTTP transport
(`url`). The returned session owns its transport stack and closes it through
the `PersistentMcpSession.close_callback`.
"""
async def create(
self,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> PersistentMcpSession:
owner = _SessionOwner(factory=self, connection=connection, auth=auth)
await owner.start()
return PersistentMcpSession(
connection=connection,
auth=auth,
call_callback=owner.call_tool,
close_callback=owner.close,
)
async def _create_with_stack(
self,
stack: AsyncExitStack,
connection: ConnectionConfig,
auth: AuthRecord | None,
) -> ClientSession:
source_connection = mcp_source_connection_from_connection_config(connection)
session = await stack.enter_async_context(
open_mcp_session(source_connection, auth)
)
return session
@dataclass(slots=True)
class _ToolCallRequest:
"""One request submitted to the task that owns the MCP transport."""
tool_name: str
payload: dict[str, object]
result: asyncio.Future[CallToolResult]
@dataclass(slots=True)
class _SessionOwner:
"""Run one MCP client session entirely inside its owning asyncio task.
MCP SDK transports open AnyIO cancel scopes. Entering a transport in one
inbound MCP request and reusing it from another causes
`ClosedResourceError`/cancel-scope ownership failures. This actor keeps
transport creation, calls, and cleanup in one stable task while the public
workflow surface submits requests through a queue.
"""
factory: PersistentSessionFactory
connection: ConnectionConfig
auth: AuthRecord | None
_requests: asyncio.Queue[_ToolCallRequest | None] = field(
default_factory=asyncio.Queue
)
_task: asyncio.Task[None] | None = None
async def start(self) -> None:
"""Start the owner task and wait until its MCP session is initialized."""
ready = asyncio.get_running_loop().create_future()
self._task = asyncio.create_task(
self._run(ready),
name=f"wf-mcp-session:{self.connection.id}",
)
await ready
async def call_tool(
self,
tool_name: str,
payload: dict[str, object],
) -> CallToolResult:
"""Submit a call and fail promptly if its transport owner exits."""
task = self._task
if task is None:
raise RuntimeError("persistent MCP session is not started")
if task.done():
await task
raise RuntimeError("persistent MCP session stopped unexpectedly")
result = asyncio.get_running_loop().create_future()
await self._requests.put(
_ToolCallRequest(tool_name=tool_name, payload=payload, result=result)
)
done, _pending = await asyncio.wait(
{result, task}, return_when=asyncio.FIRST_COMPLETED
)
if result in done:
return result.result()
await task
raise RuntimeError("persistent MCP session stopped unexpectedly")
async def close(self) -> None:
"""Ask the owner task to close the MCP transport in its own scope."""
task = self._task
if task is None:
return
if not task.done():
await self._requests.put(None)
await task
self._task = None
async def _run(self, ready: asyncio.Future[None]) -> None:
"""Own the complete MCP transport lifecycle and serialized call loop."""
try:
async with AsyncExitStack() as stack:
session = await self.factory._create_with_stack(
stack, self.connection, self.auth
)
ready.set_result(None)
while True:
request = await self._requests.get()
if request is None:
return
try:
response = await session.call_tool(
request.tool_name, request.payload
)
except Exception as exc:
request.result.set_exception(exc)
else:
request.result.set_result(response)
except BaseException as exc:
if not ready.done():
ready.set_exception(exc)
return
# Calls already queued behind the failing request cannot otherwise
# observe that their sole transport owner has exited.
while not self._requests.empty():
pending = self._requests.get_nowait()
if pending is not None and not pending.result.done():
pending.result.set_exception(exc)
raise
+10 -141
View File
@@ -1,144 +1,13 @@
from __future__ import annotations """Compatibility shim for the canonical MCP source runtime pool."""
import json from wf_sources_mcp.runtime.pool import (
from collections.abc import Awaitable, Callable McpRuntimePool,
from dataclasses import asdict, dataclass, field SessionFactory,
from inspect import isawaitable connection_runtime_fingerprint,
from typing import Any, cast )
from wf_sources_mcp.connections import McpSourceConnection __all__ = [
from wf_sources_mcp.sdk import ToolCallResult "McpRuntimePool",
from wf_sources_mcp.transports import HttpSourceTransport, StdioSourceTransport "SessionFactory",
"connection_runtime_fingerprint",
from ..auth import AuthRecord
from ..models import ConnectionConfig
from .session import PersistentMcpSession
RuntimeConnection = ConnectionConfig | McpSourceConnection
SessionFactory = Callable[
[ConnectionConfig, AuthRecord | None],
PersistentMcpSession | Awaitable[PersistentMcpSession],
] ]
def connection_runtime_fingerprint(
connection: RuntimeConnection,
auth: AuthRecord | None = None,
) -> str:
"""Return the connection identity that decides MCP runtime reuse.
This is intentionally transport/auth level, not catalog level. Tool list
refreshes should not restart a browser-like MCP session, but changing the
command, URL, account, or auth payload must create a fresh session.
"""
return json.dumps(
{
"connection": asdict(connection),
"auth": asdict(auth) if auth is not None else None,
},
sort_keys=True,
separators=(",", ":"),
default=str,
)
@dataclass(slots=True)
class McpRuntimePool:
"""Cache one persistent MCP runtime per unchanged connection fingerprint.
Callers provide full `ConnectionConfig` and optional `AuthRecord` on every
call. The pool decides whether that identity still maps to the existing
upstream MCP session. If command, URL, account, or auth changes, the old
session is closed and replaced.
"""
session_factory: SessionFactory
_sessions: dict[str, tuple[str, PersistentMcpSession]] = field(default_factory=dict)
async def get_session(
self,
connection: RuntimeConnection,
auth: AuthRecord | None,
) -> PersistentMcpSession:
fingerprint = connection_runtime_fingerprint(connection, auth)
current = self._sessions.get(connection.id)
if current is not None and current[0] == fingerprint:
return current[1]
if current is not None:
await current[1].close()
# Compatibility boundary: wrappers now pass McpSourceConnection, while
# PersistentSessionFactory still consumes the legacy broker DTO until
# the shared opener/runtime move lands.
created = self.session_factory(_legacy_connection_config(connection), auth)
if isawaitable(created):
session = await created
else:
session = cast(PersistentMcpSession, created)
self._sessions[connection.id] = (fingerprint, session)
return session
async def call_tool(
self,
connection: RuntimeConnection,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
session = await self.get_session(connection, auth)
return await session.call_tool(tool_name, payload)
async def close_connection(self, connection_id: str) -> None:
current = self._sessions.pop(connection_id, None)
if current is not None:
await current[1].close()
async def close_all(self) -> None:
"""Close all live runtimes; useful for server shutdown and tests."""
sessions = list(self._sessions.values())
self._sessions.clear()
for _fingerprint, session in sessions:
await session.close()
def _legacy_connection_config(connection: RuntimeConnection) -> ConnectionConfig:
if isinstance(connection, ConnectionConfig):
return connection
metadata = dict(connection.metadata)
transport = connection.transport
if isinstance(transport, StdioSourceTransport):
metadata.update(
{
"transport": "stdio",
"command": transport.command,
"args": list(transport.args),
"env": dict(transport.env),
}
)
if transport.cwd is not None:
metadata["cwd"] = transport.cwd
elif isinstance(transport, HttpSourceTransport):
metadata.update(
{
"transport": "streamable_http",
"url": str(transport.url),
"headers": dict(transport.headers),
}
)
else:
raise ValueError(f"connection {connection.id!r} requires metadata.transport")
if connection.profile is not None:
metadata["profile"] = connection.profile
if connection.auth_ref is not None:
metadata["auth_ref"] = connection.auth_ref
return ConnectionConfig(
id=connection.id,
server=connection.provider,
account=connection.account,
enabled=connection.enabled,
metadata=metadata,
)
+3 -47
View File
@@ -1,49 +1,5 @@
from __future__ import annotations """Compatibility shim for the canonical MCP source runtime session."""
from collections.abc import Awaitable, Callable from wf_sources_mcp.runtime.session import PersistentMcpSession, RawToolCaller
from dataclasses import dataclass
from typing import Any
from mcp.client.session import ClientSession __all__ = ["PersistentMcpSession", "RawToolCaller"]
from mcp.types import CallToolResult
from wf_sources_mcp.sdk import ToolCallResult
from wf_sources_mcp.sdk.converters import tool_result_to_call_result
from ..auth import AuthRecord
from ..models import ConnectionConfig
RawToolCaller = Callable[[str, dict[str, Any]], Awaitable[CallToolResult]]
@dataclass(slots=True)
class PersistentMcpSession:
"""Long-lived MCP execution handle for one configured connection.
Production sessions use `call_callback` because MCP transports are entered
inside an AnyIO cancel scope and must be called and closed by that same
owner task. `client` remains available for simple injected/fake sessions in
tests. `call_tool()` always normalizes SDK results for workflow nodes.
"""
connection: ConnectionConfig
auth: AuthRecord | None
client: ClientSession | None = None
call_callback: RawToolCaller | None = None
close_callback: Callable[[], Awaitable[None]] | None = None
async def call_tool(
self, tool_name: str, payload: dict[str, Any]
) -> ToolCallResult:
if self.call_callback is not None:
result = await self.call_callback(tool_name, payload)
elif self.client is not None:
result = await self.client.call_tool(tool_name, payload)
else:
raise RuntimeError("persistent MCP session has no tool call transport")
return tool_result_to_call_result(result)
async def close(self) -> None:
"""Close the transport/session stack owned by the runtime factory."""
if self.close_callback is not None:
await self.close_callback()
+10
View File
@@ -0,0 +1,10 @@
from .factory import PersistentSessionFactory
from .pool import McpRuntimePool, connection_runtime_fingerprint
from .session import PersistentMcpSession
__all__ = [
"McpRuntimePool",
"PersistentMcpSession",
"PersistentSessionFactory",
"connection_runtime_fingerprint",
]
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
import asyncio
from contextlib import AsyncExitStack
from dataclasses import dataclass, field
from mcp.client.session import ClientSession
from mcp.types import CallToolResult
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.client import open_mcp_session
from wf_sources_mcp.connections import McpSourceConnection
from .session import PersistentMcpSession
@dataclass(slots=True)
class PersistentSessionFactory:
"""Create initialized persistent MCP sessions for configured connections.
Input connection metadata must describe either stdio transport
(`command`, optional `args`/`env`/`cwd`) or streamable HTTP transport
(`url`). The returned session owns its transport stack and closes it through
the `PersistentMcpSession.close_callback`.
"""
async def create(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> PersistentMcpSession:
owner = _SessionOwner(factory=self, connection=connection, auth=auth)
await owner.start()
return PersistentMcpSession(
connection=connection,
auth=auth,
call_callback=owner.call_tool,
close_callback=owner.close,
)
async def _create_with_stack(
self,
stack: AsyncExitStack,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> ClientSession:
session = await stack.enter_async_context(open_mcp_session(connection, auth))
return session
@dataclass(slots=True)
class _ToolCallRequest:
"""One request submitted to the task that owns the MCP transport."""
tool_name: str
payload: dict[str, object]
result: asyncio.Future[CallToolResult]
@dataclass(slots=True)
class _SessionOwner:
"""Run one MCP client session entirely inside its owning asyncio task.
MCP SDK transports open AnyIO cancel scopes. Entering a transport in one
inbound MCP request and reusing it from another causes
`ClosedResourceError`/cancel-scope ownership failures. This actor keeps
transport creation, calls, and cleanup in one stable task while the public
workflow surface submits requests through a queue.
"""
factory: PersistentSessionFactory
connection: McpSourceConnection
auth: AuthRecord | None
_requests: asyncio.Queue[_ToolCallRequest | None] = field(
default_factory=asyncio.Queue
)
_task: asyncio.Task[None] | None = None
async def start(self) -> None:
"""Start the owner task and wait until its MCP session is initialized."""
ready = asyncio.get_running_loop().create_future()
self._task = asyncio.create_task(
self._run(ready),
name=f"wf-mcp-session:{self.connection.id}",
)
await ready
async def call_tool(
self,
tool_name: str,
payload: dict[str, object],
) -> CallToolResult:
"""Submit a call and fail promptly if its transport owner exits."""
task = self._task
if task is None:
raise RuntimeError("persistent MCP session is not started")
if task.done():
await task
raise RuntimeError("persistent MCP session stopped unexpectedly")
result = asyncio.get_running_loop().create_future()
await self._requests.put(
_ToolCallRequest(tool_name=tool_name, payload=payload, result=result)
)
done, _pending = await asyncio.wait(
{result, task}, return_when=asyncio.FIRST_COMPLETED
)
if result in done:
return result.result()
await task
raise RuntimeError("persistent MCP session stopped unexpectedly")
async def close(self) -> None:
"""Ask the owner task to close the MCP transport in its own scope."""
task = self._task
if task is None:
return
if not task.done():
await self._requests.put(None)
await task
self._task = None
async def _run(self, ready: asyncio.Future[None]) -> None:
"""Own the complete MCP transport lifecycle and serialized call loop."""
try:
async with AsyncExitStack() as stack:
session = await self.factory._create_with_stack(
stack, self.connection, self.auth
)
ready.set_result(None)
while True:
request = await self._requests.get()
if request is None:
return
try:
response = await session.call_tool(
request.tool_name, request.payload
)
except Exception as exc:
request.result.set_exception(exc)
else:
request.result.set_result(response)
except BaseException as exc:
if not ready.done():
ready.set_exception(exc)
return
# Calls already queued behind the failing request cannot otherwise
# observe that their sole transport owner has exited.
while not self._requests.empty():
pending = self._requests.get_nowait()
if pending is not None and not pending.result.done():
pending.result.set_exception(exc)
raise
+96
View File
@@ -0,0 +1,96 @@
from __future__ import annotations
import json
from collections.abc import Awaitable, Callable
from dataclasses import asdict, dataclass, field
from inspect import isawaitable
from typing import Any, cast
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import ToolCallResult
from .session import PersistentMcpSession
SessionFactory = Callable[
[McpSourceConnection, AuthRecord | None],
PersistentMcpSession | Awaitable[PersistentMcpSession],
]
def connection_runtime_fingerprint(
connection: McpSourceConnection,
auth: AuthRecord | None = None,
) -> str:
"""Return the connection identity that decides MCP runtime reuse.
This is intentionally transport/auth level, not catalog level. Tool list
refreshes should not restart a browser-like MCP session, but changing the
command, URL, account, or auth payload must create a fresh session.
"""
return json.dumps(
{
"connection": asdict(connection),
"auth": asdict(auth) if auth is not None else None,
},
sort_keys=True,
separators=(",", ":"),
default=str,
)
@dataclass(slots=True)
class McpRuntimePool:
"""Cache one persistent MCP runtime per unchanged connection fingerprint.
Callers provide full `McpSourceConnection` and optional `AuthRecord` on
every call. The pool decides whether that identity still maps to the
existing upstream MCP session. If command, URL, account, or auth changes,
the old session is closed and replaced.
"""
session_factory: SessionFactory
_sessions: dict[str, tuple[str, PersistentMcpSession]] = field(default_factory=dict)
async def get_session(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> PersistentMcpSession:
fingerprint = connection_runtime_fingerprint(connection, auth)
current = self._sessions.get(connection.id)
if current is not None and current[0] == fingerprint:
return current[1]
if current is not None:
await current[1].close()
created = self.session_factory(connection, auth)
if isawaitable(created):
session = await created
else:
session = cast(PersistentMcpSession, created)
self._sessions[connection.id] = (fingerprint, session)
return session
async def call_tool(
self,
connection: McpSourceConnection,
auth: AuthRecord | None,
tool_name: str,
payload: dict[str, Any],
) -> ToolCallResult:
session = await self.get_session(connection, auth)
return await session.call_tool(tool_name, payload)
async def close_connection(self, connection_id: str) -> None:
current = self._sessions.pop(connection_id, None)
if current is not None:
await current[1].close()
async def close_all(self) -> None:
"""Close all live runtimes; useful for server shutdown and tests."""
sessions = list(self._sessions.values())
self._sessions.clear()
for _fingerprint, session in sessions:
await session.close()
+48
View File
@@ -0,0 +1,48 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Any
from mcp.client.session import ClientSession
from mcp.types import CallToolResult
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import ToolCallResult
from wf_sources_mcp.sdk.converters import tool_result_to_call_result
RawToolCaller = Callable[[str, dict[str, Any]], Awaitable[CallToolResult]]
@dataclass(slots=True)
class PersistentMcpSession:
"""Long-lived MCP execution handle for one configured connection.
Production sessions use `call_callback` because MCP transports are entered
inside an AnyIO cancel scope and must be called and closed by that same
owner task. `client` remains available for simple injected/fake sessions in
tests. `call_tool()` always normalizes SDK results for workflow nodes.
"""
connection: McpSourceConnection
auth: AuthRecord | None
client: ClientSession | None = None
call_callback: RawToolCaller | None = None
close_callback: Callable[[], Awaitable[None]] | None = None
async def call_tool(
self, tool_name: str, payload: dict[str, Any]
) -> ToolCallResult:
if self.call_callback is not None:
result = await self.call_callback(tool_name, payload)
elif self.client is not None:
result = await self.client.call_tool(tool_name, payload)
else:
raise RuntimeError("persistent MCP session has no tool call transport")
return tool_result_to_call_result(result)
async def close(self) -> None:
"""Close the transport/session stack owned by the runtime factory."""
if self.close_callback is not None:
await self.close_callback()
+26
View File
@@ -147,3 +147,29 @@ def test_wf_mcp_sdk_converter_shim_reexports_wf_sources_mcp_converters() -> None
assert compat_tool_result is tool_result_to_call_result assert compat_tool_result is tool_result_to_call_result
assert compat_tool_to_discovered is tool_to_discovered assert compat_tool_to_discovered is tool_to_discovered
assert compat_output_schema is workflow_output_schema_from_mcp_tool_schema assert compat_output_schema is workflow_output_schema_from_mcp_tool_schema
def test_runtime_shims_reexport_wf_sources_mcp_runtime() -> None:
from wf_mcp.runtime import (
McpRuntimePool as OldMcpRuntimePool,
)
from wf_mcp.runtime import (
PersistentMcpSession as OldPersistentMcpSession,
)
from wf_mcp.runtime import (
PersistentSessionFactory as OldPersistentSessionFactory,
)
from wf_mcp.runtime import (
connection_runtime_fingerprint as old_connection_runtime_fingerprint,
)
from wf_sources_mcp.runtime import (
McpRuntimePool,
PersistentMcpSession,
PersistentSessionFactory,
connection_runtime_fingerprint,
)
assert OldMcpRuntimePool is McpRuntimePool
assert OldPersistentMcpSession is PersistentMcpSession
assert OldPersistentSessionFactory is PersistentSessionFactory
assert old_connection_runtime_fingerprint is connection_runtime_fingerprint
+15 -20
View File
@@ -11,7 +11,7 @@ from mcp.types import CallToolResult
from wf_authoring import build_async_registry from wf_authoring import build_async_registry
from wf_core import RuntimeContext from wf_core import RuntimeContext
from wf_mcp.capabilities import DiscoveredTool from wf_mcp.capabilities import DiscoveredTool
from wf_mcp.models import AuthRecord, ConnectionConfig from wf_mcp.models import AuthRecord
from wf_mcp.runtime import McpRuntimePool, PersistentMcpSession from wf_mcp.runtime import McpRuntimePool, PersistentMcpSession
from wf_mcp.runtime.factory import PersistentSessionFactory from wf_mcp.runtime.factory import PersistentSessionFactory
from wf_mcp.sdk import ToolCallResult from wf_mcp.sdk import ToolCallResult
@@ -102,7 +102,7 @@ class CrashingSessionFactory(PersistentSessionFactory):
async def _create_with_stack( async def _create_with_stack(
self, self,
stack: AsyncExitStack, stack: AsyncExitStack,
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> ClientSession: ) -> ClientSession:
return cast(ClientSession, self.client) return cast(ClientSession, self.client)
@@ -161,20 +161,16 @@ def test_generated_workflow_specs_share_injected_tool_executor() -> None:
def test_runtime_pool_reuses_stateful_session_for_same_connection() -> None: def test_runtime_pool_reuses_stateful_session_for_same_connection() -> None:
connection = ConnectionConfig( connection = McpSourceConnection(
id="playwright.default", id="playwright.default",
server="playwright", provider="playwright",
account="default", account="default",
metadata={ transport=StdioSourceTransport(command="pnpx"),
"transport": "stdio",
"command": "pnpx",
"args": ["@playwright/mcp"],
},
) )
created_clients: list[FakeStatefulClient] = [] created_clients: list[FakeStatefulClient] = []
async def factory( async def factory(
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> PersistentMcpSession: ) -> PersistentMcpSession:
client = FakeStatefulClient() client = FakeStatefulClient()
@@ -199,22 +195,22 @@ def test_runtime_pool_reuses_stateful_session_for_same_connection() -> None:
def test_runtime_pool_replaces_session_when_fingerprint_changes() -> None: def test_runtime_pool_replaces_session_when_fingerprint_changes() -> None:
original = ConnectionConfig( original = McpSourceConnection(
id="playwright.default", id="playwright.default",
server="playwright", provider="playwright",
account="default", account="default",
metadata={"transport": "stdio", "command": "pnpx", "args": ["old"]}, transport=StdioSourceTransport(command="pnpx"),
) )
changed = ConnectionConfig( changed = McpSourceConnection(
id="playwright.default", id="playwright.default",
server="playwright", provider="playwright",
account="default", account="default",
metadata={"transport": "stdio", "command": "pnpx", "args": ["new"]}, transport=StdioSourceTransport(command="pnpx-new"),
) )
created_clients: list[FakeStatefulClient] = [] created_clients: list[FakeStatefulClient] = []
def factory( def factory(
connection: ConnectionConfig, connection: McpSourceConnection,
auth: AuthRecord | None, auth: AuthRecord | None,
) -> PersistentMcpSession: ) -> PersistentMcpSession:
client = FakeStatefulClient() client = FakeStatefulClient()
@@ -238,11 +234,10 @@ def test_runtime_pool_replaces_session_when_fingerprint_changes() -> None:
def test_persistent_session_fails_inflight_and_queued_calls_if_owner_dies() -> None: def test_persistent_session_fails_inflight_and_queued_calls_if_owner_dies() -> None:
connection = ConnectionConfig( connection = McpSourceConnection(
id="failing.default", id="failing.default",
server="failing", provider="failing",
account="default", account="default",
metadata={},
) )
async def exercise() -> tuple[ async def exercise() -> tuple[
+152
View File
@@ -0,0 +1,152 @@
from __future__ import annotations
from contextlib import AsyncExitStack
from typing import Any
import pytest
from mcp.client.session import ClientSession
from mcp.types import CallToolResult, TextContent
from wf_sources_mcp.auth import AuthRecord
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.runtime import (
McpRuntimePool,
PersistentMcpSession,
connection_runtime_fingerprint,
)
from wf_sources_mcp.runtime.factory import PersistentSessionFactory
from wf_sources_mcp.transports import StdioSourceTransport
def _connection() -> McpSourceConnection:
return McpSourceConnection(
id="demo.personal",
provider="demo",
account="personal",
transport=StdioSourceTransport(command="fake"),
)
@pytest.mark.asyncio
async def test_persistent_session_call_callback_normalizes_tool_result() -> None:
async def call_tool(tool_name: str, payload: dict[str, Any]) -> CallToolResult:
assert tool_name == "echo"
assert payload == {"text": "hi"}
return CallToolResult(
content=[TextContent(type="text", text="ok")],
structuredContent={"echoed": "hi"},
)
session = PersistentMcpSession(
connection=_connection(),
auth=AuthRecord(connection_id="demo.personal", scheme="none"),
call_callback=call_tool,
)
result = await session.call_tool("echo", {"text": "hi"})
assert result.outcome == "ok"
assert result.output == {"echoed": "hi"}
@pytest.mark.asyncio
async def test_persistent_session_raises_without_transport() -> None:
session = PersistentMcpSession(connection=_connection(), auth=None)
with pytest.raises(RuntimeError, match="no tool call transport"):
await session.call_tool("echo", {})
class _FakeFactory(PersistentSessionFactory):
def __init__(self) -> None:
self.calls: list[tuple[str, dict[str, object]]] = []
self.closed = False
async def _call_tool(
self, tool_name: str, payload: dict[str, object]
) -> CallToolResult:
self.calls.append((tool_name, payload))
return CallToolResult(
content=[TextContent(type="text", text="ok")],
structuredContent={"echoed": payload["text"]},
)
async def _close(self) -> None:
self.closed = True
async def _create_with_stack(
self,
stack: AsyncExitStack,
connection: McpSourceConnection,
auth: AuthRecord | None,
) -> ClientSession:
factory = self
class _FakeClient:
async def call_tool(
self, tool_name: str, payload: dict[str, object]
) -> CallToolResult:
return await factory._call_tool(tool_name, payload)
return _FakeClient() # type: ignore[return-value]
@pytest.mark.asyncio
async def test_persistent_session_factory_serializes_tool_calls() -> None:
factory = _FakeFactory()
session = await factory.create(_connection(), None)
first = await session.call_tool("echo", {"text": "one"})
second = await session.call_tool("echo", {"text": "two"})
await session.close()
assert first.output == {"echoed": "one"}
assert second.output == {"echoed": "two"}
assert factory.calls == [
("echo", {"text": "one"}),
("echo", {"text": "two"}),
]
@pytest.mark.asyncio
async def test_runtime_pool_reuses_unchanged_connection() -> None:
created: list[McpSourceConnection] = []
async def create_session(
connection: McpSourceConnection, auth: AuthRecord | None
) -> PersistentMcpSession:
created.append(connection)
async def _call(tool_name: str, payload: dict[str, Any]) -> CallToolResult:
return CallToolResult(
content=[TextContent(type="text", text="ok")],
structuredContent={"echoed": payload["text"]},
)
return PersistentMcpSession(
connection=connection,
auth=auth,
call_callback=_call,
)
pool = McpRuntimePool(session_factory=create_session)
connection = _connection()
await pool.call_tool(connection, None, "echo", {"text": "one"})
await pool.call_tool(connection, None, "echo", {"text": "two"})
assert created == [connection]
def test_runtime_fingerprint_changes_when_transport_changes() -> None:
original = _connection()
changed = McpSourceConnection(
id="demo.personal",
provider="demo",
account="personal",
transport=StdioSourceTransport(command="changed"),
)
assert connection_runtime_fingerprint(original) != connection_runtime_fingerprint(
changed
)