416 lines
15 KiB
Python
416 lines
15 KiB
Python
"""MCP 服务器管理路由"""
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException
|
||
from pydantic import BaseModel, ConfigDict, Field
|
||
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
||
from yuxi.agents.mcp.service import (
|
||
create_mcp_server,
|
||
get_mcp_tools_stats,
|
||
delete_mcp_server,
|
||
get_all_mcp_servers,
|
||
get_all_mcp_tools,
|
||
get_mcp_server,
|
||
set_server_enabled,
|
||
toggle_tool_enabled,
|
||
update_mcp_server,
|
||
)
|
||
from yuxi.storage.postgres.models_business import User
|
||
from yuxi.utils import logger
|
||
from server.utils.auth_middleware import get_admin_user, get_db, get_required_user
|
||
|
||
mcp = APIRouter(prefix="/system/mcp-servers", tags=["mcp"])
|
||
|
||
|
||
# =============================================================================
|
||
# === DTOs ===
|
||
# =============================================================================
|
||
|
||
|
||
class CreateMcpServerRequest(BaseModel):
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
slug: str = Field(..., description="稳定标识")
|
||
name: str = Field(..., description="展示名称")
|
||
transport: str = Field(..., description="传输类型:sse/streamable_http/stdio")
|
||
url: str | None = Field(None, description="服务器 URL(sse/streamable_http)")
|
||
command: str | None = Field(None, description="命令(stdio)")
|
||
args: list | None = Field(None, description="命令参数数组(stdio)")
|
||
env: dict | None = Field(None, description="环境变量(stdio)")
|
||
description: str | None = Field(None, description="描述")
|
||
headers: dict | None = Field(None, description="HTTP 请求头")
|
||
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
|
||
sse_read_timeout: int | None = Field(None, description="SSE 读取超时(秒)")
|
||
tags: list | None = Field(None, description="标签数组")
|
||
icon: str | None = Field(None, description="图标(emoji)")
|
||
|
||
|
||
class UpdateMcpServerRequest(BaseModel):
|
||
model_config = ConfigDict(extra="forbid")
|
||
|
||
name: str | None = Field(None, description="展示名称")
|
||
transport: str | None = Field(None, description="传输类型")
|
||
url: str | None = Field(None, description="服务器 URL")
|
||
command: str | None = Field(None, description="命令(stdio)")
|
||
args: list | None = Field(None, description="命令参数数组(stdio)")
|
||
env: dict | None = Field(None, description="环境变量(stdio)")
|
||
description: str | None = Field(None, description="描述")
|
||
headers: dict | None = Field(None, description="HTTP 请求头")
|
||
timeout: int | None = Field(None, description="HTTP 超时时间(秒)")
|
||
sse_read_timeout: int | None = Field(None, description="SSE 读取超时(秒)")
|
||
tags: list | None = Field(None, description="标签数组")
|
||
icon: str | None = Field(None, description="图标(emoji)")
|
||
|
||
|
||
class UpdateMcpServerStatusRequest(BaseModel):
|
||
enabled: bool = Field(..., description="是否启用")
|
||
|
||
|
||
# =============================================================================
|
||
# === Helpers ===
|
||
# =============================================================================
|
||
|
||
|
||
async def get_server_or_404(db: AsyncSession, slug: str):
|
||
"""Helper to get server or raise 404."""
|
||
server = await get_mcp_server(db, slug)
|
||
if not server:
|
||
raise HTTPException(status_code=404, detail=f"服务器 '{slug}' 不存在")
|
||
return server
|
||
|
||
|
||
# =============================================================================
|
||
# === MCP 服务器 CRUD ===
|
||
# =============================================================================
|
||
|
||
|
||
@mcp.get("")
|
||
async def get_mcp_servers(
|
||
current_user: User = Depends(get_required_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取所有 MCP 服务器配置(普通用户仅获取脱敏的基础信息)"""
|
||
try:
|
||
servers = await get_all_mcp_servers(db)
|
||
if current_user.role in ["admin", "superadmin"]:
|
||
return {"success": True, "data": [s.to_dict() for s in servers]}
|
||
|
||
data = []
|
||
for s in servers:
|
||
data.append(
|
||
{
|
||
"name": getattr(s, "name", ""),
|
||
"description": getattr(s, "description", None),
|
||
"icon": getattr(s, "icon", None),
|
||
"enabled": bool(getattr(s, "enabled", True)),
|
||
"tags": getattr(s, "tags", None) or [],
|
||
}
|
||
)
|
||
return {"success": True, "data": data}
|
||
except Exception as e:
|
||
logger.error(f"Failed to get MCP servers: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@mcp.post("")
|
||
async def create_mcp_server_route(
|
||
request: CreateMcpServerRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""创建新的 MCP 服务器"""
|
||
# 校验传输类型
|
||
valid_transports = ("sse", "streamable_http", "stdio")
|
||
if request.transport not in valid_transports:
|
||
raise HTTPException(status_code=400, detail=f"传输类型必须是 {', '.join(valid_transports)} 之一")
|
||
|
||
# 根据传输类型校验必填字段
|
||
if request.transport in ("sse", "streamable_http") and not request.url:
|
||
raise HTTPException(status_code=400, detail=f"传输类型为 {request.transport} 时,url 必填")
|
||
if request.transport == "stdio" and not request.command:
|
||
raise HTTPException(status_code=400, detail="传输类型为 stdio 时,command 必填")
|
||
|
||
try:
|
||
server = await create_mcp_server(
|
||
db,
|
||
slug=request.slug,
|
||
name=request.name,
|
||
transport=request.transport,
|
||
url=request.url,
|
||
command=request.command,
|
||
args=request.args,
|
||
env=request.env,
|
||
description=request.description,
|
||
headers=request.headers,
|
||
timeout=request.timeout,
|
||
sse_read_timeout=request.sse_read_timeout,
|
||
tags=request.tags,
|
||
icon=request.icon,
|
||
created_by=current_user.username,
|
||
)
|
||
return {"success": True, "data": server.to_dict()}
|
||
except ValueError as ve:
|
||
raise HTTPException(status_code=400, detail=str(ve))
|
||
except Exception as e:
|
||
logger.error(f"Failed to create MCP server: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@mcp.get("/{slug}")
|
||
async def get_mcp_server_route(
|
||
slug: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取单个 MCP 服务器配置"""
|
||
try:
|
||
server = await get_server_or_404(db, slug)
|
||
return {"success": True, "data": server.to_dict()}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get MCP server: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@mcp.put("/{slug}")
|
||
async def update_mcp_server_route(
|
||
slug: str,
|
||
request: UpdateMcpServerRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""更新 MCP 服务器配置"""
|
||
# 校验传输类型
|
||
valid_transports = ("sse", "streamable_http", "stdio")
|
||
if request.transport is not None and request.transport not in valid_transports:
|
||
raise HTTPException(status_code=400, detail=f"传输类型必须是 {', '.join(valid_transports)} 之一")
|
||
|
||
try:
|
||
fields_set = request.model_fields_set
|
||
update_kwargs = {}
|
||
if "env" in fields_set:
|
||
update_kwargs["env"] = request.env
|
||
|
||
server = await update_mcp_server(
|
||
db,
|
||
slug=slug,
|
||
name=request.name,
|
||
description=request.description,
|
||
transport=request.transport,
|
||
url=request.url,
|
||
command=request.command,
|
||
args=request.args,
|
||
headers=request.headers,
|
||
timeout=request.timeout,
|
||
sse_read_timeout=request.sse_read_timeout,
|
||
tags=request.tags,
|
||
icon=request.icon,
|
||
updated_by=current_user.username,
|
||
**update_kwargs,
|
||
)
|
||
return {"success": True, "data": server.to_dict()}
|
||
except ValueError as ve:
|
||
raise HTTPException(status_code=404, detail=str(ve))
|
||
except Exception as e:
|
||
logger.error(f"Failed to update MCP server: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@mcp.delete("/{slug}")
|
||
async def delete_mcp_server_route(
|
||
slug: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""删除 MCP 服务器"""
|
||
try:
|
||
# 检查是否为系统内置服务器
|
||
server = await get_mcp_server(db, slug)
|
||
if server and server.created_by == "system":
|
||
raise HTTPException(status_code=403, detail="系统内置的 MCP 服务器无法删除")
|
||
|
||
deleted = await delete_mcp_server(db, slug)
|
||
if not deleted:
|
||
raise HTTPException(status_code=404, detail=f"服务器 '{slug}' 不存在")
|
||
return {"success": True, "message": f"服务器 '{slug}' 已删除"}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to delete MCP server: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# =============================================================================
|
||
# === MCP 服务器操作 ===
|
||
# =============================================================================
|
||
|
||
|
||
@mcp.post("/{slug}/test")
|
||
async def test_mcp_server(
|
||
slug: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""测试 MCP 服务器连接"""
|
||
try:
|
||
await get_server_or_404(db, slug)
|
||
|
||
try:
|
||
tools = await get_all_mcp_tools(slug)
|
||
return {
|
||
"success": True,
|
||
"message": f"连接成功,共发现 {len(tools)} 个工具",
|
||
"tool_count": len(tools),
|
||
}
|
||
except Exception as test_error:
|
||
raise HTTPException(status_code=500, detail=f"连接失败: {str(test_error)}")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to test MCP server: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@mcp.put("/{slug}/status")
|
||
async def update_mcp_server_status_route(
|
||
slug: str,
|
||
request: UpdateMcpServerStatusRequest,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""更新 MCP 服务器启用状态"""
|
||
try:
|
||
is_enabled, server = await set_server_enabled(db, slug, request.enabled, current_user.username)
|
||
return {
|
||
"success": True,
|
||
"enabled": is_enabled,
|
||
"data": server.to_dict(),
|
||
"message": f"MCP '{slug}' 已{'添加' if is_enabled else '移除'}",
|
||
}
|
||
except ValueError as ve:
|
||
raise HTTPException(status_code=404, detail=str(ve))
|
||
except Exception as e:
|
||
logger.error(f"Failed to toggle MCP server: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
# =============================================================================
|
||
# === MCP 工具管理 ===
|
||
# =============================================================================
|
||
|
||
|
||
@mcp.get("/{slug}/tools")
|
||
async def get_mcp_server_tools(
|
||
slug: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""获取 MCP 服务器的工具列表"""
|
||
try:
|
||
server = await get_server_or_404(db, slug)
|
||
disabled_tools = server.disabled_tools or []
|
||
|
||
try:
|
||
# 获取所有工具(不过滤 disabled_tools)
|
||
tools = await get_all_mcp_tools(slug)
|
||
tool_list = []
|
||
|
||
for tool in tools:
|
||
original_name = tool.name
|
||
unique_id = tool.metadata.get("id") if tool.metadata else original_name
|
||
|
||
tool_info = {
|
||
"name": original_name,
|
||
"id": unique_id,
|
||
"description": getattr(tool, "description", ""),
|
||
"enabled": original_name not in disabled_tools,
|
||
}
|
||
# 提取参数信息
|
||
if hasattr(tool, "args_schema") and tool.args_schema:
|
||
schema = tool.args_schema.schema() if hasattr(tool.args_schema, "schema") else {}
|
||
tool_info["parameters"] = schema.get("properties", {})
|
||
tool_info["required"] = schema.get("required", [])
|
||
else:
|
||
tool_info["parameters"] = {}
|
||
tool_info["required"] = []
|
||
tool_list.append(tool_info)
|
||
|
||
return {
|
||
"success": True,
|
||
"data": tool_list,
|
||
"total": len(tool_list),
|
||
}
|
||
except Exception as tool_error:
|
||
logger.error(f"Failed to get tools from MCP server '{slug}': {tool_error}")
|
||
raise HTTPException(status_code=500, detail=f"获取工具失败: {str(tool_error)}")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to get MCP server tools: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@mcp.post("/{slug}/tools/refresh")
|
||
async def refresh_mcp_server_tools(
|
||
slug: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""刷新 MCP 服务器的工具列表(清除缓存重新获取)"""
|
||
try:
|
||
await get_server_or_404(db, slug)
|
||
|
||
try:
|
||
# 获取所有工具(不过滤 disabled_tools)
|
||
tools = await get_all_mcp_tools(slug)
|
||
|
||
# 获取统计信息
|
||
stats = get_mcp_tools_stats(slug)
|
||
enabled_count = stats.get("enabled", len(tools)) if stats else len(tools)
|
||
disabled_count = stats.get("disabled", 0) if stats else 0
|
||
|
||
message = "工具列表已刷新"
|
||
if disabled_count > 0:
|
||
message += f",{enabled_count} 个已启用,{disabled_count} 个已禁用"
|
||
else:
|
||
message += f",共发现 {enabled_count} 个工具"
|
||
|
||
return {
|
||
"success": True,
|
||
"message": message,
|
||
"tool_count": enabled_count,
|
||
"enabled_count": enabled_count,
|
||
"disabled_count": disabled_count,
|
||
}
|
||
except Exception as tool_error:
|
||
raise HTTPException(status_code=500, detail=f"刷新失败: {str(tool_error)}")
|
||
except HTTPException:
|
||
raise
|
||
except Exception as e:
|
||
logger.error(f"Failed to refresh MCP server tools: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|
||
|
||
|
||
@mcp.put("/{slug}/tools/{tool_name}/toggle")
|
||
async def toggle_mcp_server_tool_route(
|
||
slug: str,
|
||
tool_name: str,
|
||
current_user: User = Depends(get_admin_user),
|
||
db: AsyncSession = Depends(get_db),
|
||
):
|
||
"""切换单个工具的启用状态"""
|
||
try:
|
||
enabled, _ = await toggle_tool_enabled(db, slug, tool_name, current_user.username)
|
||
return {
|
||
"success": True,
|
||
"tool_name": tool_name,
|
||
"enabled": enabled,
|
||
"message": f"工具 '{tool_name}' 已{'启用' if enabled else '禁用'}",
|
||
}
|
||
except ValueError as ve:
|
||
raise HTTPException(status_code=404, detail=str(ve))
|
||
except Exception as e:
|
||
logger.error(f"Failed to toggle MCP server tool: {e}")
|
||
raise HTTPException(status_code=500, detail=str(e))
|