refactor: move mcp runtime to wf_sources_mcp
This commit is contained in:
@@ -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",
|
||||
|
||||
@@ -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
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user