| """ |
| 插件MCP工具动态注册器 - 扫描插件并注册MCP工具 |
| |
| 扩展功能: |
| - 工具可用性绑定插件生命周期 |
| - 最近错误记录 |
| - 元数据完整性检查 |
| """ |
|
|
| import importlib.util |
| import sys |
| import logging |
| import functools |
| import inspect |
| from pathlib import Path |
| from typing import Dict, Any, List, TYPE_CHECKING, Callable, Optional |
| from datetime import datetime |
|
|
| if TYPE_CHECKING: |
| from mcp.server import FastMCP |
|
|
| logger = logging.getLogger(__name__) |
|
|
|
|
| class MCPToolAvailability: |
| """MCP 工具可用状态常量""" |
|
|
| AVAILABLE = "available" |
| UNAVAILABLE = "unavailable" |
| INCOMPLETE = "incomplete" |
|
|
|
|
| class PluginMCPRegistry: |
| """插件MCP工具动态注册器 |
| |
| 扩展功能: |
| - 工具可用性绑定插件生命周期 |
| - 最近错误记录 |
| - 元数据完整性检查 |
| """ |
|
|
| def __init__(self, mcp_server: "FastMCP"): |
| self.mcp = mcp_server |
| self.registered_tools: Dict[str, Dict[str, Any]] = {} |
| self._tool_wrappers: Dict[str, Callable] = {} |
|
|
| def _create_tool_wrapper(self, tool_name: str, original_func: Callable) -> Callable: |
| """创建工具调用包装器,保持原始函数签名用于 schema 生成。 |
| |
| 包装器在调用前查询插件状态和依赖状态,不可用时直接返回结构化错误。 |
| 捕获参数错误和运行异常,写入最近错误记录。 |
| """ |
| @functools.wraps(original_func) |
| async def wrapper(*args, **kwargs): |
| |
| tool_info = self.registered_tools.get(tool_name) |
| if not tool_info: |
| return { |
| "success": False, |
| "error": f"工具 {tool_name} 不存在", |
| "error_code": "TOOL_NOT_FOUND", |
| } |
|
|
| |
| availability = tool_info.get("availability", MCPToolAvailability.AVAILABLE) |
| if availability == MCPToolAvailability.UNAVAILABLE: |
| reason = tool_info.get("unavailable_reason", "工具不可用") |
| return { |
| "success": False, |
| "error": f"工具 {tool_name} 不可用:{reason}", |
| "error_code": "TOOL_UNAVAILABLE", |
| "unavailable_reason": reason, |
| } |
|
|
| |
| tool_info["last_called_at"] = datetime.now() |
|
|
| try: |
| |
| result = await original_func(*args, **kwargs) |
| return result |
| except TypeError as e: |
| |
| error_msg = f"参数错误:{str(e)}" |
| tool_info["last_error"] = { |
| "error_code": "INVALID_PARAMETERS", |
| "message": error_msg, |
| "occurred_at": datetime.now().isoformat(), |
| } |
| logger.error(f"工具 {tool_name} 参数错误: {e}") |
| return { |
| "success": False, |
| "error": error_msg, |
| "error_code": "INVALID_PARAMETERS", |
| } |
| except Exception as e: |
| |
| error_msg = f"运行错误:{str(e)}" |
| tool_info["last_error"] = { |
| "error_code": "RUNTIME_ERROR", |
| "message": error_msg, |
| "occurred_at": datetime.now().isoformat(), |
| } |
| logger.error(f"工具 {tool_name} 运行错误: {e}") |
| return { |
| "success": False, |
| "error": error_msg, |
| "error_code": "RUNTIME_ERROR", |
| } |
|
|
| |
| wrapper.__signature__ = inspect.signature(original_func) |
| return wrapper |
|
|
| def scan_and_register_all(self, plugins_dir: Path) -> int: |
| """扫描所有插件并注册MCP工具。""" |
| total = 0 |
| for plugin_dir in plugins_dir.iterdir(): |
| if plugin_dir.is_dir() and not plugin_dir.name.startswith('_'): |
| count = self.register_plugin_tools(plugin_dir) |
| total += count |
| if count > 0: |
| logger.info(f"插件 {plugin_dir.name} 注册了 {count} 个MCP工具") |
| return total |
|
|
| def register_plugin_tools_by_name(self, plugins_dir: Path, plugin_names: List[str]) -> int: |
| """仅注册指定插件的 MCP 工具""" |
| total = 0 |
| for plugin_name in plugin_names: |
| plugin_dir = plugins_dir / plugin_name |
| if plugin_dir.is_dir() and not plugin_dir.name.startswith('_'): |
| count = self.register_plugin_tools(plugin_dir) |
| total += count |
| if count > 0: |
| logger.info(f"插件 {plugin_name} 注册了 {count} 个MCP工具") |
| return total |
|
|
| def register_plugin_tools(self, plugin_dir: Path) -> int: |
| """注册单个插件的MCP工具。""" |
| mcp_file = plugin_dir / "mcp.py" |
| if not mcp_file.exists(): |
| return 0 |
|
|
| plugin_name = plugin_dir.name |
| module = self._import_module(mcp_file, plugin_name) |
| if not module: |
| return 0 |
|
|
| tools = self._scan_tools(module) |
| count = 0 |
| for tool_def in tools: |
| |
| full_name = tool_def['name'] |
| try: |
| wrapped_func = self._create_tool_wrapper(full_name, tool_def['func']) |
| self._tool_wrappers[full_name] = wrapped_func |
|
|
| self.mcp.tool( |
| name=full_name, |
| title=tool_def.get('title'), |
| description=tool_def.get('description'), |
| )(wrapped_func) |
|
|
| |
| incomplete_reasons = [] |
| if not tool_def.get('title'): |
| incomplete_reasons.append("缺少标题") |
| if not tool_def.get('description'): |
| incomplete_reasons.append("缺少描述") |
|
|
| availability = MCPToolAvailability.AVAILABLE |
| if incomplete_reasons: |
| availability = MCPToolAvailability.INCOMPLETE |
|
|
| self.registered_tools[full_name] = { |
| "plugin": plugin_name, |
| "tool": tool_def['name'], |
| "input_model": tool_def.get('input_model'), |
| "output_model": tool_def.get('output_model'), |
| "title": tool_def.get('title'), |
| "description": tool_def.get('description'), |
| |
| "availability": availability, |
| "unavailable_reason": None, |
| "incomplete_reasons": incomplete_reasons if incomplete_reasons else None, |
| "last_error": None, |
| "last_called_at": None, |
| "registered_at": datetime.now().isoformat(), |
| } |
| count += 1 |
| except Exception as e: |
| logger.error(f"注册工具 {full_name} 失败: {e}") |
|
|
| return count |
|
|
| def _import_module(self, mcp_file: Path, plugin_name: str): |
| """动态导入模块。""" |
| try: |
| spec = importlib.util.spec_from_file_location( |
| f"plugins.{plugin_name}.mcp", mcp_file |
| ) |
| if not spec or not spec.loader: |
| return None |
| module = importlib.util.module_from_spec(spec) |
| sys.modules[spec.name] = module |
| spec.loader.exec_module(module) |
| return module |
| except Exception as e: |
| logger.error(f"导入模块 {mcp_file} 失败: {e}") |
| return None |
|
|
| def _scan_tools(self, module) -> List[Dict]: |
| """扫描模块中的工具定义。""" |
| tools = [] |
| for attr_name in dir(module): |
| attr = getattr(module, attr_name) |
| if callable(attr) and hasattr(attr, '_mcp_tool_def'): |
| tools.append(attr._mcp_tool_def) |
| return tools |
|
|
| def get_tool_docs(self, plugin_name: str = None) -> Dict: |
| """获取工具文档。""" |
| if plugin_name: |
| return { |
| name: info for name, info in self.registered_tools.items() |
| if info['plugin'] == plugin_name |
| } |
| return self.registered_tools.copy() |
|
|
| def unregister_plugin_tools(self, plugin_name: str) -> int: |
| """注销插件的MCP工具。""" |
| tools_to_remove = [ |
| name for name, info in self.registered_tools.items() |
| if info['plugin'] == plugin_name |
| ] |
| for tool_name in tools_to_remove: |
| del self.registered_tools[tool_name] |
| logger.info(f"已注销插件 {plugin_name} 的 {len(tools_to_remove)} 个工具") |
| return len(tools_to_remove) |
|
|
| def get_plugin_tools(self, plugin_name: str) -> List[str]: |
| """获取插件的所有工具名称。""" |
| return [ |
| name for name, info in self.registered_tools.items() |
| if info['plugin'] == plugin_name |
| ] |
|
|
| def set_tool_availability( |
| self, |
| tool_name: str, |
| availability: str, |
| unavailable_reason: Optional[str] = None, |
| ) -> bool: |
| """设置工具可用状态 |
| |
| Args: |
| tool_name: 工具全名 |
| availability: 可用状态 |
| unavailable_reason: 不可用原因 |
| |
| Returns: |
| 是否设置成功 |
| """ |
| tool_info = self.registered_tools.get(tool_name) |
| if not tool_info: |
| return False |
|
|
| tool_info["availability"] = availability |
| tool_info["unavailable_reason"] = unavailable_reason |
| return True |
|
|
| def set_plugin_tools_availability( |
| self, |
| plugin_name: str, |
| availability: str, |
| unavailable_reason: Optional[str] = None, |
| ) -> int: |
| """设置插件所有工具的可用状态 |
| |
| Args: |
| plugin_name: 插件名 |
| availability: 可用状态 |
| unavailable_reason: 不可用原因 |
| |
| Returns: |
| 更新的工具数量 |
| """ |
| count = 0 |
| for tool_name, tool_info in self.registered_tools.items(): |
| if tool_info["plugin"] == plugin_name: |
| tool_info["availability"] = availability |
| tool_info["unavailable_reason"] = unavailable_reason |
| count += 1 |
| return count |
|
|
| def get_mcp_status(self) -> Dict[str, Any]: |
| """获取 MCP 服务状态 |
| |
| Returns: |
| 包含服务状态、工具总数、按插件分组、最近错误的字典 |
| """ |
| total_tools = len(self.registered_tools) |
| available_tools = sum( |
| 1 for info in self.registered_tools.values() |
| if info.get("availability") == MCPToolAvailability.AVAILABLE |
| ) |
| unavailable_tools = sum( |
| 1 for info in self.registered_tools.values() |
| if info.get("availability") == MCPToolAvailability.UNAVAILABLE |
| ) |
| incomplete_tools = sum( |
| 1 for info in self.registered_tools.values() |
| if info.get("availability") == MCPToolAvailability.INCOMPLETE |
| ) |
|
|
| |
| groups = {} |
| for tool_name, tool_info in self.registered_tools.items(): |
| plugin_name = tool_info["plugin"] |
| if plugin_name not in groups: |
| groups[plugin_name] = { |
| "plugin_name": plugin_name, |
| "tools": [], |
| } |
| groups[plugin_name]["tools"].append({ |
| "name": tool_name, |
| "title": tool_info.get("title"), |
| "description": tool_info.get("description"), |
| "availability": tool_info.get("availability"), |
| "unavailable_reason": tool_info.get("unavailable_reason"), |
| "incomplete_reasons": tool_info.get("incomplete_reasons"), |
| "last_error": tool_info.get("last_error"), |
| "last_called_at": tool_info.get("last_called_at"), |
| }) |
|
|
| |
| recent_errors = [] |
| for tool_name, tool_info in self.registered_tools.items(): |
| if tool_info.get("last_error"): |
| recent_errors.append({ |
| "tool_name": tool_name, |
| "plugin_name": tool_info["plugin"], |
| **tool_info["last_error"], |
| }) |
|
|
| return { |
| "service_available": True, |
| "total_tools": total_tools, |
| "available_tools": available_tools, |
| "unavailable_tools": unavailable_tools, |
| "incomplete_tools": incomplete_tools, |
| "groups": list(groups.values()), |
| "recent_errors": recent_errors, |
| } |
|
|
|
|
| |
| _plugin_registry: Optional[PluginMCPRegistry] = None |
|
|
|
|
| def get_plugin_registry() -> PluginMCPRegistry: |
| """获取全局插件注册器实例。""" |
| global _plugin_registry |
| if _plugin_registry is None: |
| raise RuntimeError("PluginMCPRegistry 未初始化,请先调用 init_plugin_registry(mcp)") |
| return _plugin_registry |
|
|
|
|
| def init_plugin_registry(mcp_server: "FastMCP") -> PluginMCPRegistry: |
| """初始化全局插件注册器。""" |
| global _plugin_registry |
| _plugin_registry = PluginMCPRegistry(mcp_server) |
| return _plugin_registry |