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 -3
View File
@@ -1,7 +1,11 @@
from .factory import PersistentSessionFactory
from .pool import McpRuntimePool, connection_runtime_fingerprint
from wf_sources_mcp.runtime import (
McpRuntimePool,
PersistentMcpSession,
PersistentSessionFactory,
connection_runtime_fingerprint,
)
from .protocols import ToolExecutor
from .session import PersistentMcpSession
__all__ = [
"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 contextlib import AsyncExitStack
from dataclasses import dataclass, field
from wf_sources_mcp.runtime.factory import PersistentSessionFactory
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 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
__all__ = ["PersistentSessionFactory"]
+10 -141
View File
@@ -1,144 +1,13 @@
from __future__ import annotations
"""Compatibility shim for the canonical MCP source runtime pool."""
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.runtime.pool import (
McpRuntimePool,
SessionFactory,
connection_runtime_fingerprint,
)
from wf_sources_mcp.connections import McpSourceConnection
from wf_sources_mcp.sdk import ToolCallResult
from wf_sources_mcp.transports import HttpSourceTransport, StdioSourceTransport
from ..auth import AuthRecord
from ..models import ConnectionConfig
from .session import PersistentMcpSession
RuntimeConnection = ConnectionConfig | McpSourceConnection
SessionFactory = Callable[
[ConnectionConfig, AuthRecord | None],
PersistentMcpSession | Awaitable[PersistentMcpSession],
__all__ = [
"McpRuntimePool",
"SessionFactory",
"connection_runtime_fingerprint",
]
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 dataclasses import dataclass
from typing import Any
from wf_sources_mcp.runtime.session import PersistentMcpSession, RawToolCaller
from mcp.client.session import ClientSession
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()
__all__ = ["PersistentMcpSession", "RawToolCaller"]