mirror of
https://github.com/HKUDS/OpenSpace.git
synced 2026-08-28 05:15:00 +00:00
406 lines
16 KiB
Python
406 lines
16 KiB
Python
"""
|
|
Client for managing MCP servers and sessions.
|
|
|
|
This module provides a high-level client that manages MCP servers, connectors,
|
|
and sessions from configuration.
|
|
"""
|
|
import asyncio
|
|
import warnings
|
|
from typing import Any, Optional
|
|
|
|
from openspace.grounding.core.types import SandboxOptions
|
|
from openspace.config.utils import get_config_value, save_json_file, load_json_file
|
|
from .config import create_connector_from_config
|
|
from .session import MCPSession
|
|
from .installer import MCPInstallerManager, MCPDependencyError
|
|
|
|
from openspace.utils.logging import Logger
|
|
|
|
logger = Logger.get_logger(__name__)
|
|
|
|
|
|
def _normalize_client_config(config: dict[str, Any]) -> dict[str, Any]:
|
|
normalized = dict(config)
|
|
external_servers = normalized.pop("mcpServers", None)
|
|
if "servers" not in normalized and isinstance(external_servers, dict):
|
|
normalized["servers"] = external_servers
|
|
return normalized
|
|
|
|
|
|
class MCPClient:
|
|
"""Client for managing MCP servers and sessions.
|
|
|
|
This class provides a unified interface for working with MCP servers,
|
|
handling configuration, connector creation, and session management.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: str | dict[str, Any] | None = None,
|
|
sandbox: bool = False,
|
|
sandbox_options: SandboxOptions | None = None,
|
|
timeout: float = 30.0,
|
|
sse_read_timeout: float = 300.0,
|
|
max_retries: int = 3,
|
|
retry_interval: float = 2.0,
|
|
installer: Optional[MCPInstallerManager] = None,
|
|
check_dependencies: bool = True,
|
|
tool_call_max_retries: int = 3,
|
|
tool_call_retry_delay: float = 1.0,
|
|
) -> None:
|
|
"""Initialize a new MCP client.
|
|
|
|
Args:
|
|
config: Either a dict containing configuration or a path to a JSON config file.
|
|
If None, an empty configuration is used.
|
|
sandbox: Whether to use sandboxed execution mode for running MCP servers.
|
|
sandbox_options: Optional sandbox configuration options.
|
|
timeout: Timeout for operations in seconds (default: 30.0)
|
|
sse_read_timeout: SSE read timeout in seconds (default: 300.0)
|
|
max_retries: Maximum number of retry attempts for failed operations (default: 3)
|
|
retry_interval: Wait time between retries in seconds (default: 2.0)
|
|
installer: Optional installer manager for dependency installation
|
|
check_dependencies: Whether to check and install dependencies (default: True)
|
|
tool_call_max_retries: Maximum number of retries for tool calls (default: 3)
|
|
tool_call_retry_delay: Initial delay between tool call retries in seconds (default: 1.0)
|
|
"""
|
|
self.config: dict[str, Any] = {}
|
|
self.sandbox = sandbox
|
|
self.sandbox_options = sandbox_options
|
|
self.timeout = timeout
|
|
self.sse_read_timeout = sse_read_timeout
|
|
self.max_retries = max_retries
|
|
self.retry_interval = retry_interval
|
|
self.installer = installer
|
|
self.check_dependencies = check_dependencies
|
|
self.tool_call_max_retries = tool_call_max_retries
|
|
self.tool_call_retry_delay = tool_call_retry_delay
|
|
self.sessions: dict[str, MCPSession] = {}
|
|
self.active_sessions: list[str] = []
|
|
|
|
# Load configuration if provided
|
|
if config is not None:
|
|
if isinstance(config, str):
|
|
self.config = _normalize_client_config(load_json_file(config))
|
|
else:
|
|
self.config = _normalize_client_config(config)
|
|
|
|
def _get_mcp_servers(self) -> dict[str, Any]:
|
|
"""Return the internal MCP server configuration."""
|
|
|
|
servers = get_config_value(self.config, "servers", {})
|
|
return servers or {}
|
|
|
|
@classmethod
|
|
def from_dict(
|
|
cls,
|
|
config: dict[str, Any],
|
|
sandbox: bool = False,
|
|
sandbox_options: SandboxOptions | None = None,
|
|
timeout: float = 30.0,
|
|
sse_read_timeout: float = 300.0,
|
|
max_retries: int = 3,
|
|
retry_interval: float = 2.0,
|
|
) -> "MCPClient":
|
|
"""Create a MCPClient from a dictionary.
|
|
|
|
Args:
|
|
config: The configuration dictionary.
|
|
sandbox: Whether to use sandboxed execution mode for running MCP servers.
|
|
sandbox_options: Optional sandbox configuration options.
|
|
timeout: Timeout for operations in seconds (default: 30.0)
|
|
sse_read_timeout: SSE read timeout in seconds (default: 300.0)
|
|
max_retries: Maximum number of retry attempts (default: 3)
|
|
retry_interval: Wait time between retries in seconds (default: 2.0)
|
|
"""
|
|
return cls(config=config, sandbox=sandbox, sandbox_options=sandbox_options,
|
|
timeout=timeout, sse_read_timeout=sse_read_timeout,
|
|
max_retries=max_retries, retry_interval=retry_interval)
|
|
|
|
@classmethod
|
|
def from_config_file(
|
|
cls, filepath: str, sandbox: bool = False, sandbox_options: SandboxOptions | None = None,
|
|
timeout: float = 30.0, sse_read_timeout: float = 300.0,
|
|
max_retries: int = 3, retry_interval: float = 2.0,
|
|
) -> "MCPClient":
|
|
"""Create a MCPClient from a configuration file.
|
|
|
|
Args:
|
|
filepath: The path to the configuration file.
|
|
sandbox: Whether to use sandboxed execution mode for running MCP servers.
|
|
sandbox_options: Optional sandbox configuration options.
|
|
timeout: Timeout for operations in seconds (default: 30.0)
|
|
sse_read_timeout: SSE read timeout in seconds (default: 300.0)
|
|
max_retries: Maximum number of retry attempts (default: 3)
|
|
retry_interval: Wait time between retries in seconds (default: 2.0)
|
|
"""
|
|
return cls(config=load_json_file(filepath), sandbox=sandbox, sandbox_options=sandbox_options,
|
|
timeout=timeout, sse_read_timeout=sse_read_timeout,
|
|
max_retries=max_retries, retry_interval=retry_interval)
|
|
|
|
def add_server(
|
|
self,
|
|
name: str,
|
|
server_config: dict[str, Any],
|
|
) -> None:
|
|
"""Add a server configuration.
|
|
|
|
Args:
|
|
name: The name to identify this server.
|
|
server_config: The server configuration.
|
|
"""
|
|
if "servers" not in self.config:
|
|
self.config["servers"] = {}
|
|
|
|
self.config["servers"][name] = server_config
|
|
logger.debug(f"Added MCP server configuration: {name}")
|
|
|
|
def remove_server(self, name: str) -> None:
|
|
"""Remove a server configuration.
|
|
|
|
Args:
|
|
name: The name of the server to remove.
|
|
"""
|
|
mcp_servers = self._get_mcp_servers()
|
|
if name in mcp_servers:
|
|
if "servers" in self.config:
|
|
self.config["servers"].pop(name, None)
|
|
|
|
# If we removed an active session, remove it from active_sessions
|
|
if name in self.active_sessions:
|
|
self.active_sessions.remove(name)
|
|
|
|
logger.debug(f"Removed MCP server configuration: {name}")
|
|
else:
|
|
logger.warning(f"Server '{name}' not found in configuration")
|
|
|
|
def get_server_names(self) -> list[str]:
|
|
"""Get the list of configured server names.
|
|
|
|
Returns:
|
|
List of server names.
|
|
"""
|
|
return list(self._get_mcp_servers().keys())
|
|
|
|
def save_config(self, filepath: str) -> None:
|
|
"""Save the current configuration to a file.
|
|
|
|
Args:
|
|
filepath: The path to save the configuration to.
|
|
"""
|
|
save_json_file(self.config, filepath)
|
|
|
|
async def create_session(self, server_name: str, auto_initialize: bool = True) -> MCPSession:
|
|
"""Create a session for the specified server with retry logic.
|
|
|
|
Args:
|
|
server_name: The name of the server to create a session for.
|
|
auto_initialize: Whether to automatically initialize the session.
|
|
|
|
Returns:
|
|
The created MCPSession.
|
|
|
|
Raises:
|
|
ValueError: If the specified server doesn't exist.
|
|
Exception: If session creation fails after all retries.
|
|
"""
|
|
# Check if session already exists
|
|
if server_name in self.sessions:
|
|
logger.debug(f"Session for server '{server_name}' already exists, returning existing session")
|
|
return self.sessions[server_name]
|
|
|
|
# Get server config
|
|
servers = self._get_mcp_servers()
|
|
|
|
if not servers:
|
|
warnings.warn("No MCP servers defined in config", UserWarning, stacklevel=2)
|
|
return None
|
|
|
|
if server_name not in servers:
|
|
raise ValueError(f"Server '{server_name}' not found in config. Available: {list(servers.keys())}")
|
|
|
|
server_config = servers[server_name]
|
|
|
|
# Retry logic for session creation
|
|
last_exc: Exception | None = None
|
|
|
|
for attempt in range(1, self.max_retries + 1):
|
|
try:
|
|
# Create connector with options (now async)
|
|
connector = await create_connector_from_config(
|
|
server_config,
|
|
server_name=server_name,
|
|
sandbox=self.sandbox,
|
|
sandbox_options=self.sandbox_options,
|
|
timeout=self.timeout,
|
|
sse_read_timeout=self.sse_read_timeout,
|
|
installer=self.installer,
|
|
check_dependencies=self.check_dependencies,
|
|
tool_call_max_retries=self.tool_call_max_retries,
|
|
tool_call_retry_delay=self.tool_call_retry_delay,
|
|
)
|
|
|
|
# Create the session with proper initialization parameters
|
|
session = MCPSession(
|
|
connector=connector,
|
|
session_id=f"mcp-{server_name}",
|
|
auto_connect=True,
|
|
auto_initialize=False, # We'll handle initialization explicitly below
|
|
)
|
|
|
|
# Initialize if requested
|
|
if auto_initialize:
|
|
await session.initialize()
|
|
logger.debug(f"Initialized session for server '{server_name}'")
|
|
|
|
# Store session
|
|
self.sessions[server_name] = session
|
|
|
|
# Add to active sessions
|
|
if server_name not in self.active_sessions:
|
|
self.active_sessions.append(server_name)
|
|
|
|
logger.info(f"Created session for MCP server '{server_name}' (attempt {attempt}/{self.max_retries})")
|
|
return session
|
|
|
|
except MCPDependencyError as e:
|
|
# Don't retry dependency errors - they won't succeed on retry
|
|
# Error already shown to user by installer, just re-raise
|
|
logger.debug(f"Dependency error for server '{server_name}': {type(e).__name__}")
|
|
raise
|
|
except Exception as e:
|
|
last_exc = e
|
|
if attempt == self.max_retries:
|
|
break
|
|
|
|
# Use info level for first attempt (common after fresh install), warning for subsequent
|
|
log_level = logger.info if attempt == 1 else logger.warning
|
|
log_level(
|
|
f"Failed to create session for server '{server_name}' (attempt {attempt}/{self.max_retries}): {e}, "
|
|
f"retrying in {self.retry_interval} seconds..."
|
|
)
|
|
await asyncio.sleep(self.retry_interval)
|
|
|
|
# All retries failed
|
|
error_msg = f"Failed to create session for server '{server_name}' after {self.max_retries} retries"
|
|
logger.error(error_msg)
|
|
raise last_exc or RuntimeError(error_msg)
|
|
|
|
async def create_all_sessions(
|
|
self,
|
|
auto_initialize: bool = True,
|
|
) -> dict[str, MCPSession]:
|
|
"""Create sessions for all configured servers.
|
|
|
|
Args:
|
|
auto_initialize: Whether to automatically initialize the sessions.
|
|
|
|
Returns:
|
|
Dictionary mapping server names to their MCPSession instances.
|
|
|
|
Warns:
|
|
UserWarning: If no servers are configured.
|
|
"""
|
|
servers = self._get_mcp_servers()
|
|
|
|
if not servers:
|
|
warnings.warn("No MCP servers defined in config", UserWarning, stacklevel=2)
|
|
return {}
|
|
|
|
# Create sessions for all servers (create_session already handles initialization)
|
|
logger.debug(f"Creating sessions for {len(servers)} servers")
|
|
for name in servers:
|
|
try:
|
|
await self.create_session(name, auto_initialize)
|
|
except Exception as e:
|
|
logger.error(f"Failed to create session for server '{name}': {e}")
|
|
|
|
logger.info(f"Created {len(self.sessions)} MCP sessions")
|
|
return self.sessions
|
|
|
|
def get_session(self, server_name: str) -> MCPSession:
|
|
"""Get an existing session.
|
|
|
|
Args:
|
|
server_name: The name of the server to get the session for.
|
|
If None, uses the first active session.
|
|
|
|
Returns:
|
|
The MCPSession for the specified server.
|
|
|
|
Raises:
|
|
ValueError: If no active sessions exist or the specified session doesn't exist.
|
|
"""
|
|
if server_name not in self.sessions:
|
|
raise ValueError(f"No session exists for server '{server_name}'")
|
|
|
|
return self.sessions[server_name]
|
|
|
|
def get_all_active_sessions(self) -> dict[str, MCPSession]:
|
|
"""Get all active sessions.
|
|
|
|
Returns:
|
|
Dictionary mapping server names to their MCPSession instances.
|
|
"""
|
|
return {name: self.sessions[name] for name in self.active_sessions if name in self.sessions}
|
|
|
|
async def close_session(self, server_name: str) -> None:
|
|
"""Close a session.
|
|
|
|
Args:
|
|
server_name: The name of the server to close the session for.
|
|
|
|
Raises:
|
|
ValueError: If no active sessions exist or the specified session doesn't exist.
|
|
"""
|
|
# Check if the session exists
|
|
if server_name not in self.sessions:
|
|
logger.warning(f"No session exists for server '{server_name}', nothing to close")
|
|
return
|
|
|
|
# Get the session
|
|
session = self.sessions[server_name]
|
|
error_occurred = False
|
|
|
|
try:
|
|
# Disconnect from the session
|
|
logger.debug(f"Closing session for server '{server_name}'")
|
|
await session.disconnect()
|
|
logger.info(f"Successfully closed session for server '{server_name}'")
|
|
except Exception as e:
|
|
error_occurred = True
|
|
logger.error(f"Error closing session for server '{server_name}': {e}")
|
|
finally:
|
|
# Remove the session regardless of whether disconnect succeeded
|
|
self.sessions.pop(server_name, None)
|
|
|
|
# Remove from active_sessions
|
|
if server_name in self.active_sessions:
|
|
self.active_sessions.remove(server_name)
|
|
|
|
if error_occurred:
|
|
logger.warning(f"Session for '{server_name}' removed from tracking despite disconnect error")
|
|
|
|
async def close_all_sessions(self) -> None:
|
|
"""Close all active sessions.
|
|
|
|
This method ensures all sessions are closed even if some fail.
|
|
"""
|
|
# Get a list of all session names first to avoid modification during iteration
|
|
server_names = list(self.sessions.keys())
|
|
errors = []
|
|
|
|
for server_name in server_names:
|
|
try:
|
|
logger.debug(f"Closing session for server '{server_name}'")
|
|
await self.close_session(server_name)
|
|
except Exception as e:
|
|
error_msg = f"Failed to close session for server '{server_name}': {e}"
|
|
logger.error(error_msg)
|
|
errors.append(error_msg)
|
|
|
|
# Log summary if there were errors
|
|
if errors:
|
|
logger.error(f"Encountered {len(errors)} errors while closing sessions")
|
|
else:
|
|
logger.debug("All sessions closed successfully")
|