"""MCP插件管理API""" from fastapi import APIRouter, HTTPException, Depends, Query, Request from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy import select from typing import List, Optional from datetime import datetime from app.database import get_db from app.models.mcp_plugin import MCPPlugin from app.schemas.mcp_plugin import ( MCPPluginCreate, MCPPluginSimpleCreate, MCPPluginUpdate, MCPPluginResponse, MCPToolCall, MCPTestResult ) import json from app.user_manager import User from app.mcp.registry import mcp_registry from app.services.mcp_test_service import mcp_test_service from app.services.mcp_tool_service import mcp_tool_service from app.logger import get_logger logger = get_logger(__name__) router = APIRouter(prefix="/mcp/plugins", tags=["MCP插件管理"]) def require_login(request: Request) -> User: """依赖:要求用户已登录""" if not hasattr(request.state, "user") or not request.state.user: raise HTTPException(status_code=401, detail="需要登录") return request.state.user @router.get("", response_model=List[MCPPluginResponse]) async def list_plugins( enabled_only: bool = Query(False, description="只返回启用的插件"), category: Optional[str] = Query(None, description="按分类筛选"), user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 获取用户的所有MCP插件 """ query = select(MCPPlugin).where(MCPPlugin.user_id == user.user_id) if enabled_only: query = query.where(MCPPlugin.enabled == True) if category: query = query.where(MCPPlugin.category == category) query = query.order_by(MCPPlugin.sort_order, MCPPlugin.created_at) result = await db.execute(query) plugins = result.scalars().all() logger.info(f"用户 {user.user_id} 查询插件列表,共 {len(plugins)} 个") return plugins @router.post("", response_model=MCPPluginResponse) async def create_plugin( data: MCPPluginCreate, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 创建新的MCP插件 """ # 检查插件名是否已存在 result = await db.execute( select(MCPPlugin).where( MCPPlugin.user_id == user.user_id, MCPPlugin.plugin_name == data.plugin_name ) ) existing = result.scalar_one_or_none() if existing: raise HTTPException(status_code=400, detail=f"插件名已存在: {data.plugin_name}") # 创建插件数据 plugin_data = data.model_dump() # 如果没有提供display_name,使用plugin_name作为默认值 if not plugin_data.get("display_name"): plugin_data["display_name"] = plugin_data["plugin_name"] # 创建插件 plugin = MCPPlugin( user_id=user.user_id, **plugin_data ) db.add(plugin) await db.commit() await db.refresh(plugin) # 如果启用,加载到注册表 if plugin.enabled: success = await mcp_registry.load_plugin(plugin) if success: plugin.status = "active" else: plugin.status = "error" plugin.last_error = "加载失败" await db.commit() await db.refresh(plugin) logger.info(f"用户 {user.user_id} 创建插件: {plugin.plugin_name}") return plugin @router.post("/simple", response_model=MCPPluginResponse) async def create_plugin_simple( data: MCPPluginSimpleCreate, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 通过标准MCP配置JSON创建或更新插件(简化版) 接受格式: { "config_json": '{"mcpServers": {"exa": {"type": "http", "url": "...", "headers": {}}}}', "category": "search" } 自动从mcpServers中提取插件名称(取第一个键) 如果插件已存在,则更新;否则创建新插件 """ try: # 解析配置JSON config = json.loads(data.config_json) # 验证格式 if "mcpServers" not in config: raise HTTPException(status_code=400, detail="配置JSON必须包含mcpServers字段") servers = config["mcpServers"] if not servers or len(servers) == 0: raise HTTPException(status_code=400, detail="mcpServers不能为空") # 自动提取第一个插件名称 plugin_name = list(servers.keys())[0] server_config = servers[plugin_name] logger.info(f"从配置中提取插件名称: {plugin_name}") # 提取配置 server_type = server_config.get("type", "http") if server_type not in ["http", "stdio"]: raise HTTPException(status_code=400, detail=f"不支持的服务器类型: {server_type}") # 检查插件名是否已存在 result = await db.execute( select(MCPPlugin).where( MCPPlugin.user_id == user.user_id, MCPPlugin.plugin_name == plugin_name ) ) existing = result.scalar_one_or_none() # 构建插件数据 plugin_data = { "plugin_name": plugin_name, "display_name": plugin_name, "plugin_type": server_type, "enabled": data.enabled, "category": data.category, "sort_order": 0 } if server_type == "http": plugin_data["server_url"] = server_config.get("url") plugin_data["headers"] = server_config.get("headers", {}) if not plugin_data["server_url"]: raise HTTPException(status_code=400, detail="HTTP类型插件必须提供url字段") elif server_type == "stdio": plugin_data["command"] = server_config.get("command") plugin_data["args"] = server_config.get("args", []) plugin_data["env"] = server_config.get("env", {}) if not plugin_data["command"]: raise HTTPException(status_code=400, detail="Stdio类型插件必须提供command字段") if existing: # 更新现有插件 logger.info(f"插件 {plugin_name} 已存在,执行更新操作") # 先卸载旧插件 if existing.enabled: await mcp_registry.unload_plugin(user.user_id, existing.plugin_name) # 更新字段 for key, value in plugin_data.items(): setattr(existing, key, value) plugin = existing await db.commit() await db.refresh(plugin) # 如果启用,重新加载 if plugin.enabled: success = await mcp_registry.load_plugin(plugin) if success: plugin.status = "active" plugin.last_error = None else: plugin.status = "error" plugin.last_error = "加载失败" await db.commit() await db.refresh(plugin) logger.info(f"用户 {user.user_id} 更新插件: {plugin_name}") else: # 创建新插件 plugin = MCPPlugin( user_id=user.user_id, **plugin_data ) db.add(plugin) await db.commit() await db.refresh(plugin) # 如果启用,加载到注册表 if plugin.enabled: success = await mcp_registry.load_plugin(plugin) if success: plugin.status = "active" else: plugin.status = "error" plugin.last_error = "加载失败" await db.commit() await db.refresh(plugin) logger.info(f"用户 {user.user_id} 通过简化配置创建插件: {plugin_name}") return plugin except json.JSONDecodeError as e: raise HTTPException(status_code=400, detail=f"配置JSON格式错误: {str(e)}") except HTTPException: raise except Exception as e: logger.error(f"创建插件失败: {str(e)}") raise HTTPException(status_code=500, detail=f"创建插件失败: {str(e)}") @router.get("/{plugin_id}", response_model=MCPPluginResponse) async def get_plugin( plugin_id: str, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 获取插件详情 """ result = await db.execute( select(MCPPlugin).where( MCPPlugin.id == plugin_id, MCPPlugin.user_id == user.user_id ) ) plugin = result.scalar_one_or_none() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") return plugin @router.put("/{plugin_id}", response_model=MCPPluginResponse) async def update_plugin( plugin_id: str, data: MCPPluginUpdate, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 更新插件配置 """ result = await db.execute( select(MCPPlugin).where( MCPPlugin.id == plugin_id, MCPPlugin.user_id == user.user_id ) ) plugin = result.scalar_one_or_none() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") # 更新字段 update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(plugin, key, value) await db.commit() await db.refresh(plugin) # 如果插件已启用,重新加载 if plugin.enabled: await mcp_registry.reload_plugin(plugin) logger.info(f"用户 {user.user_id} 更新插件: {plugin.plugin_name}") return plugin @router.delete("/{plugin_id}") async def delete_plugin( plugin_id: str, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 删除插件 """ result = await db.execute( select(MCPPlugin).where( MCPPlugin.id == plugin_id, MCPPlugin.user_id == user.user_id ) ) plugin = result.scalar_one_or_none() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") # 从注册表卸载 await mcp_registry.unload_plugin(user.user_id, plugin.plugin_name) # 删除数据库记录 await db.delete(plugin) await db.commit() logger.info(f"用户 {user.user_id} 删除插件: {plugin.plugin_name}") return {"message": "插件已删除", "plugin_name": plugin.plugin_name} @router.post("/{plugin_id}/toggle", response_model=MCPPluginResponse) async def toggle_plugin( plugin_id: str, enabled: bool = Query(..., description="启用或禁用"), user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 启用或禁用插件 """ result = await db.execute( select(MCPPlugin).where( MCPPlugin.id == plugin_id, MCPPlugin.user_id == user.user_id ) ) plugin = result.scalar_one_or_none() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") plugin.enabled = enabled if enabled: # 启用:加载到注册表 success = await mcp_registry.load_plugin(plugin) if success: plugin.status = "active" plugin.last_error = None else: plugin.status = "error" plugin.last_error = "加载失败" else: # 禁用:从注册表卸载 await mcp_registry.unload_plugin(user.user_id, plugin.plugin_name) plugin.status = "inactive" await db.commit() await db.refresh(plugin) action = "启用" if enabled else "禁用" logger.info(f"用户 {user.user_id} {action}插件: {plugin.plugin_name}") return plugin @router.post("/{plugin_id}/test", response_model=MCPTestResult) async def test_plugin( plugin_id: str, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 测试插件连接并调用工具验证功能 使用新的MCPTestService进行测试 """ result = await db.execute( select(MCPPlugin).where( MCPPlugin.id == plugin_id, MCPPlugin.user_id == user.user_id ) ) plugin = result.scalar_one_or_none() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") if not plugin.enabled: return MCPTestResult( success=False, message="插件未启用", error="请先启用插件", suggestions=["点击开关按钮启用插件"] ) # 使用新的测试服务 try: test_result = await mcp_test_service.test_plugin_with_ai(plugin, user, db) # 更新插件状态 if test_result.success: plugin.status = "active" plugin.last_error = None else: plugin.status = "error" plugin.last_error = test_result.error plugin.last_test_at = datetime.now() await db.commit() return test_result except Exception as e: logger.error(f"测试插件失败: {plugin.plugin_name}, 错误: {e}") plugin.status = "error" plugin.last_error = str(e) plugin.last_test_at = datetime.now() await db.commit() raise HTTPException(status_code=500, detail=f"测试失败: {str(e)}") async def _ensure_plugin_loaded( plugin: MCPPlugin, user_id: str ) -> bool: """ 确保插件已加载(共享逻辑) Args: plugin: 插件对象 user_id: 用户ID Returns: 是否加载成功 Raises: HTTPException: 加载失败 """ if not mcp_registry.get_client(user_id, plugin.plugin_name): logger.info(f"插件 {plugin.plugin_name} 未加载,自动加载中...") success = await mcp_registry.load_plugin(plugin) if not success: raise HTTPException( status_code=500, detail=f"插件加载失败: {plugin.plugin_name}" ) return True @router.get("/metrics") async def get_metrics( tool_name: Optional[str] = Query(None, description="工具名称(可选,获取特定工具的指标)"), user: User = Depends(require_login) ): """ 获取MCP工具调用指标 Query参数: - tool_name: 可选,指定工具名称获取特定工具的指标 Returns: 工具调用指标字典,包含: - total_calls: 总调用次数 - success_calls: 成功调用次数 - failed_calls: 失败调用次数 - success_rate: 成功率 - avg_duration_ms: 平均耗时(毫秒) - last_call_time: 最后调用时间 """ metrics = mcp_tool_service.get_metrics(tool_name) return { "metrics": metrics, "tool_name": tool_name, "timestamp": datetime.now().isoformat() } @router.get("/cache/stats") async def get_cache_stats( user: User = Depends(require_login) ): """ 获取工具缓存统计信息 Returns: 缓存统计信息,包含: - total_entries: 缓存条目总数 - total_hits: 缓存总命中次数 - cache_ttl_minutes: 缓存TTL(分钟) - entries: 各缓存条目详情 """ stats = mcp_tool_service.get_cache_stats() return { "cache_stats": stats, "timestamp": datetime.now().isoformat() } @router.post("/cache/clear") async def clear_cache( user_id: Optional[str] = Query(None, description="用户ID(可选)"), plugin_name: Optional[str] = Query(None, description="插件名称(可选)"), user: User = Depends(require_login) ): """ 清理工具缓存 Query参数: - user_id: 可选,清理特定用户的缓存 - plugin_name: 可选,清理特定插件的缓存 说明: - 不提供任何参数:清理所有缓存 - 只提供user_id:清理该用户的所有缓存 - 提供user_id和plugin_name:清理特定插件的缓存 """ # 非管理员只能清理自己的缓存 if user_id and user_id != user.user_id: raise HTTPException(status_code=403, detail="无权清理其他用户的缓存") # 如果没有指定user_id,使用当前用户 target_user_id = user_id or user.user_id mcp_tool_service.clear_cache(target_user_id, plugin_name) message = "已清理" if plugin_name: message += f"插件 {plugin_name} 的缓存" elif target_user_id: message += f"用户 {target_user_id} 的所有缓存" else: message += "所有缓存" logger.info(f"用户 {user.user_id} {message}") return { "success": True, "message": message, "timestamp": datetime.now().isoformat() } @router.get("/{plugin_id}/tools") async def get_plugin_tools( plugin_id: str, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 获取插件提供的工具列表 """ result = await db.execute( select(MCPPlugin).where( MCPPlugin.id == plugin_id, MCPPlugin.user_id == user.user_id ) ) plugin = result.scalar_one_or_none() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") if not plugin.enabled: raise HTTPException(status_code=400, detail="插件未启用") try: # 确保插件已加载 await _ensure_plugin_loaded(plugin, user.user_id) tools = await mcp_registry.get_plugin_tools(user.user_id, plugin.plugin_name) # 更新缓存 plugin.tools = tools await db.commit() return { "plugin_name": plugin.plugin_name, "tools": tools, "count": len(tools) } except HTTPException: raise except Exception as e: logger.error(f"获取工具列表失败: {plugin.plugin_name}, 错误: {e}") raise HTTPException(status_code=500, detail=f"获取工具列表失败: {str(e)}") @router.post("/call") async def call_mcp_tool( data: MCPToolCall, user: User = Depends(require_login), db: AsyncSession = Depends(get_db) ): """ 调用MCP工具 """ # 获取插件 result = await db.execute( select(MCPPlugin).where( MCPPlugin.id == data.plugin_id, MCPPlugin.user_id == user.user_id ) ) plugin = result.scalar_one_or_none() if not plugin: raise HTTPException(status_code=404, detail="插件不存在") if not plugin.enabled: raise HTTPException(status_code=400, detail="插件未启用") try: # 确保插件已加载 await _ensure_plugin_loaded(plugin, user.user_id) # 调用工具 result = await mcp_registry.call_tool( user.user_id, plugin.plugin_name, data.tool_name, data.arguments ) return { "success": True, "plugin_name": plugin.plugin_name, "tool_name": data.tool_name, "result": result } except HTTPException: raise except Exception as e: logger.error(f"调用工具失败: {plugin.plugin_name}.{data.tool_name}, 错误: {e}") raise HTTPException(status_code=500, detail=f"工具调用失败: {str(e)}")