ForcePilot/backend/package/yuxi/services/mcp_service.py
2026-03-24 11:09:39 +08:00

637 lines
21 KiB
Python

"""MCP Service - Unified business logic and state management for MCP.
Responsibilities:
- Server configuration CRUD operations
- Configuration synchronization (Database <-> Cache)
- Unified entry point for Agent tool retrieval (auto-filtering disabled_tools)
- MCP Client and Tools management (formerly in agents/common/mcp.py)
"""
import asyncio
import re
import traceback
from collections.abc import Callable
from typing import Any, cast
from langchain_mcp_adapters.client import MultiServerMCPClient
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from yuxi.storage.postgres.models_business import MCPServer
from yuxi.utils import logger
# =============================================================================
# === Global Cache & State ===
# =============================================================================
# Global Lock for MCP state
_mcp_lock = asyncio.Lock()
# Global MCP tools cache
_mcp_tools_cache: dict[str, list[Callable[..., Any]]] = {}
# MCP tools statistics (for reporting enabled/disabled counts)
_mcp_tools_stats: dict[str, dict[str, int]] = {}
# MCP Server configurations (Runtime cache, loaded from DB)
MCP_SERVERS: dict[str, dict[str, Any]] = {}
_UNSET = object()
# Default MCP Server configurations (Imported to DB on first run)
_DEFAULT_MCP_SERVERS = {
"sequentialthinking": {
"url": "https://remote.mcpservers.org/sequentialthinking/mcp",
"transport": "streamable_http",
"description": "顺序思考工具,帮助 AI 将复杂问题分解为多个步骤",
"icon": "🧠",
"tags": ["内置", "AI"],
},
"mcp-server-chart": {
"command": "npx",
"args": ["-y", "@antv/mcp-server-chart"],
"transport": "stdio",
"description": "图表生成工具,支持生成各类图表(柱状图、折线图、饼图等)",
"icon": "📊",
"tags": ["内置", "图表"],
},
}
# =============================================================================
# === Core Logic (Moved from agents/common/mcp.py) ===
# =============================================================================
async def load_mcp_servers_from_db() -> None:
"""Load all enabled MCP server configurations from database to MCP_SERVERS cache."""
global MCP_SERVERS
# Delayed import to avoid circular references
from yuxi.storage.postgres.manager import pg_manager
try:
async with pg_manager.get_async_session_context() as session:
result = await session.execute(select(MCPServer).filter(MCPServer.enabled == 1))
servers = result.scalars().all()
async with _mcp_lock:
MCP_SERVERS.clear()
for server in servers:
MCP_SERVERS[server.name] = server.to_mcp_config()
logger.info(f"Loaded {len(MCP_SERVERS)} MCP servers from database: {list(MCP_SERVERS.keys())}")
except Exception as e:
logger.error(f"Failed to load MCP servers from database: {e}")
async def sync_mcp_server_to_cache(name: str, config: dict[str, Any] | None) -> None:
"""Sync a single MCP server configuration to cache.
Args:
name: Server name
config: Server configuration, or None to remove from cache
"""
global MCP_SERVERS
async with _mcp_lock:
if config is None:
MCP_SERVERS.pop(name, None)
logger.info(f"Removed MCP server '{name}' from cache")
else:
MCP_SERVERS[name] = config
logger.info(f"Synced MCP server '{name}' to cache")
# Clear tools cache for this server
_mcp_tools_cache.pop(name, None)
async def init_mcp_servers() -> None:
"""Initialize MCP server configurations.
On first run, if database is empty, import default configurations.
Then load configurations from database to MCP_SERVERS cache.
Also ensures all built-in MCP servers are present in the database.
"""
# Delayed import to avoid circular references
from yuxi.storage.postgres.manager import pg_manager
try:
async with pg_manager.get_async_session_context() as session:
# Check if database has MCP configurations
result = await session.execute(select(func.count(MCPServer.name)))
count = result.scalar()
if count == 0:
# Database is empty, import default configurations
logger.info("No MCP servers in database, importing default configurations...")
for name, config in _DEFAULT_MCP_SERVERS.items():
server = MCPServer(
name=name,
description=config.get("description"),
transport=config["transport"],
url=config.get("url"),
command=config.get("command"),
args=config.get("args"),
env=config.get("env"),
headers=config.get("headers"),
timeout=config.get("timeout"),
sse_read_timeout=config.get("sse_read_timeout"),
tags=config.get("tags"),
icon=config.get("icon"),
enabled=1,
created_by="system",
updated_by="system",
)
session.add(server)
await session.commit()
logger.info(f"Imported {len(_DEFAULT_MCP_SERVERS)} default MCP servers to database")
else:
# Ensure all built-in MCP servers exist in database
for name, config in _DEFAULT_MCP_SERVERS.items():
result = await session.execute(select(MCPServer).filter(MCPServer.name == name))
existing = result.scalar_one_or_none()
if not existing:
server = MCPServer(
name=name,
description=config.get("description"),
transport=config["transport"],
url=config.get("url"),
command=config.get("command"),
args=config.get("args"),
env=config.get("env"),
headers=config.get("headers"),
timeout=config.get("timeout"),
sse_read_timeout=config.get("sse_read_timeout"),
tags=config.get("tags"),
icon=config.get("icon"),
enabled=1,
created_by="system",
updated_by="system",
)
session.add(server)
logger.info(f"Added built-in MCP server '{name}' to database")
# Commit if any new servers were added (check session state)
if session.new:
await session.commit()
# Load configurations from database to cache
await load_mcp_servers_from_db()
except Exception as e:
logger.error(f"Failed to initialize MCP servers: {e}, traceback: {traceback.format_exc()}")
async def get_mcp_client(
server_configs: dict[str, Any] | None = None,
) -> MultiServerMCPClient | None:
"""Initializes an MCP client with the given server configurations."""
try:
client = MultiServerMCPClient(server_configs) # pyright: ignore[reportArgumentType]
logger.info(f"Initialized MCP client with servers: {list(server_configs.keys())}")
return client
except Exception as e:
logger.error("Failed to initialize MCP client: {}", e)
return None
def to_camel_case(s: str) -> str:
"""Convert string to lowerCamelCase."""
# Handle - and _
s = re.sub(r"[-_]+(.)", lambda m: m.group(1).upper(), s)
# Lowercase first letter
if len(s) > 0:
s = s[0].lower() + s[1:]
return s
async def get_mcp_tools(
server_name: str,
additional_servers: dict[str, dict] = None,
disabled_tools: list[str] = None,
cache: bool = True,
force_refresh: bool = False,
raise_on_error: bool = False,
) -> list[Callable[..., Any]]:
"""Get MCP tools for a specific server.
Architecture:
1. Fetching: Connects to MCP server to get ALL tools.
2. Caching: Stores the FULL, UNFILTERED list of tools in `_mcp_tools_cache`.
3. Filtering: Filters the return value based on `disabled_tools` argument.
Args:
server_name: Server name
additional_servers: Additional server configurations
disabled_tools: List of tool names to filter out from the RETURN value (does not affect cache)
cache: Whether to use/update the cache (default: True)
force_refresh: Whether to force a refresh from the server (default: False)
"""
global _mcp_tools_cache
# 1. Prepare Server Config
async with _mcp_lock:
mcp_servers = MCP_SERVERS | (additional_servers or {})
all_processed_tools = []
# 2. Check Cache / Fetch Strategy
# If we have it in cache and don't need to force refresh, use cache.
if not force_refresh and cache and server_name in _mcp_tools_cache:
all_processed_tools = _mcp_tools_cache[server_name]
else:
# Need to fetch from server
try:
assert server_name in mcp_servers, f"Server {server_name} not found in ({list(mcp_servers.keys())})"
# Extract connection config
server_config = mcp_servers[server_name]
client_config = {k: v for k, v in server_config.items() if k not in ("disabled_tools",)}
client = await get_mcp_client({server_name: client_config})
if client is None:
return []
# Get ALL tools (Raw)
raw_tools = cast(list[Any], await client.get_tools())
# Render IDs for ALL tools
server_cc = to_camel_case(server_name)
for tool in raw_tools:
# Render unique ID rule: mcp__[camelCaseServer]__[camelCaseTool]
original_name = tool.name
tool_cc = to_camel_case(original_name)
unique_id = f"mcp__{server_cc}__{tool_cc}"
# Use metadata to store
if tool.metadata is None:
tool.metadata = {}
tool.metadata["id"] = unique_id
all_processed_tools.append(tool)
# Update Cache (Store the FULL list)
if cache:
_mcp_tools_cache[server_name] = all_processed_tools
# Update Stats
# Stats should reflect the GLOBAL configuration state
# (How many are disabled in the stored config, not the transient arg)
global_config_disabled = mcp_servers.get(server_name, {}).get("disabled_tools") or []
enabled_count = len([t for t in all_processed_tools if t.name not in global_config_disabled])
_mcp_tools_stats[server_name] = {
"total": len(all_processed_tools),
"enabled": enabled_count,
"disabled": len(all_processed_tools) - enabled_count,
}
logger.info(f"Refreshed MCP tools cache for '{server_name}': {len(all_processed_tools)} tools loaded.")
except AssertionError as e:
logger.warning(f"[assert] Failed to load tools from MCP server '{server_name}': {e}")
if raise_on_error:
raise
return []
except Exception as e:
logger.error(
f"Failed to load tools from MCP server '{server_name}': {e}, traceback: {traceback.format_exc()}"
)
if raise_on_error:
raise
return []
# 3. Filtering (Apply to Return Value Only)
if disabled_tools:
filtered_tools = [t for t in all_processed_tools if t.name not in disabled_tools]
logger.debug(
f"Returning {len(filtered_tools)}/{len(all_processed_tools)} tools for '{server_name}' "
f"(filtered {len(disabled_tools)} by argument)"
)
return filtered_tools
return all_processed_tools
async def get_tools_from_all_servers() -> list[Callable[..., Any]]:
"""Get all tools from all configured MCP servers."""
all_tools = []
for server_name in MCP_SERVERS.keys():
tools = await get_mcp_tools(server_name)
all_tools.extend(tools)
return all_tools
def add_mcp_server(name: str, config: dict[str, Any]) -> None:
"""Add a new MCP server configuration."""
MCP_SERVERS[name] = config
# Clear client to force reinitialization with new config
clear_mcp_cache()
def clear_mcp_cache() -> None:
"""Clear the MCP tools cache (useful for testing)."""
global _mcp_tools_cache, _mcp_tools_stats
_mcp_tools_cache = {}
_mcp_tools_stats = {}
def clear_mcp_server_tools_cache(server_name: str) -> None:
"""Clear the tools cache for a specific MCP server."""
global _mcp_tools_cache, _mcp_tools_stats
_mcp_tools_cache.pop(server_name, None)
_mcp_tools_stats.pop(server_name, None)
logger.info(f"Cleared tools cache for MCP server '{server_name}'")
def get_mcp_tools_stats(server_name: str) -> dict[str, int] | None:
"""Get tools statistics for a MCP server.
Returns:
dict with 'total', 'enabled', 'disabled' counts, or None if not available
"""
return _mcp_tools_stats.get(server_name)
# =============================================================================
# === Server Config CRUD (Existing in mcp_service.py) ===
# =============================================================================
async def get_mcp_server(db: AsyncSession, name: str) -> MCPServer | None:
"""Get single server configuration."""
result = await db.execute(select(MCPServer).filter(MCPServer.name == name))
return result.scalar_one_or_none()
async def get_all_mcp_servers(db: AsyncSession) -> list[MCPServer]:
"""Get all server configurations."""
result = await db.execute(select(MCPServer))
return list(result.scalars().all())
async def create_mcp_server(
db: AsyncSession,
name: str,
transport: str,
url: str = None,
command: str = None,
args: list = None,
env: dict = None,
description: str = None,
headers: dict = None,
timeout: int = None,
sse_read_timeout: int = None,
tags: list = None,
icon: str = None,
created_by: str = None,
) -> MCPServer:
"""Create server."""
# Check if name exists
existing = await get_mcp_server(db, name)
if existing:
raise ValueError(f"Server name '{name}' already exists")
server = MCPServer(
name=name,
description=description,
transport=transport,
url=url,
command=command,
args=args,
env=env,
headers=headers,
timeout=timeout,
sse_read_timeout=sse_read_timeout,
tags=tags,
icon=icon,
enabled=1,
created_by=created_by,
updated_by=created_by,
)
db.add(server)
await db.commit()
await db.refresh(server)
# Sync to cache
await sync_mcp_server_to_cache(name, server.to_mcp_config())
logger.info(f"Created MCP server '{name}'")
return server
async def update_mcp_server(
db: AsyncSession,
name: str,
description: str = None,
transport: str = None,
url: str = None,
command: str = None,
args: list = None,
env: Any = _UNSET,
headers: dict = None,
timeout: int = None,
sse_read_timeout: int = None,
tags: list = None,
icon: str = None,
updated_by: str = None,
) -> MCPServer:
"""Update server configuration."""
server = await get_mcp_server(db, name)
if not server:
raise ValueError(f"Server '{name}' does not exist")
if description is not None:
server.description = description
if transport is not None:
server.transport = transport
if url is not None:
server.url = url
if command is not None:
server.command = command
if args is not None:
server.args = args
if env is not _UNSET:
server.env = env
if headers is not None:
server.headers = headers
if timeout is not None:
server.timeout = timeout
if sse_read_timeout is not None:
server.sse_read_timeout = sse_read_timeout
if tags is not None:
server.tags = tags
if icon is not None:
server.icon = icon
if updated_by is not None:
server.updated_by = updated_by
await db.commit()
await db.refresh(server)
# Sync to cache (if enabled)
if server.enabled:
await sync_mcp_server_to_cache(name, server.to_mcp_config())
logger.info(f"Updated MCP server '{name}'")
return server
async def delete_mcp_server(db: AsyncSession, name: str) -> bool:
"""Delete server."""
server = await get_mcp_server(db, name)
if not server:
return False
await db.delete(server)
await db.commit()
# Remove from cache
await sync_mcp_server_to_cache(name, None)
logger.info(f"Deleted MCP server '{name}'")
return True
# =============================================================================
# === Tool Management ===
# =============================================================================
async def toggle_server_enabled(db: AsyncSession, name: str, updated_by: str = None) -> tuple[bool, MCPServer]:
"""Toggle server enabled status."""
server = await get_mcp_server(db, name)
if not server:
raise ValueError(f"Server '{name}' does not exist")
server.enabled = 0 if server.enabled else 1
if updated_by is not None:
server.updated_by = updated_by
await db.commit()
# Sync to cache
is_enabled = bool(server.enabled)
server_config = server.to_mcp_config() if is_enabled else None
await sync_mcp_server_to_cache(name, server_config)
logger.info(f"Toggled MCP server '{name}' enabled={is_enabled}")
return is_enabled, server
async def toggle_tool_enabled(
db: AsyncSession,
server_name: str,
tool_name: str,
updated_by: str = None,
) -> tuple[bool, MCPServer]:
"""Toggle single tool enabled status.
Args:
db: Database session
server_name: Server name
tool_name: Tool name
updated_by: Updater
Returns:
(enabled, server): Tool enabled status and updated server object
"""
server = await get_mcp_server(db, server_name)
if not server:
raise ValueError(f"Server '{server_name}' does not exist")
disabled_tools = list(server.disabled_tools or [])
if tool_name in disabled_tools:
disabled_tools.remove(tool_name)
enabled = True
else:
disabled_tools.append(tool_name)
enabled = False
server.disabled_tools = disabled_tools
if updated_by is not None:
server.updated_by = updated_by
await db.commit()
# Clear tool cache (re-filtered on next fetch)
clear_mcp_server_tools_cache(server_name)
logger.info(f"Toggled tool '{tool_name}' for server '{server_name}' enabled={enabled}")
return enabled, server
# =============================================================================
# === Unified Entry Points (Wrappers) ===
# =============================================================================
def get_mcp_server_names() -> list[str]:
"""Get list of loaded MCP server names.
Returns a copy of keys to avoid runtime modification issues during iteration.
"""
return list(MCP_SERVERS.keys())
async def get_enabled_mcp_tools(server_name: str) -> list:
"""Get MCP server tools (auto-filtering disabled_tools).
Unified entry point for Agents, automatically:
1. Gets server config from cache
2. Gets all tools
3. Filters out disabled_tools
Args:
server_name: Server name
Returns:
List of enabled tools
"""
config = MCP_SERVERS.get(server_name)
if not config:
logger.warning(f"MCP server '{server_name}' not found in cache")
return []
disabled_tools = config.get("disabled_tools") or []
return await get_mcp_tools(server_name, disabled_tools=disabled_tools)
async def get_servers_config(names: list[str]) -> dict[str, dict[str, Any]]:
"""Batch get server configurations.
Args:
names: List of server names
Returns:
{name: config} dictionary, containing only found servers
"""
return {name: MCP_SERVERS[name] for name in names if name in MCP_SERVERS}
async def get_all_mcp_tools(server_name: str, raise_on_error: bool = False) -> list:
"""Get all tools of an MCP server (no filtering).
For management UI to display tool list, supports viewing all tools and their enabled status.
Does NOT update the global tools cache to avoid polluting agent's filtered view.
Args:
server_name: Server name
raise_on_error: Whether to raise an exception on error instead of returning empty list
Returns:
List of all tools (unfiltered)
"""
config = MCP_SERVERS.get(server_name)
if not config:
logger.warning(f"MCP server '{server_name}' not found in cache")
if raise_on_error:
raise ValueError(f"MCP server '{server_name}' not found in cache")
return []
# Get all tools (no filtering, force refresh, no cache update)
return await get_mcp_tools(
server_name,
disabled_tools=[],
cache=False,
force_refresh=True,
raise_on_error=raise_on_error,
)