Files
openai--openai-agents-python/src/agents/mcp/manager.py
T
2026-07-13 12:39:17 +08:00

412 lines
16 KiB
Python

from __future__ import annotations
import asyncio
from collections.abc import Awaitable, Callable, Iterable
from contextlib import AbstractAsyncContextManager
from dataclasses import dataclass
from typing import Any
from ..logger import logger
from .server import MCPServer
@dataclass
class _ServerCommand:
action: str
timeout_seconds: float | None
future: asyncio.Future[None]
class _ServerWorker:
def __init__(
self,
server: MCPServer,
connect_timeout_seconds: float | None,
cleanup_timeout_seconds: float | None,
) -> None:
self._server = server
self._connect_timeout_seconds = connect_timeout_seconds
self._cleanup_timeout_seconds = cleanup_timeout_seconds
self._queue: asyncio.Queue[_ServerCommand] = asyncio.Queue()
self._task = asyncio.create_task(self._run())
@property
def is_done(self) -> bool:
return self._task.done()
async def connect(self) -> None:
await self._submit("connect", self._connect_timeout_seconds)
async def cleanup(self) -> None:
await self._submit("cleanup", self._cleanup_timeout_seconds)
async def _submit(self, action: str, timeout_seconds: float | None) -> None:
loop = asyncio.get_running_loop()
future: asyncio.Future[None] = loop.create_future()
await self._queue.put(
_ServerCommand(action=action, timeout_seconds=timeout_seconds, future=future)
)
await future
async def _run(self) -> None:
while True:
command = await self._queue.get()
should_exit = command.action == "cleanup"
try:
if command.action == "connect":
await _run_with_timeout_in_task(self._server.connect, command.timeout_seconds)
elif command.action == "cleanup":
await _run_with_timeout_in_task(self._server.cleanup, command.timeout_seconds)
else:
raise ValueError(f"Unknown command: {command.action}")
if not command.future.cancelled():
command.future.set_result(None)
except BaseException as exc:
if not command.future.cancelled():
command.future.set_exception(exc)
if should_exit:
return
async def _run_with_timeout_in_task(
func: Callable[[], Awaitable[Any]], timeout_seconds: float | None
) -> None:
# Use an in-task timeout to preserve task affinity for MCP cleanup.
# asyncio.wait_for creates a new Task on Python < 3.11, which breaks
# libraries that require connect/cleanup in the same task (e.g. AnyIO cancel scopes).
if timeout_seconds is None:
await func()
return
timeout_context = getattr(asyncio, "timeout", None)
if timeout_context is not None:
async with timeout_context(timeout_seconds):
await func()
return
task = asyncio.current_task()
if task is None:
await asyncio.wait_for(func(), timeout=timeout_seconds)
return
timed_out = False
loop = asyncio.get_running_loop()
def _cancel() -> None:
nonlocal timed_out
timed_out = True
task.cancel()
handle = loop.call_later(timeout_seconds, _cancel)
try:
await func()
except asyncio.CancelledError as exc:
if timed_out:
raise asyncio.TimeoutError() from exc
raise
finally:
handle.cancel()
class MCPServerManager(AbstractAsyncContextManager["MCPServerManager"]):
"""Manage MCP server lifecycles and expose only connected servers.
Use this helper to keep MCP connect/cleanup on the same task and avoid
run failures when a server is unavailable. The manager will attempt to
connect each server and then expose the connected subset via
`active_servers`.
Basic usage:
async with MCPServerManager([server_a, server_b]) as manager:
agent = Agent(
name="Assistant",
instructions="...",
mcp_servers=manager.active_servers,
)
FastAPI lifespan example:
@asynccontextmanager
async def lifespan(app: FastAPI):
async with MCPServerManager([server_a, server_b]) as manager:
app.state.mcp_manager = manager
yield
app = FastAPI(lifespan=lifespan)
Important behaviors:
- `active_servers` only includes servers that connected successfully.
`failed_servers` holds the failures and `errors` maps servers to errors.
- `drop_failed_servers=True` removes failed servers from `active_servers`
(recommended). If False, `active_servers` will still include all servers.
- `strict=True` raises on the first connection failure. If False, failures
are recorded and the run can proceed with the remaining servers.
- `reconnect(failed_only=True)` retries failed servers and refreshes
`active_servers`.
- `connect_in_parallel=True` uses a dedicated worker task per server to
allow concurrent connects while preserving task affinity for cleanup.
"""
def __init__(
self,
servers: Iterable[MCPServer],
*,
connect_timeout_seconds: float | None = 10.0,
cleanup_timeout_seconds: float | None = 10.0,
drop_failed_servers: bool = True,
strict: bool = False,
suppress_cancelled_error: bool = True,
connect_in_parallel: bool = False,
) -> None:
self._all_servers = list(servers)
self._active_servers = list(servers)
self.connect_timeout_seconds = connect_timeout_seconds
self.cleanup_timeout_seconds = cleanup_timeout_seconds
self.drop_failed_servers = drop_failed_servers
self.strict = strict
self.suppress_cancelled_error = suppress_cancelled_error
self.connect_in_parallel = connect_in_parallel
self._workers: dict[MCPServer, _ServerWorker] = {}
self.failed_servers: list[MCPServer] = []
self._failed_server_set: set[MCPServer] = set()
self._connected_servers: set[MCPServer] = set()
self.errors: dict[MCPServer, BaseException] = {}
@property
def active_servers(self) -> list[MCPServer]:
"""Return the active MCP servers after connection attempts."""
return list(self._active_servers)
@property
def all_servers(self) -> list[MCPServer]:
"""Return all MCP servers managed by this instance."""
return list(self._all_servers)
async def __aenter__(self) -> MCPServerManager:
await self.connect_all()
return self
async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool | None:
await self.cleanup_all()
return None
async def connect_all(self) -> list[MCPServer]:
"""Connect all servers in order and return the active list."""
previous_connected_servers = set(self._connected_servers)
previous_active_servers = list(self._active_servers)
self.failed_servers = []
self._failed_server_set = set()
self.errors = {}
servers_to_connect = self._servers_to_connect(self._all_servers)
connected_servers: list[MCPServer] = []
try:
if self.connect_in_parallel:
await self._connect_all_parallel(servers_to_connect)
else:
for server in servers_to_connect:
await self._attempt_connect(server)
if server not in self._failed_server_set:
connected_servers.append(server)
except BaseException:
if self.connect_in_parallel:
await self._cleanup_servers(servers_to_connect)
else:
servers_to_cleanup = self._unique_servers(
[*connected_servers, *self.failed_servers]
)
await self._cleanup_servers(servers_to_cleanup)
if self.drop_failed_servers:
self._active_servers = [
server for server in self._all_servers if server in previous_connected_servers
]
else:
self._active_servers = previous_active_servers
raise
self._refresh_active_servers()
return self._active_servers
async def reconnect(self, *, failed_only: bool = True) -> list[MCPServer]:
"""Reconnect servers and return the active list.
Args:
failed_only: If True, only retry servers that previously failed.
If False, cleanup and retry all servers.
"""
if failed_only:
servers_to_retry = self._unique_servers(self.failed_servers)
else:
await self.cleanup_all()
servers_to_retry = list(self._all_servers)
self.failed_servers = []
self._failed_server_set = set()
self.errors = {}
servers_to_retry = self._servers_to_connect(servers_to_retry)
try:
if self.connect_in_parallel:
await self._connect_all_parallel(servers_to_retry)
else:
for server in servers_to_retry:
await self._attempt_connect(server)
finally:
self._refresh_active_servers()
return self._active_servers
async def cleanup_all(self) -> None:
"""Cleanup all servers in reverse order."""
for server in reversed(self._all_servers):
try:
await self._cleanup_server(server)
except asyncio.CancelledError as exc:
if not self.suppress_cancelled_error:
raise
logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc)
self.errors[server] = exc
except Exception as exc:
logger.exception("Failed to cleanup MCP server '%s': %s", server.name, exc)
self.errors[server] = exc
async def _run_with_timeout(
self, func: Callable[[], Awaitable[Any]], timeout_seconds: float | None
) -> None:
await _run_with_timeout_in_task(func, timeout_seconds)
async def _attempt_connect(
self, server: MCPServer, *, raise_on_error: bool | None = None
) -> None:
if raise_on_error is None:
raise_on_error = self.strict
try:
await self._run_connect(server)
self._connected_servers.add(server)
if server in self.failed_servers:
self._remove_failed_server(server)
self.errors.pop(server, None)
except asyncio.CancelledError as exc:
if not self.suppress_cancelled_error:
raise
self._record_failure(server, exc, phase="connect")
except Exception as exc:
self._record_failure(server, exc, phase="connect")
if raise_on_error:
raise
except BaseException as exc:
self._record_failure(server, exc, phase="connect")
raise
def _refresh_active_servers(self) -> None:
if self.drop_failed_servers:
failed = set(self._failed_server_set)
self._active_servers = [server for server in self._all_servers if server not in failed]
else:
self._active_servers = list(self._all_servers)
def _record_failure(self, server: MCPServer, exc: BaseException, phase: str) -> None:
logger.exception("Failed to %s MCP server '%s': %s", phase, server.name, exc)
if server not in self._failed_server_set:
self.failed_servers.append(server)
self._failed_server_set.add(server)
self.errors[server] = exc
async def _run_connect(self, server: MCPServer) -> None:
if self.connect_in_parallel:
worker = self._get_worker(server)
await worker.connect()
else:
await self._run_with_timeout(server.connect, self.connect_timeout_seconds)
async def _cleanup_server(self, server: MCPServer) -> None:
if self.connect_in_parallel and server in self._workers:
worker = self._workers[server]
if worker.is_done:
self._workers.pop(server, None)
self._connected_servers.discard(server)
return
try:
await worker.cleanup()
finally:
self._workers.pop(server, None)
self._connected_servers.discard(server)
return
try:
await self._run_with_timeout(server.cleanup, self.cleanup_timeout_seconds)
finally:
self._connected_servers.discard(server)
async def _cleanup_servers(self, servers: Iterable[MCPServer]) -> None:
for server in reversed(list(servers)):
try:
await self._cleanup_server(server)
except asyncio.CancelledError as exc:
if not self.suppress_cancelled_error:
raise
logger.debug("Cleanup cancelled for MCP server '%s': %s", server.name, exc)
self.errors[server] = exc
except Exception as exc:
logger.exception("Failed to cleanup MCP server '%s': %s", server.name, exc)
self.errors[server] = exc
async def _connect_all_parallel(self, servers: list[MCPServer]) -> None:
tasks = [
asyncio.create_task(self._attempt_connect(server, raise_on_error=False))
for server in servers
]
results = await asyncio.gather(*tasks, return_exceptions=True)
if not self.suppress_cancelled_error:
for result in results:
if isinstance(result, asyncio.CancelledError):
raise result
for result in results:
if isinstance(result, BaseException) and not isinstance(result, asyncio.CancelledError):
raise result
if self.strict and self.failed_servers:
first_failure = None
if self.suppress_cancelled_error:
for server in self.failed_servers:
error = self.errors.get(server)
if error is None or isinstance(error, asyncio.CancelledError):
continue
first_failure = server
break
else:
first_failure = self.failed_servers[0]
if first_failure is not None:
error = self.errors.get(first_failure)
if error is not None:
raise error
raise RuntimeError(f"Failed to connect MCP server '{first_failure.name}'")
def _get_worker(self, server: MCPServer) -> _ServerWorker:
worker = self._workers.get(server)
if worker is None or worker.is_done:
worker = _ServerWorker(
server=server,
connect_timeout_seconds=self.connect_timeout_seconds,
cleanup_timeout_seconds=self.cleanup_timeout_seconds,
)
self._workers[server] = worker
return worker
def _remove_failed_server(self, server: MCPServer) -> None:
if server in self._failed_server_set:
self._failed_server_set.remove(server)
self.failed_servers = [
failed_server for failed_server in self.failed_servers if failed_server != server
]
def _servers_to_connect(self, servers: Iterable[MCPServer]) -> list[MCPServer]:
unique = self._unique_servers(servers)
if not self._connected_servers:
return unique
return [server for server in unique if server not in self._connected_servers]
@staticmethod
def _unique_servers(servers: Iterable[MCPServer]) -> list[MCPServer]:
seen: set[MCPServer] = set()
unique: list[MCPServer] = []
for server in servers:
if server not in seen:
seen.add(server)
unique.append(server)
return unique