From 829fe9de79c1fee8992c8d08181985d7c917906d Mon Sep 17 00:00:00 2001 From: CharTyr Date: Wed, 3 Dec 2025 14:53:28 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat:=20=E6=96=B0=E5=A2=9E=20MCP=20?= =?UTF-8?q?=E6=A1=A5=E6=8E=A5=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot 功能特性: - 支持 stdio/SSE/HTTP/Streamable HTTP 四种传输方式 - 心跳检测与自动重连 - 工具调用缓存、追踪、权限控制 - 配置导入导出(兼容 Claude Desktop) - WebUI 完整配置支持 - 断路器模式,故障快速失败 默认禁用,用户需在 WebUI 手动启用 --- .gitignore | 2 +- plugins/MaiBot_MCPBridgePlugin | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) create mode 160000 plugins/MaiBot_MCPBridgePlugin diff --git a/.gitignore b/.gitignore index b2cbb3ba..8e23b6d3 100644 --- a/.gitignore +++ b/.gitignore @@ -334,4 +334,4 @@ config.toml interested_rates.txt MaiBot.code-workspace -*.lock \ No newline at end of file +*.lock!/plugins/MaiBot_MCPBridgePlugin diff --git a/plugins/MaiBot_MCPBridgePlugin b/plugins/MaiBot_MCPBridgePlugin new file mode 160000 index 00000000..c061f577 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin @@ -0,0 +1 @@ +Subproject commit c061f57706a85dd79470df30dcfae19242b0cd08 From 8d7d7f0fb21a8096bdf417ce207d25a10076d5da Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 6 Dec 2025 00:44:47 +0800 Subject: [PATCH 2/3] Update .gitignore --- .gitignore | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/.gitignore b/.gitignore index 8e23b6d3..3ca1422e 100644 --- a/.gitignore +++ b/.gitignore @@ -35,9 +35,6 @@ message_queue_content.bat message_queue_window.bat message_queue_window.txt queue_update.txt -memory_graph.gml -/src/tools/tool_can_use/auto_create_tool.py -/src/tools/tool_can_use/execute_python_code_tool.py .env .env.* .cursor @@ -48,9 +45,6 @@ config/lpmm_config.toml config/lpmm_config.toml.bak template/compare/bot_config_template.toml template/compare/model_config_template.toml -(测试版)麦麦生成人格.bat -(临时版)麦麦开始学习.bat -src/plugins/utils/statistic.py CLAUDE.md MaiBot-Dashboard/ cloudflare-workers/ @@ -327,6 +321,7 @@ run_pet.bat !/plugins/emoji_manage_plugin !/plugins/take_picture_plugin !/plugins/deep_think +!/plugins/MaiBot_MCPBridgePlugin !/plugins/ChatFrequency/ !/plugins/__init__.py @@ -334,4 +329,3 @@ config.toml interested_rates.txt MaiBot.code-workspace -*.lock!/plugins/MaiBot_MCPBridgePlugin From 1aaf129d460605fe4df54f7807ab10a791c738f0 Mon Sep 17 00:00:00 2001 From: SengokuCola <1026294844@qq.com> Date: Sat, 6 Dec 2025 00:50:23 +0800 Subject: [PATCH 3/3] =?UTF-8?q?re=EF=BC=9A=E4=BB=A5=E7=9B=B4=E6=8E=A5?= =?UTF-8?q?=E4=BB=A3=E7=A0=81=E8=80=8C=E4=B8=8D=E6=98=AFsubmodule=E5=BD=A2?= =?UTF-8?q?=E5=BC=8F=E6=B7=BB=E5=8A=A0=E5=86=85=E7=BD=AE=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- plugins/MaiBot_MCPBridgePlugin | 1 - plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md | 569 +++ plugins/MaiBot_MCPBridgePlugin/README.md | 220 ++ plugins/MaiBot_MCPBridgePlugin/__init__.py | 44 + plugins/MaiBot_MCPBridgePlugin/_manifest.json | 60 + .../config.example.toml | 263 ++ .../config_converter.py | 448 +++ plugins/MaiBot_MCPBridgePlugin/mcp_client.py | 1542 ++++++++ plugins/MaiBot_MCPBridgePlugin/plugin.py | 3138 +++++++++++++++++ .../MaiBot_MCPBridgePlugin/requirements.txt | 2 + .../MaiBot_MCPBridgePlugin/test_mcp_client.py | 270 ++ test_edge.py | 30 - 12 files changed, 6556 insertions(+), 31 deletions(-) delete mode 160000 plugins/MaiBot_MCPBridgePlugin create mode 100644 plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md create mode 100644 plugins/MaiBot_MCPBridgePlugin/README.md create mode 100644 plugins/MaiBot_MCPBridgePlugin/__init__.py create mode 100644 plugins/MaiBot_MCPBridgePlugin/_manifest.json create mode 100644 plugins/MaiBot_MCPBridgePlugin/config.example.toml create mode 100644 plugins/MaiBot_MCPBridgePlugin/config_converter.py create mode 100644 plugins/MaiBot_MCPBridgePlugin/mcp_client.py create mode 100644 plugins/MaiBot_MCPBridgePlugin/plugin.py create mode 100644 plugins/MaiBot_MCPBridgePlugin/requirements.txt create mode 100644 plugins/MaiBot_MCPBridgePlugin/test_mcp_client.py delete mode 100644 test_edge.py diff --git a/plugins/MaiBot_MCPBridgePlugin b/plugins/MaiBot_MCPBridgePlugin deleted file mode 160000 index c061f577..00000000 --- a/plugins/MaiBot_MCPBridgePlugin +++ /dev/null @@ -1 +0,0 @@ -Subproject commit c061f57706a85dd79470df30dcfae19242b0cd08 diff --git a/plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md b/plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md new file mode 100644 index 00000000..b9e05238 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md @@ -0,0 +1,569 @@ +# MCP 桥接插件 - 开发文档 + +本文档面向 AI 助手或开发者进行插件开发/维护。 + +## 前置知识 + +本插件基于 MaiBot 插件系统开发,需要了解: +- MaiBot 插件框架:`BasePlugin`, `BaseTool`, `BaseCommand`, `BaseEventHandler` +- 配置系统:`ConfigField`, `config_schema` +- 组件注册:`component_registry.register_component()` + +详见项目根目录 `.kiro/steering/plugin-dev.md`。 + +--- + +## 版本历史 + +| 版本 | 主要功能 | +|------|----------| +| v1.5.4 | 易用性优化:新增 MCP 服务器获取快捷入口 | +| v1.5.3 | 配置优化:新增智能心跳 WebUI 配置项 | +| v1.5.2 | 性能优化:智能心跳间隔,根据服务器稳定性动态调整 | +| v1.5.1 | 易用性优化:新增「快速添加服务器」表单式配置 | +| v1.5.0 | 性能优化:服务器并行连接,大幅减少启动时间 | +| v1.4.4 | 修复首次生成默认配置文件时多行字符串导致 TOML 解析失败 | +| v1.4.3 | 修复 WebUI 保存配置后多行字符串格式错误导致配置文件无法读取 | +| v1.4.2 | HTTP 鉴权头支持(headers 字段) | +| v1.4.0 | 工具禁用、调用追踪、缓存、权限控制、WebUI 易用性改进 | +| v1.3.0 | 结果后处理(LLM 摘要提炼) | +| v1.2.0 | Resources/Prompts 支持(实验性) | +| v1.1.x | 心跳检测、自动重连、调用统计、`/mcp` 命令 | +| v1.0.0 | 基础 MCP 桥接 | + +--- + +## 项目结构 + +``` +MCPBridgePlugin/ +├── plugin.py # 主插件逻辑(1800+ 行) +├── mcp_client.py # MCP 客户端封装(800+ 行) +├── _manifest.json # 插件清单 +├── config.example.toml # 配置示例 +├── requirements.txt # 依赖:mcp>=1.0.0 +├── README.md # 用户文档 +└── DEVELOPMENT.md # 开发文档(本文件) +``` + +--- + +## 核心模块详解 + +### 1. mcp_client.py - MCP 客户端 + +负责与 MCP 服务器通信,可独立于 MaiBot 运行测试。 + +#### 数据类 + +```python +class TransportType(Enum): + STDIO = "stdio" # 本地进程 + SSE = "sse" # Server-Sent Events + HTTP = "http" # HTTP + STREAMABLE_HTTP = "streamable_http" # HTTP Streamable(推荐) + +@dataclass +class MCPServerConfig: + name: str # 服务器唯一标识 + enabled: bool = True + transport: TransportType = TransportType.STDIO + command: str = "" # stdio: 启动命令 + args: List[str] = field(default_factory=list) # stdio: 参数 + env: Dict[str, str] = field(default_factory=dict) # stdio: 环境变量 + url: str = "" # http/sse: 服务器 URL + +@dataclass +class MCPToolInfo: + name: str # 工具原始名称 + description: str + input_schema: Dict[str, Any] # JSON Schema + server_name: str + +@dataclass +class MCPCallResult: + success: bool + content: str = "" + error: Optional[str] = None + duration_ms: float = 0.0 + +@dataclass +class MCPResourceInfo: + uri: str + name: str + description: str = "" + mime_type: Optional[str] = None + server_name: str = "" + +@dataclass +class MCPPromptInfo: + name: str + description: str = "" + arguments: List[Dict[str, Any]] = field(default_factory=list) + server_name: str = "" +``` + +#### MCPClientSession + +管理单个 MCP 服务器连接。 + +```python +class MCPClientSession: + def __init__(self, config: MCPServerConfig): ... + + async def connect(self) -> bool: + """连接服务器,返回是否成功""" + + async def disconnect(self) -> None: + """断开连接""" + + async def call_tool(self, tool_name: str, arguments: Dict) -> MCPCallResult: + """调用工具""" + + async def check_health(self) -> bool: + """健康检查(用于心跳)""" + + async def fetch_resources(self) -> bool: + """获取资源列表""" + + async def read_resource(self, uri: str) -> MCPCallResult: + """读取资源""" + + async def fetch_prompts(self) -> bool: + """获取提示模板列表""" + + async def get_prompt(self, name: str, arguments: Optional[Dict]) -> MCPCallResult: + """获取提示模板""" + + @property + def tools(self) -> List[MCPToolInfo]: ... + @property + def resources(self) -> List[MCPResourceInfo]: ... + @property + def prompts(self) -> List[MCPPromptInfo]: ... + @property + def is_connected(self) -> bool: ... +``` + +#### MCPClientManager + +全局单例,管理多服务器。 + +```python +class MCPClientManager: + def configure(self, settings: Dict) -> None: + """配置超时、重试等参数""" + + async def add_server(self, config: MCPServerConfig) -> bool: + """添加并连接服务器""" + + async def remove_server(self, server_name: str) -> bool: + """移除服务器""" + + async def reconnect_server(self, server_name: str) -> bool: + """重连服务器""" + + async def call_tool(self, tool_key: str, arguments: Dict) -> MCPCallResult: + """调用工具,tool_key 格式: mcp_{server}_{tool}""" + + async def start_heartbeat(self) -> None: + """启动心跳检测""" + + async def shutdown(self) -> None: + """关闭所有连接""" + + def get_status(self) -> Dict[str, Any]: + """获取状态""" + + def get_all_stats(self) -> Dict[str, Any]: + """获取统计信息""" + + def set_status_change_callback(self, callback: Callable) -> None: + """设置状态变化回调""" + + @property + def all_tools(self) -> Dict[str, Tuple[MCPToolInfo, MCPClientSession]]: ... + @property + def all_resources(self) -> Dict[str, Tuple[MCPResourceInfo, MCPClientSession]]: ... + @property + def all_prompts(self) -> Dict[str, Tuple[MCPPromptInfo, MCPClientSession]]: ... + @property + def disconnected_servers(self) -> List[str]: ... + +# 全局单例 +mcp_manager = MCPClientManager() +``` + +--- + +### 2. plugin.py - MaiBot 插件 + +#### v1.4.0 新增模块 + +```python +# ============ 调用追踪 ============ +@dataclass +class ToolCallRecord: + call_id: str # UUID + timestamp: float + tool_name: str + server_name: str + chat_id: str = "" + user_id: str = "" + user_query: str = "" + arguments: Dict = field(default_factory=dict) + raw_result: str = "" + processed_result: str = "" + duration_ms: float = 0.0 + success: bool = True + error: str = "" + post_processed: bool = False + cache_hit: bool = False + +class ToolCallTracer: + def configure(self, enabled: bool, max_records: int, log_enabled: bool, log_path: Path): ... + def record(self, record: ToolCallRecord) -> None: ... + def get_recent(self, n: int = 10) -> List[ToolCallRecord]: ... + def get_by_tool(self, tool_name: str) -> List[ToolCallRecord]: ... + def clear(self) -> None: ... + +tool_call_tracer = ToolCallTracer() + +# ============ 调用缓存 ============ +@dataclass +class CacheEntry: + tool_name: str + args_hash: str # MD5(tool_name + sorted_json_args) + result: str + created_at: float + expires_at: float + hit_count: int = 0 + +class ToolCallCache: + def configure(self, enabled: bool, ttl: int, max_entries: int, exclude_tools: str): ... + def get(self, tool_name: str, args: Dict) -> Optional[str]: ... + def set(self, tool_name: str, args: Dict, result: str) -> None: ... + def clear(self) -> None: ... + def get_stats(self) -> Dict[str, Any]: ... + +tool_call_cache = ToolCallCache() + +# ============ 权限控制 ============ +class PermissionChecker: + def configure(self, enabled: bool, default_mode: str, rules_json: str, + quick_deny_groups: str = "", quick_allow_users: str = ""): ... + def check(self, tool_name: str, chat_id: str, user_id: str, is_group: bool) -> bool: ... + def get_rules_for_tool(self, tool_name: str) -> List[Dict]: ... + +permission_checker = PermissionChecker() +``` + +#### 工具代理 + +```python +class MCPToolProxy(BaseTool): + """所有 MCP 工具的基类""" + + # 类属性(动态子类覆盖) + name: str = "" + description: str = "" + parameters: List[Tuple] = [] + available_for_llm: bool = True + + # MCP 属性 + _mcp_tool_key: str = "" + _mcp_original_name: str = "" + _mcp_server_name: str = "" + + async def execute(self, function_args: Dict) -> Dict[str, Any]: + """执行流程: + 1. 权限检查 → 拒绝则返回错误 + 2. 缓存检查 → 命中则返回缓存 + 3. 调用 MCP 服务器 + 4. 存入缓存 + 5. 后处理(可选) + 6. 记录追踪 + 7. 返回结果 + """ + +def create_mcp_tool_class(tool_key: str, tool_info: MCPToolInfo, + tool_prefix: str, disabled: bool = False) -> Type[MCPToolProxy]: + """动态创建工具类""" +``` + +#### 内置工具 + +```python +class MCPStatusTool(BaseTool): + """mcp_status - 查询状态/工具/资源/模板/统计/追踪/缓存""" + name = "mcp_status" + parameters = [ + ("query_type", STRING, "查询类型", False, + ["status", "tools", "resources", "prompts", "stats", "trace", "cache", "all"]), + ("server_name", STRING, "服务器名称", False, None), + ] + +class MCPReadResourceTool(BaseTool): + """mcp_read_resource - 读取资源""" + name = "mcp_read_resource" + +class MCPGetPromptTool(BaseTool): + """mcp_get_prompt - 获取提示模板""" + name = "mcp_get_prompt" +``` + +#### 命令 + +```python +class MCPStatusCommand(BaseCommand): + """处理 /mcp 命令""" + command_pattern = r"^[//]mcp(?:\s+(?Pstatus|tools|stats|reconnect|trace|cache|perm))?(?:\s+(?P\S+))?$" + + # 子命令处理 + async def _handle_reconnect(self, server_name): ... + async def _handle_trace(self, arg): ... + async def _handle_cache(self, arg): ... + async def _handle_perm(self, arg): ... +``` + +#### 事件处理器 + +```python +class MCPStartupHandler(BaseEventHandler): + """ON_START - 连接服务器、注册工具""" + event_type = EventType.ON_START + +class MCPStopHandler(BaseEventHandler): + """ON_STOP - 关闭连接""" + event_type = EventType.ON_STOP +``` + +#### 主插件类 + +```python +@register_plugin +class MCPBridgePlugin(BasePlugin): + plugin_name = "mcp_bridge_plugin" + python_dependencies = ["mcp"] + + config_section_descriptions = { + "guide": "📖 快速入门", + "servers": "🔌 服务器配置", + "status": "📊 运行状态", + "plugin": "插件开关", + "settings": "⚙️ 高级设置", + "tools": "🔧 工具管理", + "permissions": "🔐 权限控制", + } + + config_schema = { + "guide": { "quick_start": ConfigField(...) }, + "plugin": { "enabled": ConfigField(...) }, + "settings": { + # 基础:tool_prefix, connect_timeout, call_timeout, auto_connect, retry_* + # 心跳:heartbeat_enabled, heartbeat_interval, auto_reconnect, max_reconnect_attempts + # 高级:enable_resources, enable_prompts + # 后处理:post_process_* + # 追踪:trace_* + # 缓存:cache_* + }, + "tools": { "tool_list", "disabled_tools" }, + "permissions": { "perm_enabled", "perm_default_mode", "quick_deny_groups", "quick_allow_users", "perm_rules" }, + "servers": { "list" }, + "status": { "connection_status" }, + } + + def __init__(self): + # 配置 mcp_manager, tool_call_tracer, tool_call_cache, permission_checker + + async def _async_connect_servers(self): + # 解析配置 → 连接服务器 → 注册工具(检查禁用列表) + + def _update_status_display(self): + # 更新 WebUI 状态显示 + + def _update_tool_list_display(self): + # 更新工具清单显示 +``` + +--- + +## 数据流 + +``` +MaiBot 启动 + │ + ▼ +MCPBridgePlugin.__init__() + ├─ mcp_manager.configure(settings) + ├─ tool_call_tracer.configure(...) + ├─ tool_call_cache.configure(...) + └─ permission_checker.configure(...) + │ + ▼ +ON_START 事件 → MCPStartupHandler.execute() + │ + ▼ +_async_connect_servers() + ├─ 解析 servers.list JSON + ├─ 遍历服务器配置 + │ ├─ mcp_manager.add_server(config) + │ ├─ 获取工具列表 + │ ├─ 检查 disabled_tools + │ └─ component_registry.register_component(tool_info, tool_class) + ├─ _update_status_display() + └─ _update_tool_list_display() + │ + ▼ +mcp_manager.start_heartbeat() + │ + ▼ +LLM 调用工具 → MCPToolProxy.execute(function_args) + ├─ 1. permission_checker.check() → 拒绝则返回错误 + ├─ 2. tool_call_cache.get() → 命中则跳到步骤 5 + ├─ 3. mcp_manager.call_tool() + ├─ 4. tool_call_cache.set() + ├─ 5. _post_process_result() (如果启用且超过阈值) + ├─ 6. tool_call_tracer.record() + └─ 7. 返回 {"name": ..., "content": ...} + │ + ▼ +ON_STOP 事件 → MCPStopHandler.execute() + │ + ▼ +mcp_manager.shutdown() +mcp_tool_registry.clear() +``` + +--- + +## 配置项速查 + +### settings(高级设置) + +| 配置项 | 类型 | 默认值 | 说明 | +|--------|------|--------|------| +| tool_prefix | str | "mcp" | 工具名前缀 | +| connect_timeout | float | 30.0 | 连接超时(秒) | +| call_timeout | float | 60.0 | 调用超时(秒) | +| auto_connect | bool | true | 自动连接 | +| retry_attempts | int | 3 | 重试次数 | +| retry_interval | float | 5.0 | 重试间隔 | +| heartbeat_enabled | bool | true | 心跳检测 | +| heartbeat_interval | float | 60.0 | 心跳间隔 | +| auto_reconnect | bool | true | 自动重连 | +| max_reconnect_attempts | int | 3 | 最大重连次数 | +| enable_resources | bool | false | Resources 支持 | +| enable_prompts | bool | false | Prompts 支持 | +| post_process_enabled | bool | false | 结果后处理 | +| post_process_threshold | int | 500 | 后处理阈值 | +| trace_enabled | bool | true | 调用追踪 | +| trace_max_records | int | 100 | 追踪记录上限 | +| cache_enabled | bool | false | 调用缓存 | +| cache_ttl | int | 300 | 缓存 TTL | +| cache_max_entries | int | 200 | 最大缓存条目 | + +### permissions(权限控制) + +| 配置项 | 说明 | +|--------|------| +| perm_enabled | 启用权限控制 | +| perm_default_mode | allow_all / deny_all | +| quick_deny_groups | 禁用群列表(每行一个群号) | +| quick_allow_users | 管理员白名单(每行一个 QQ 号) | +| perm_rules | 高级规则 JSON | + +--- + +## 扩展开发示例 + +### 添加新命令子命令 + +```python +# 1. 修改 command_pattern +command_pattern = r"^[//]mcp(?:\s+(?Pstatus|...|newcmd))?..." + +# 2. 在 execute() 添加分支 +if subcommand == "newcmd": + return await self._handle_newcmd(arg) + +# 3. 实现处理方法 +async def _handle_newcmd(self, arg: str = None): + # 处理逻辑 + await self.send_text("结果") + return (True, None, True) +``` + +### 添加新配置项 + +```python +# 1. config_schema 添加 +"settings": { + "new_option": ConfigField( + type=bool, + default=False, + description="新选项说明", + label="🆕 新选项", + order=50, + ), +} + +# 2. 在 __init__ 或相应方法中读取 +new_option = settings.get("new_option", False) +``` + +### 添加新的全局模块 + +```python +# 1. 定义数据类和管理类 +@dataclass +class NewRecord: + ... + +class NewManager: + def configure(self, ...): ... + def do_something(self, ...): ... + +new_manager = NewManager() + +# 2. 在 MCPBridgePlugin.__init__ 中配置 +new_manager.configure(...) + +# 3. 在 MCPToolProxy.execute() 中使用 +result = new_manager.do_something(...) +``` + +--- + +## 调试 + +```python +# 导入 +from plugins.MCPBridgePlugin.mcp_client import mcp_manager +from plugins.MCPBridgePlugin.plugin import tool_call_tracer, tool_call_cache, permission_checker + +# 检查状态 +mcp_manager.get_status() +mcp_manager.get_all_stats() + +# 追踪记录 +tool_call_tracer.get_recent(10) + +# 缓存状态 +tool_call_cache.get_stats() + +# 手动调用 +result = await mcp_manager.call_tool("mcp_server_tool", {"arg": "value"}) +``` + +--- + +## 依赖 + +- MaiBot >= 0.11.6 +- Python >= 3.10 +- mcp >= 1.0.0 + +## 许可证 + +AGPL-3.0 diff --git a/plugins/MaiBot_MCPBridgePlugin/README.md b/plugins/MaiBot_MCPBridgePlugin/README.md new file mode 100644 index 00000000..f1c676ef --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/README.md @@ -0,0 +1,220 @@ +# MCP 桥接插件 + +将 [MCP (Model Context Protocol)](https://modelcontextprotocol.io/) 服务器的工具桥接到 MaiBot,使麦麦能够调用外部 MCP 工具。 + +image + +## 🚀 快速开始 + +### 1. 安装 + +```bash +# 克隆到 MaiBot 插件目录 +cd /path/to/MaiBot/plugins +git clone https://github.com/CharTyr/MaiBot_MCPBridgePlugin.git MCPBridgePlugin + +# 安装依赖 +pip install mcp + +# 复制配置文件 +cd MCPBridgePlugin +cp config.example.toml config.toml +``` + +### 2. 添加服务器 + +编辑 `config.toml`,在 `[servers]` 的 `list` 中添加服务器: + +**免费服务器:** +```json +{"name": "time", "enabled": true, "transport": "streamable_http", "url": "https://mcp.api-inference.modelscope.cn/server/mcp-server-time"} +``` + +**带鉴权的服务器(v1.4.2):** +```json +{"name": "my-server", "enabled": true, "transport": "streamable_http", "url": "https://mcp.xxx.com/mcp", "headers": {"Authorization": "Bearer 你的密钥"}} +``` + +**本地服务器(需要 uvx):** +```json +{"name": "fetch", "enabled": true, "transport": "stdio", "command": "uvx", "args": ["mcp-server-fetch"]} +``` + +### 3. 启动 + +重启 MaiBot,或发送 `/mcp reconnect` + +--- + +## 📚 去哪找 MCP 服务器? + +| 平台 | 说明 | +|------|------| +| [mcp.modelscope.cn](https://mcp.modelscope.cn/) | 魔搭 ModelScope,免费推荐 | +| [smithery.ai](https://smithery.ai/) | MCP 服务器注册中心 | +| [github.com/modelcontextprotocol/servers](https://github.com/modelcontextprotocol/servers) | 官方服务器列表 | + +--- + +## 💡 常用命令 + +| 命令 | 说明 | +|------|------| +| `/mcp` | 查看连接状态 | +| `/mcp tools` | 查看可用工具 | +| `/mcp reconnect` | 重连服务器 | +| `/mcp trace` | 查看调用记录 | +| `/mcp cache` | 查看缓存状态 | +| `/mcp perm` | 查看权限配置 | +| `/mcp import ` | 🆕 导入 Claude Desktop 配置 | +| `/mcp export [claude]` | 🆕 导出配置 | +| `/mcp search <关键词>` | 🆕 搜索工具 | + +--- + +## ✨ 功能特性 + +### 核心功能 +- 🔌 多服务器同时连接 +- 📡 支持 stdio / SSE / HTTP / Streamable HTTP +- 🔄 自动重试、心跳检测、断线重连 +- 🖥️ WebUI 完整配置支持 + +### v1.7.0 新增 +- ⚡ **断路器模式** - 故障服务器快速失败,避免拖慢整体响应 +- 🔄 **状态实时刷新** - WebUI 自动更新连接状态(可配置间隔) +- 🔍 **工具搜索** - `/mcp search <关键词>` 快速查找工具 + +### v1.6.0 新增 +- 📥 **配置导入** - 从 Claude Desktop 格式一键导入 +- 📤 **配置导出** - 导出为 Claude Desktop / Kiro / MaiBot 格式 + +### v1.4.0 新增 +- 🚫 **工具禁用** - WebUI 直接禁用不想用的工具 +- 🔍 **调用追踪** - 记录每次调用详情,便于调试 +- 🗄️ **调用缓存** - 相同请求自动缓存 +- 🔐 **权限控制** - 按群/用户限制工具使用 + +### 高级功能 +- 📦 Resources 支持(实验性) +- 📝 Prompts 支持(实验性) +- 🔄 结果后处理(LLM 摘要提炼) + +--- + +## ⚙️ 配置说明 + +### 服务器配置 + +```json +[ + { + "name": "服务器名", + "enabled": true, + "transport": "streamable_http", + "url": "https://..." + } +] +``` + +| 字段 | 说明 | +|------|------| +| `name` | 服务器名称(唯一) | +| `enabled` | 是否启用 | +| `transport` | `stdio` / `sse` / `http` / `streamable_http` | +| `url` | 远程服务器地址 | +| `headers` | 🆕 鉴权头(如 `{"Authorization": "Bearer xxx"}`) | +| `command` / `args` | 本地服务器启动命令 | + +### 权限控制(v1.4.0) + +**快捷配置(推荐):** +```toml +[permissions] +perm_enabled = true +quick_deny_groups = "123456789" # 禁用的群号 +quick_allow_users = "111111111" # 管理员白名单 +``` + +**高级规则:** +```json +[{"tool": "mcp_*_delete_*", "denied": ["qq:123456:group"]}] +``` + +### 工具禁用 + +```toml +[tools] +disabled_tools = ''' +mcp_filesystem_delete_file +mcp_filesystem_write_file +''' +``` + +### 调用缓存 + +```toml +[settings] +cache_enabled = true +cache_ttl = 300 +cache_exclude_tools = "mcp_*_time_*" +``` + +--- + +## ❓ 常见问题 + +**Q: 工具没有注册?** +- 检查 `enabled = true` +- 检查 MaiBot 日志错误信息 +- 确认 `pip install mcp` + +**Q: JSON 格式报错?** +- 多行 JSON 用 `'''` 三引号包裹 +- 使用英文双引号 `"` + +**Q: 如何手动重连?** +- `/mcp reconnect` 或 `/mcp reconnect 服务器名` + +--- + +## 📥 配置导入导出(v1.6.0) + +### 从 Claude Desktop 导入 + +如果你已有 Claude Desktop 的 MCP 配置,可以直接导入: + +``` +/mcp import {"mcpServers":{"time":{"command":"uvx","args":["mcp-server-time"]},"fetch":{"command":"uvx","args":["mcp-server-fetch"]}}} +``` + +支持的格式: +- Claude Desktop 格式(`mcpServers` 对象) +- Kiro MCP 格式 +- MaiBot 格式(数组) + +### 导出配置 + +``` +/mcp export # 导出为 Claude Desktop 格式(默认) +/mcp export claude # 导出为 Claude Desktop 格式 +/mcp export kiro # 导出为 Kiro MCP 格式 +/mcp export maibot # 导出为 MaiBot 格式 +``` + +### 注意事项 +- 导入时会自动跳过同名服务器 +- 导入后需要发送 `/mcp reconnect` 使配置生效 +- 支持 stdio、sse、http、streamable_http 全部传输类型 + +--- + +## 📋 依赖 + +- MaiBot >= 0.11.6 +- Python >= 3.10 +- mcp >= 1.0.0 + +## 📄 许可证 + +AGPL-3.0 diff --git a/plugins/MaiBot_MCPBridgePlugin/__init__.py b/plugins/MaiBot_MCPBridgePlugin/__init__.py new file mode 100644 index 00000000..80e2ae47 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/__init__.py @@ -0,0 +1,44 @@ +""" +MCP 桥接插件 +将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot + +v1.1.0 新增功能: +- 心跳检测和自动重连 +- 调用统计(次数、成功率、耗时) +- 更好的错误处理 + +v1.2.0 新增功能: +- Resources 支持(资源读取) +- Prompts 支持(提示模板) +""" + +from .plugin import MCPBridgePlugin, mcp_tool_registry, MCPStartupHandler, MCPStopHandler +from .mcp_client import ( + mcp_manager, + MCPClientManager, + MCPServerConfig, + TransportType, + MCPCallResult, + MCPToolInfo, + MCPResourceInfo, + MCPPromptInfo, + ToolCallStats, + ServerStats, +) + +__all__ = [ + "MCPBridgePlugin", + "mcp_tool_registry", + "mcp_manager", + "MCPClientManager", + "MCPServerConfig", + "TransportType", + "MCPCallResult", + "MCPToolInfo", + "MCPResourceInfo", + "MCPPromptInfo", + "ToolCallStats", + "ServerStats", + "MCPStartupHandler", + "MCPStopHandler", +] diff --git a/plugins/MaiBot_MCPBridgePlugin/_manifest.json b/plugins/MaiBot_MCPBridgePlugin/_manifest.json new file mode 100644 index 00000000..002bc782 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/_manifest.json @@ -0,0 +1,60 @@ +{ + "manifest_version": 1, + "name": "MCP桥接插件", + "version": "1.7.0", + "description": "将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot,使麦麦能够调用外部 MCP 工具", + "author": { + "name": "CharTyr", + "url": "https://github.com/CharTyr" + }, + "license": "AGPL-3.0", + "host_application": { + "min_version": "0.11.6" + }, + "homepage_url": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin", + "repository_url": "https://github.com/CharTyr/MaiBot_MCPBridgePlugin", + "keywords": [ + "mcp", + "bridge", + "tool", + "integration", + "resources", + "prompts", + "post-process", + "cache", + "trace", + "permissions", + "import", + "export", + "claude-desktop" + ], + "categories": [ + "工具扩展", + "外部集成" + ], + "default_locale": "zh-CN", + "plugin_info": { + "is_built_in": false, + "components": [], + "features": [ + "支持多个 MCP 服务器", + "自动发现并注册 MCP 工具", + "支持 stdio、SSE、HTTP、Streamable HTTP 四种传输方式", + "工具参数自动转换", + "心跳检测与自动重连", + "调用统计(次数、成功率、耗时)", + "WebUI 配置支持", + "Resources 支持(实验性)", + "Prompts 支持(实验性)", + "结果后处理(LLM 摘要提炼)", + "工具禁用管理", + "调用链路追踪", + "工具调用缓存(LRU)", + "工具权限控制(群/用户级别)", + "配置导入导出(Claude Desktop / Kiro 格式)", + "断路器模式(故障快速失败)", + "状态实时刷新" + ] + }, + "id": "MaiBot Community.MCPBridgePlugin" +} diff --git a/plugins/MaiBot_MCPBridgePlugin/config.example.toml b/plugins/MaiBot_MCPBridgePlugin/config.example.toml new file mode 100644 index 00000000..a45a16ee --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/config.example.toml @@ -0,0 +1,263 @@ +# MCP桥接插件 v1.7.0 - 配置文件示例 +# 将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot +# +# 使用方法:复制此文件为 config.toml,然后根据需要修改配置 +# +# ============================================================ +# 🎯 快速开始(三步) +# ============================================================ +# 1. 在下方 [servers] 添加 MCP 服务器配置 +# 2. 将 enabled 改为 true 启用服务器 +# 3. 重启 MaiBot 或发送 /mcp reconnect +# +# ============================================================ +# 📚 去哪找 MCP 服务器? +# ============================================================ +# +# 【远程服务(推荐新手)】 +# - ModelScope: https://mcp.modelscope.cn/ (免费,推荐) +# - Smithery: https://smithery.ai/ +# - Glama: https://glama.ai/mcp/servers +# +# 【本地服务(需要 npx 或 uvx)】 +# - 官方列表: https://github.com/modelcontextprotocol/servers +# +# ============================================================ + +# ============================================================ +# 插件基本信息 +# ============================================================ +[plugin] +name = "mcp_bridge_plugin" +version = "1.7.0" +config_version = "1.7.0" +enabled = false # 默认禁用,在 WebUI 中启用 + +# ============================================================ +# 全局设置 +# ============================================================ +[settings] +# 🏷️ 工具前缀 - 用于区分 MCP 工具和原生工具 +tool_prefix = "mcp" + +# ⏱️ 连接超时(秒) +connect_timeout = 30.0 + +# ⏱️ 调用超时(秒) +call_timeout = 60.0 + +# 🔄 自动连接 - 启动时自动连接所有已启用的服务器 +auto_connect = true + +# 🔁 重试次数 - 连接失败时的重试次数 +retry_attempts = 3 + +# ⏳ 重试间隔(秒) +retry_interval = 5.0 + +# 💓 心跳检测 - 定期检测服务器连接状态 +heartbeat_enabled = true + +# 💓 心跳间隔(秒)- 建议 30-120 秒 +heartbeat_interval = 60.0 + +# 🔄 自动重连 - 检测到断开时自动尝试重连 +auto_reconnect = true + +# 🔄 最大重连次数 - 连续重连失败后暂停重连 +max_reconnect_attempts = 3 + +# ============================================================ +# v1.2.0 高级功能(实验性) +# ============================================================ +# 📦 启用 Resources - 允许读取 MCP 服务器提供的资源 +enable_resources = false + +# 📝 启用 Prompts - 允许使用 MCP 服务器提供的提示模板 +enable_prompts = false + +# ============================================================ +# v1.3.0 结果后处理功能 +# ============================================================ +# 当 MCP 工具返回的内容过长时,使用 LLM 对结果进行摘要提炼 + +# 🔄 启用结果后处理 +post_process_enabled = false + +# 📏 后处理阈值(字符数)- 结果长度超过此值才触发后处理 +post_process_threshold = 500 + +# � 后处理输e出限制 - LLM 摘要输出的最大 token 数 +post_process_max_tokens = 500 + +# 🤖 后处理模型(可选)- 留空则使用 utils 模型组 +post_process_model = "" + +# � 后处理提示词模板- +post_process_prompt = '''用户问题:{query} + +工具返回内容: +{result} + +请从上述内容中提取与用户问题最相关的关键信息,简洁准确地输出:''' + +# ============================================================ +# 🆕 v1.4.0 调用链路追踪 +# ============================================================ +# 记录工具调用详情,便于调试和分析 + +# 🔍 启用调用追踪 +trace_enabled = true + +# 📊 追踪记录上限 - 内存中保留的最大记录数 +trace_max_records = 50 + +# 📝 追踪日志文件 - 是否将追踪记录写入日志文件 +# 启用后记录写入 plugins/MaiBot_MCPBridgePlugin/logs/trace.jsonl +trace_log_enabled = false + +# ============================================================ +# 🆕 v1.4.0 工具调用缓存 +# ============================================================ +# 缓存相同参数的调用结果,减少重复请求 + +# 🗄️ 启用调用缓存 +cache_enabled = false + +# ⏱️ 缓存有效期(秒) +cache_ttl = 300 + +# 📦 最大缓存条目 - 超出后 LRU 淘汰 +cache_max_entries = 200 + +# � 缓存排除列表 - 即不缓存的工具(每行一个,支持通配符 *) +# 时间类、随机类工具建议排除 +cache_exclude_tools = ''' +mcp_*_time_* +mcp_*_random_* +''' + +# ============================================================ +# 🆕 v1.4.0 工具管理 +# ============================================================ +[tools] +# 📋 工具清单(只读)- 启动后自动生成 +tool_list = "(启动后自动生成)" + +# 🚫 禁用工具列表 - 要禁用的工具名(每行一个) +# 从上方工具清单复制工具名,禁用后该工具不会被 LLM 调用 +# 示例: +# disabled_tools = ''' +# mcp_filesystem_delete_file +# mcp_filesystem_write_file +# ''' +disabled_tools = "" + +# ============================================================ +# 🆕 v1.4.0 权限控制 +# ============================================================ +[permissions] +# 🔐 启用权限控制 - 按群/用户限制工具使用 +perm_enabled = false + +# 📋 默认模式 +# allow_all: 未配置规则的工具默认允许 +# deny_all: 未配置规则的工具默认禁止 +perm_default_mode = "allow_all" + +# ──────────────────────────────────────────────────────────── +# 🚀 快捷配置(推荐新手使用) +# ──────────────────────────────────────────────────────────── + +# 🚫 禁用群列表 - 这些群无法使用任何 MCP 工具(每行一个群号) +# 示例: +# quick_deny_groups = ''' +# 123456789 +# 987654321 +# ''' +quick_deny_groups = "" + +# ✅ 管理员白名单 - 这些用户始终可以使用所有工具(每行一个QQ号) +# 示例: +# quick_allow_users = ''' +# 111111111 +# ''' +quick_allow_users = "" + +# ──────────────────────────────────────────────────────────── +# 📜 高级权限规则(可选,针对特定工具配置) +# ──────────────────────────────────────────────────────────── +# 格式: qq:ID:group/private/user,工具名支持通配符 * +# 示例: +# perm_rules = ''' +# [ +# {"tool": "mcp_*_delete_*", "denied": ["qq:123456:group"]} +# ] +# ''' +perm_rules = "[]" + +# ============================================================ +# 🔌 MCP 服务器配置 +# ============================================================ +# +# ⚠️ 重要:JSON 格式说明 +# ──────────────────────────────────────────────────────────── +# 服务器列表必须是 JSON 数组格式! +# +# ❌ 错误写法: +# { "name": "server1", ... }, +# { "name": "server2", ... } +# +# ✅ 正确写法: +# [ +# { "name": "server1", ... }, +# { "name": "server2", ... } +# ] +# +# ──────────────────────────────────────────────────────────── +# 每个服务器的配置字段: +# name - 服务器名称(唯一标识) +# enabled - 是否启用 (true/false) +# transport - 传输方式: "stdio" / "sse" / "http" / "streamable_http" +# url - 服务器地址(sse/http/streamable_http 模式必填) +# headers - 🆕 鉴权头(可选,如 {"Authorization": "Bearer xxx"}) +# command - 启动命令(stdio 模式,如 "npx" 或 "uvx") +# args - 命令参数数组(stdio 模式) +# env - 环境变量对象(stdio 模式,可选) +# post_process - 服务器级别后处理配置(可选) +# +# ============================================================ + +[servers] +list = ''' +[ + { + "name": "time-mcp-server", + "enabled": false, + "transport": "streamable_http", + "url": "https://mcp.api-inference.modelscope.cn/server/mcp-server-time" + }, + { + "name": "my-auth-server", + "enabled": false, + "transport": "streamable_http", + "url": "https://mcp.api-inference.modelscope.net/xxxxxx/mcp", + "headers": { + "Authorization": "Bearer ms-xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx" + } + }, + { + "name": "fetch-local", + "enabled": false, + "transport": "stdio", + "command": "uvx", + "args": ["mcp-server-fetch"] + } +] +''' + +# ============================================================ +# 状态显示(只读) +# ============================================================ +[status] +connection_status = "未初始化" diff --git a/plugins/MaiBot_MCPBridgePlugin/config_converter.py b/plugins/MaiBot_MCPBridgePlugin/config_converter.py new file mode 100644 index 00000000..16f9e028 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/config_converter.py @@ -0,0 +1,448 @@ +""" +MCP 配置格式转换模块 v1.0.0 + +支持的格式: +- Claude Desktop (claude_desktop_config.json) +- Kiro MCP (mcp.json) +- MaiBot MCP Bridge Plugin (本插件格式) + +转换规则: +- stdio: command + args + env +- sse/http/streamable_http: url + headers +""" + +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple + + +@dataclass +class ConversionResult: + """转换结果""" + success: bool + servers: List[Dict[str, Any]] = field(default_factory=list) + errors: List[str] = field(default_factory=list) + warnings: List[str] = field(default_factory=list) + skipped: List[str] = field(default_factory=list) + + +class ConfigConverter: + """MCP 配置格式转换器""" + + # transport 类型映射 (外部格式 -> 内部格式) + TRANSPORT_MAP_IN = { + "sse": "sse", + "http": "http", + "streamable-http": "streamable_http", + "streamable_http": "streamable_http", + "streamable-http": "streamable_http", + "stdio": "stdio", + } + + # 支持的 transport 字段名(有些格式用 type 而不是 transport) + TRANSPORT_FIELD_NAMES = ["transport", "type"] + + # transport 类型映射 (内部格式 -> Claude 格式) + TRANSPORT_MAP_OUT = { + "sse": "sse", + "http": "http", + "streamable_http": "streamable-http", + "stdio": "stdio", + } + + @classmethod + def detect_format(cls, config: Dict[str, Any]) -> Optional[str]: + """检测配置格式类型 + + Returns: + "claude": Claude Desktop 格式 (mcpServers 对象) + "kiro": Kiro MCP 格式 (mcpServers 对象,与 Claude 相同) + "maibot": MaiBot 插件格式 (数组) + None: 无法识别 + """ + if isinstance(config, list): + # 数组格式,检查是否是 MaiBot 格式 + if len(config) == 0: + return "maibot" + if isinstance(config[0], dict) and "name" in config[0]: + return "maibot" + return None + + if isinstance(config, dict): + # 对象格式 + if "mcpServers" in config: + return "claude" # Claude 和 Kiro 格式相同 + # 可能是单个服务器配置 + if "name" in config: + return "maibot_single" + return None + + return None + + @classmethod + def parse_json_safe(cls, json_str: str) -> Tuple[Optional[Any], Optional[str]]: + """安全解析 JSON 字符串 + + Returns: + (解析结果, 错误信息) + """ + if not json_str or not json_str.strip(): + return None, "输入为空" + + json_str = json_str.strip() + + try: + return json.loads(json_str), None + except json.JSONDecodeError as e: + # 尝试提供更友好的错误信息 + line = e.lineno + col = e.colno + return None, f"JSON 解析失败 (行 {line}, 列 {col}): {e.msg}" + + @classmethod + def validate_server_config(cls, name: str, config: Dict[str, Any]) -> Tuple[bool, Optional[str], List[str]]: + """验证单个服务器配置 + + Args: + name: 服务器名称 + config: 服务器配置字典 + + Returns: + (是否有效, 错误信息, 警告列表) + """ + warnings = [] + + if not isinstance(config, dict): + return False, f"服务器 '{name}' 配置必须是对象", [] + + has_command = "command" in config + has_url = "url" in config + + # 必须有 command 或 url 之一 + if not has_command and not has_url: + return False, f"服务器 '{name}' 缺少 'command' 或 'url' 字段", [] + + # 同时有 command 和 url 时给出警告 + if has_command and has_url: + warnings.append(f"'{name}': 同时存在 command 和 url,将优先使用 stdio 模式") + + # 验证 url 格式 + if has_url and not has_command: + url = config.get("url", "") + if not isinstance(url, str): + return False, f"服务器 '{name}' 的 url 必须是字符串", [] + if not url.startswith(("http://", "https://")): + warnings.append(f"'{name}': url 不是标准 HTTP(S) 地址") + + # 验证 command 格式 + if has_command: + command = config.get("command", "") + if not isinstance(command, str): + return False, f"服务器 '{name}' 的 command 必须是字符串", [] + if not command.strip(): + return False, f"服务器 '{name}' 的 command 不能为空", [] + + # 验证 args 格式 + if "args" in config: + args = config.get("args") + if not isinstance(args, list): + return False, f"服务器 '{name}' 的 args 必须是数组", [] + for i, arg in enumerate(args): + if not isinstance(arg, str): + warnings.append(f"'{name}': args[{i}] 不是字符串,将自动转换") + + # 验证 env 格式 + if "env" in config: + env = config.get("env") + if not isinstance(env, dict): + return False, f"服务器 '{name}' 的 env 必须是对象", [] + + # 验证 headers 格式 + if "headers" in config: + headers = config.get("headers") + if not isinstance(headers, dict): + return False, f"服务器 '{name}' 的 headers 必须是对象", [] + + # 验证 transport/type 格式 + transport_value = None + for field_name in cls.TRANSPORT_FIELD_NAMES: + if field_name in config: + transport_value = config.get(field_name, "").lower() + break + if transport_value and transport_value not in cls.TRANSPORT_MAP_IN: + warnings.append(f"'{name}': 未知的 transport 类型 '{transport_value}',将自动推断") + + return True, None, warnings + + @classmethod + def convert_claude_server(cls, name: str, config: Dict[str, Any]) -> Dict[str, Any]: + """将单个 Claude 格式服务器配置转换为 MaiBot 格式 + + Args: + name: 服务器名称 + config: Claude 格式的服务器配置 + + Returns: + MaiBot 格式的服务器配置 + """ + result = { + "name": name, + "enabled": True, + } + + has_command = "command" in config + + if has_command: + # stdio 模式 + result["transport"] = "stdio" + result["command"] = config.get("command", "") + + # 处理 args + args = config.get("args", []) + if args: + # 确保所有 args 都是字符串 + result["args"] = [str(arg) for arg in args] + + # 处理 env + env = config.get("env", {}) + if env and isinstance(env, dict): + result["env"] = env + + else: + # 远程模式 (sse/http/streamable_http) + # 支持 transport 或 type 字段 + transport_raw = None + for field_name in cls.TRANSPORT_FIELD_NAMES: + if field_name in config: + transport_raw = config.get(field_name, "").lower() + break + if not transport_raw: + transport_raw = "sse" + result["transport"] = cls.TRANSPORT_MAP_IN.get(transport_raw, "sse") + result["url"] = config.get("url", "") + + # 处理 headers + headers = config.get("headers", {}) + if headers and isinstance(headers, dict): + result["headers"] = headers + + return result + + @classmethod + def convert_maibot_server(cls, config: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]: + """将单个 MaiBot 格式服务器配置转换为 Claude 格式 + + Args: + config: MaiBot 格式的服务器配置 + + Returns: + (服务器名称, Claude 格式的服务器配置) + """ + name = config.get("name", "unnamed") + result = {} + + transport = config.get("transport", "stdio").lower() + + if transport == "stdio": + # stdio 模式 + result["command"] = config.get("command", "") + + args = config.get("args", []) + if args: + result["args"] = args + + env = config.get("env", {}) + if env: + result["env"] = env + + else: + # 远程模式 + result["url"] = config.get("url", "") + + # 转换 transport 名称 + claude_transport = cls.TRANSPORT_MAP_OUT.get(transport, "sse") + if claude_transport != "sse": # sse 是默认值,可以省略 + result["transport"] = claude_transport + + headers = config.get("headers", {}) + if headers: + result["headers"] = headers + + return name, result + + @classmethod + def from_claude_format( + cls, + config: Dict[str, Any], + existing_names: Optional[set] = None + ) -> ConversionResult: + """从 Claude Desktop 格式转换为 MaiBot 格式 + + Args: + config: Claude Desktop 配置 (包含 mcpServers 字段) + existing_names: 已存在的服务器名称集合,用于跳过重复 + + Returns: + ConversionResult + """ + result = ConversionResult(success=True) + existing_names = existing_names or set() + + # 检查格式 + if not isinstance(config, dict): + result.success = False + result.errors.append("配置必须是 JSON 对象") + return result + + mcp_servers = config.get("mcpServers", {}) + + if not isinstance(mcp_servers, dict): + result.success = False + result.errors.append("mcpServers 必须是对象") + return result + + if not mcp_servers: + result.warnings.append("mcpServers 为空,没有服务器可导入") + return result + + # 转换每个服务器 + for name, srv_config in mcp_servers.items(): + # 检查名称是否已存在 + if name in existing_names: + result.skipped.append(f"'{name}' (已存在)") + continue + + # 验证配置 + valid, error, warnings = cls.validate_server_config(name, srv_config) + result.warnings.extend(warnings) + + if not valid: + result.errors.append(error) + continue + + # 转换配置 + try: + converted = cls.convert_claude_server(name, srv_config) + result.servers.append(converted) + except Exception as e: + result.errors.append(f"转换服务器 '{name}' 失败: {str(e)}") + + # 如果有错误但也有成功的,仍然标记为成功(部分成功) + if result.errors and not result.servers: + result.success = False + + return result + + @classmethod + def to_claude_format(cls, servers: List[Dict[str, Any]]) -> Dict[str, Any]: + """将 MaiBot 格式转换为 Claude Desktop 格式 + + Args: + servers: MaiBot 格式的服务器列表 + + Returns: + Claude Desktop 格式的配置 + """ + mcp_servers = {} + + for srv in servers: + if not isinstance(srv, dict): + continue + + name, config = cls.convert_maibot_server(srv) + mcp_servers[name] = config + + return {"mcpServers": mcp_servers} + + @classmethod + def import_from_string( + cls, + json_str: str, + existing_names: Optional[set] = None + ) -> ConversionResult: + """从 JSON 字符串导入配置 + + 自动检测格式并转换为 MaiBot 格式 + + Args: + json_str: JSON 字符串 + existing_names: 已存在的服务器名称集合 + + Returns: + ConversionResult + """ + result = ConversionResult(success=True) + existing_names = existing_names or set() + + # 解析 JSON + parsed, error = cls.parse_json_safe(json_str) + if error: + result.success = False + result.errors.append(error) + return result + + # 检测格式 + fmt = cls.detect_format(parsed) + + if fmt is None: + result.success = False + result.errors.append("无法识别的配置格式") + return result + + if fmt == "maibot": + # 已经是 MaiBot 格式,直接验证并返回 + for srv in parsed: + if not isinstance(srv, dict): + result.warnings.append("跳过非对象元素") + continue + + name = srv.get("name", "") + if not name: + result.warnings.append("跳过缺少 name 的服务器") + continue + + if name in existing_names: + result.skipped.append(f"'{name}' (已存在)") + continue + + result.servers.append(srv) + + elif fmt == "maibot_single": + # 单个 MaiBot 格式服务器 + name = parsed.get("name", "") + if name in existing_names: + result.skipped.append(f"'{name}' (已存在)") + else: + result.servers.append(parsed) + + elif fmt in ("claude", "kiro"): + # Claude/Kiro 格式 + return cls.from_claude_format(parsed, existing_names) + + return result + + @classmethod + def export_to_string( + cls, + servers: List[Dict[str, Any]], + format_type: str = "claude", + pretty: bool = True + ) -> str: + """导出配置为 JSON 字符串 + + Args: + servers: MaiBot 格式的服务器列表 + format_type: 导出格式 ("claude", "kiro", "maibot") + pretty: 是否格式化输出 + + Returns: + JSON 字符串 + """ + indent = 2 if pretty else None + + if format_type in ("claude", "kiro"): + config = cls.to_claude_format(servers) + else: + config = servers + + return json.dumps(config, ensure_ascii=False, indent=indent) diff --git a/plugins/MaiBot_MCPBridgePlugin/mcp_client.py b/plugins/MaiBot_MCPBridgePlugin/mcp_client.py new file mode 100644 index 00000000..0d4eebff --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/mcp_client.py @@ -0,0 +1,1542 @@ +""" +MCP 客户端封装模块 +负责与 MCP 服务器建立连接、获取工具列表、执行工具调用 + +v1.7.0 稳定性优化: +- 断路器模式:连续失败 5 次后熔断,60 秒后试探恢复 +- 熔断期间快速失败,避免等待超时 +- 连接成功时自动重置断路器 + +v1.5.2 性能优化: +- 智能心跳间隔:根据服务器稳定性动态调整心跳频率 +- 稳定服务器逐渐增加间隔(最高 3x),减少不必要的检测 +- 断开的服务器使用较短间隔快速重连 + +v1.1.0 新增功能: +- 调用统计(次数、成功率、耗时) +- 心跳检测 +- 自动重连 +- 更好的错误处理 + +v1.2.0 新增功能: +- Resources 支持(资源读取) +- Prompts 支持(提示模板) +- 新增配置项: enable_resources, enable_prompts +""" + +import asyncio +import time +import logging +from typing import Any, Dict, List, Optional, Tuple +from dataclasses import dataclass, field +from enum import Enum + +# 尝试导入 MaiBot 的 logger,如果失败则使用标准 logging +try: + from src.common.logger import get_logger + logger = get_logger("mcp_client") +except ImportError: + # Fallback: 使用标准 logging + logger = logging.getLogger("mcp_client") + if not logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter('[%(levelname)s] %(name)s: %(message)s')) + logger.addHandler(handler) + logger.setLevel(logging.INFO) + + +class TransportType(Enum): + """MCP 传输类型""" + STDIO = "stdio" # 本地进程通信 + SSE = "sse" # Server-Sent Events (旧版 HTTP) + HTTP = "http" # HTTP Streamable (新版,推荐) + STREAMABLE_HTTP = "streamable_http" # HTTP Streamable 的别名 + + +@dataclass +class MCPToolInfo: + """MCP 工具信息""" + name: str + description: str + input_schema: Dict[str, Any] + server_name: str + + +@dataclass +class MCPResourceInfo: + """MCP 资源信息""" + uri: str + name: str + description: str + mime_type: Optional[str] + server_name: str + + +@dataclass +class MCPPromptInfo: + """MCP 提示模板信息""" + name: str + description: str + arguments: List[Dict[str, Any]] # [{name, description, required}] + server_name: str + + +@dataclass +class MCPServerConfig: + """MCP 服务器配置""" + name: str + enabled: bool = True + transport: TransportType = TransportType.STDIO + # stdio 配置 + command: str = "" + args: List[str] = field(default_factory=list) + env: Dict[str, str] = field(default_factory=dict) + # http/sse 配置 + url: str = "" + headers: Dict[str, str] = field(default_factory=dict) # v1.4.2: 鉴权头支持 + + +@dataclass +class MCPCallResult: + """MCP 工具调用结果""" + success: bool + content: Any + error: Optional[str] = None + duration_ms: float = 0.0 # 调用耗时(毫秒) + circuit_broken: bool = False # v1.7.0: 是否被断路器拦截 + + +class CircuitState(Enum): + """断路器状态""" + CLOSED = "closed" # 正常状态,允许请求 + OPEN = "open" # 熔断状态,拒绝请求 + HALF_OPEN = "half_open" # 半开状态,允许少量试探请求 + + +@dataclass +class CircuitBreaker: + """v1.7.0: 断路器 - 防止对故障服务器持续请求 + + 状态转换: + - CLOSED -> OPEN: 连续失败次数达到阈值 + - OPEN -> HALF_OPEN: 熔断时间到期 + - HALF_OPEN -> CLOSED: 试探请求成功 + - HALF_OPEN -> OPEN: 试探请求失败 + """ + + # 配置 + failure_threshold: int = 5 # 连续失败多少次后熔断 + recovery_timeout: float = 60.0 # 熔断后多久尝试恢复(秒) + half_open_max_calls: int = 1 # 半开状态最多允许几次试探调用 + + # 状态 + state: CircuitState = field(default=CircuitState.CLOSED) + failure_count: int = 0 + success_count: int = 0 + last_failure_time: float = 0.0 + last_state_change: float = field(default_factory=time.time) + half_open_calls: int = 0 + + def can_execute(self) -> Tuple[bool, Optional[str]]: + """检查是否允许执行请求 + + Returns: + (是否允许, 拒绝原因) + """ + current_time = time.time() + + if self.state == CircuitState.CLOSED: + return True, None + + if self.state == CircuitState.OPEN: + # 检查是否到了恢复时间 + time_since_failure = current_time - self.last_failure_time + if time_since_failure >= self.recovery_timeout: + # 转换到半开状态 + self._transition_to(CircuitState.HALF_OPEN) + return True, None + else: + remaining = self.recovery_timeout - time_since_failure + return False, f"断路器熔断中,{remaining:.0f}秒后重试" + + if self.state == CircuitState.HALF_OPEN: + # 半开状态,检查是否还有试探配额 + if self.half_open_calls < self.half_open_max_calls: + return True, None + else: + return False, "断路器半开状态,等待试探结果" + + return True, None + + def record_success(self) -> None: + """记录成功调用""" + self.success_count += 1 + + if self.state == CircuitState.HALF_OPEN: + # 半开状态下成功,恢复到关闭状态 + self._transition_to(CircuitState.CLOSED) + logger.info("断路器恢复正常(试探成功)") + elif self.state == CircuitState.CLOSED: + # 正常状态下成功,重置失败计数 + self.failure_count = 0 + + def record_failure(self) -> None: + """记录失败调用""" + self.failure_count += 1 + self.last_failure_time = time.time() + + if self.state == CircuitState.HALF_OPEN: + # 半开状态下失败,重新熔断 + self._transition_to(CircuitState.OPEN) + logger.warning("断路器重新熔断(试探失败)") + elif self.state == CircuitState.CLOSED: + # 检查是否达到熔断阈值 + if self.failure_count >= self.failure_threshold: + self._transition_to(CircuitState.OPEN) + logger.warning(f"断路器熔断(连续失败 {self.failure_count} 次)") + + def _transition_to(self, new_state: CircuitState) -> None: + """状态转换""" + old_state = self.state + self.state = new_state + self.last_state_change = time.time() + + if new_state == CircuitState.CLOSED: + self.failure_count = 0 + self.half_open_calls = 0 + elif new_state == CircuitState.HALF_OPEN: + self.half_open_calls = 0 + + logger.debug(f"断路器状态: {old_state.value} -> {new_state.value}") + + def reset(self) -> None: + """重置断路器""" + self.state = CircuitState.CLOSED + self.failure_count = 0 + self.success_count = 0 + self.half_open_calls = 0 + self.last_state_change = time.time() + + def get_status(self) -> Dict[str, Any]: + """获取断路器状态""" + return { + "state": self.state.value, + "failure_count": self.failure_count, + "success_count": self.success_count, + "failure_threshold": self.failure_threshold, + "recovery_timeout": self.recovery_timeout, + "time_since_last_failure": time.time() - self.last_failure_time if self.last_failure_time > 0 else None, + } + + +@dataclass +class ToolCallStats: + """工具调用统计""" + tool_key: str + total_calls: int = 0 + success_calls: int = 0 + failed_calls: int = 0 + total_duration_ms: float = 0.0 + last_call_time: Optional[float] = None + last_error: Optional[str] = None + + @property + def success_rate(self) -> float: + """成功率(0-100)""" + if self.total_calls == 0: + return 0.0 + return (self.success_calls / self.total_calls) * 100 + + @property + def avg_duration_ms(self) -> float: + """平均耗时(毫秒)""" + if self.success_calls == 0: + return 0.0 + return self.total_duration_ms / self.success_calls + + def record_call(self, success: bool, duration_ms: float, error: Optional[str] = None) -> None: + """记录一次调用""" + self.total_calls += 1 + self.last_call_time = time.time() + if success: + self.success_calls += 1 + self.total_duration_ms += duration_ms + else: + self.failed_calls += 1 + self.last_error = error + + def to_dict(self) -> Dict[str, Any]: + """转换为字典""" + return { + "tool_key": self.tool_key, + "total_calls": self.total_calls, + "success_calls": self.success_calls, + "failed_calls": self.failed_calls, + "success_rate": round(self.success_rate, 2), + "avg_duration_ms": round(self.avg_duration_ms, 2), + "last_call_time": self.last_call_time, + "last_error": self.last_error, + } + + +@dataclass +class ServerStats: + """服务器统计""" + server_name: str + connect_count: int = 0 # 连接次数 + disconnect_count: int = 0 # 断开次数 + reconnect_count: int = 0 # 重连次数 + last_connect_time: Optional[float] = None + last_disconnect_time: Optional[float] = None + last_heartbeat_time: Optional[float] = None + consecutive_failures: int = 0 # 连续失败次数 + + def record_connect(self) -> None: + self.connect_count += 1 + self.last_connect_time = time.time() + self.consecutive_failures = 0 + + def record_disconnect(self) -> None: + self.disconnect_count += 1 + self.last_disconnect_time = time.time() + + def record_reconnect(self) -> None: + self.reconnect_count += 1 + self.consecutive_failures = 0 + + def record_failure(self) -> None: + self.consecutive_failures += 1 + + def record_heartbeat(self) -> None: + self.last_heartbeat_time = time.time() + + def to_dict(self) -> Dict[str, Any]: + return { + "server_name": self.server_name, + "connect_count": self.connect_count, + "disconnect_count": self.disconnect_count, + "reconnect_count": self.reconnect_count, + "last_connect_time": self.last_connect_time, + "last_disconnect_time": self.last_disconnect_time, + "last_heartbeat_time": self.last_heartbeat_time, + "consecutive_failures": self.consecutive_failures, + } + + +class MCPClientSession: + """MCP 客户端会话,管理与单个 MCP 服务器的连接""" + + def __init__(self, config: MCPServerConfig, call_timeout: float = 60.0): + self.config = config + self.call_timeout = call_timeout + self._session = None + self._read_stream = None + self._write_stream = None + self._process: Optional[asyncio.subprocess.Process] = None + self._tools: List[MCPToolInfo] = [] + self._resources: List[MCPResourceInfo] = [] # v1.2.0: Resources 支持 + self._prompts: List[MCPPromptInfo] = [] # v1.2.0: Prompts 支持 + self._connected = False + self._lock = asyncio.Lock() + + # 功能支持标记(服务器可能不支持某些功能) + self._supports_resources: bool = False + self._supports_prompts: bool = False + + # 统计信息 + self.stats = ServerStats(server_name=config.name) + self._tool_stats: Dict[str, ToolCallStats] = {} + + # v1.7.0: 断路器 + self._circuit_breaker = CircuitBreaker() + + @property + def is_connected(self) -> bool: + return self._connected + + @property + def tools(self) -> List[MCPToolInfo]: + return self._tools.copy() + + @property + def resources(self) -> List[MCPResourceInfo]: + """v1.2.0: 获取资源列表""" + return self._resources.copy() + + @property + def prompts(self) -> List[MCPPromptInfo]: + """v1.2.0: 获取提示模板列表""" + return self._prompts.copy() + + @property + def supports_resources(self) -> bool: + """v1.2.0: 服务器是否支持 Resources""" + return self._supports_resources + + @property + def supports_prompts(self) -> bool: + """v1.2.0: 服务器是否支持 Prompts""" + return self._supports_prompts + + @property + def server_name(self) -> str: + return self.config.name + + def get_tool_stats(self, tool_name: str) -> Optional[ToolCallStats]: + """获取工具统计""" + return self._tool_stats.get(tool_name) + + def get_circuit_breaker_status(self) -> Dict[str, Any]: + """v1.7.0: 获取断路器状态""" + return self._circuit_breaker.get_status() + + def reset_circuit_breaker(self) -> None: + """v1.7.0: 重置断路器""" + self._circuit_breaker.reset() + logger.info(f"[{self.server_name}] 断路器已重置") + + def get_all_tool_stats(self) -> Dict[str, ToolCallStats]: + """获取所有工具统计""" + return self._tool_stats.copy() + + async def connect(self) -> bool: + """连接到 MCP 服务器""" + async with self._lock: + if self._connected: + return True + + try: + success = False + if self.config.transport == TransportType.STDIO: + success = await self._connect_stdio() + elif self.config.transport == TransportType.SSE: + success = await self._connect_sse() + elif self.config.transport in (TransportType.HTTP, TransportType.STREAMABLE_HTTP): + success = await self._connect_http() + else: + logger.error(f"[{self.server_name}] 不支持的传输类型: {self.config.transport}") + return False + + if success: + self.stats.record_connect() + # v1.7.0: 连接成功时重置断路器 + self._circuit_breaker.reset() + else: + self.stats.record_failure() + return success + + except Exception as e: + logger.error(f"[{self.server_name}] 连接失败: {e}") + self._connected = False + self.stats.record_failure() + return False + + async def _connect_stdio(self) -> bool: + """通过 stdio 连接 MCP 服务器""" + try: + try: + from mcp import ClientSession, StdioServerParameters + from mcp.client.stdio import stdio_client + except ImportError: + logger.error(f"[{self.server_name}] 未安装 mcp 库,请运行: pip install mcp") + return False + + server_params = StdioServerParameters( + command=self.config.command, + args=self.config.args, + env=self.config.env if self.config.env else None + ) + + self._stdio_context = stdio_client(server_params) + self._read_stream, self._write_stream = await self._stdio_context.__aenter__() + + self._session_context = ClientSession(self._read_stream, self._write_stream) + self._session = await self._session_context.__aenter__() + + await self._session.initialize() + await self._fetch_tools() + + self._connected = True + logger.info(f"[{self.server_name}] stdio 连接成功,发现 {len(self._tools)} 个工具") + return True + + except Exception as e: + logger.error(f"[{self.server_name}] stdio 连接失败: {e}") + await self._cleanup() + return False + + async def _connect_sse(self) -> bool: + """通过 SSE 连接 MCP 服务器""" + try: + try: + from mcp import ClientSession + from mcp.client.sse import sse_client + except ImportError: + logger.error(f"[{self.server_name}] 未安装 mcp 库,请运行: pip install mcp") + return False + + if not self.config.url: + logger.error(f"[{self.server_name}] SSE 传输需要配置 url") + return False + + logger.debug(f"[{self.server_name}] 正在连接 SSE MCP 服务器: {self.config.url}") + + # v1.4.2: 支持 headers 鉴权 + sse_kwargs = { + "url": self.config.url, + "timeout": 60.0, + "sse_read_timeout": 300.0, + } + if self.config.headers: + sse_kwargs["headers"] = self.config.headers + + self._sse_context = sse_client(**sse_kwargs) + self._read_stream, self._write_stream = await self._sse_context.__aenter__() + + self._session_context = ClientSession(self._read_stream, self._write_stream) + self._session = await self._session_context.__aenter__() + + await self._session.initialize() + await self._fetch_tools() + + self._connected = True + logger.info(f"[{self.server_name}] SSE 连接成功,发现 {len(self._tools)} 个工具") + return True + + except Exception as e: + logger.error(f"[{self.server_name}] SSE 连接失败: {e}") + import traceback + logger.debug(f"[{self.server_name}] 详细错误: {traceback.format_exc()}") + await self._cleanup() + return False + + async def _connect_http(self) -> bool: + """通过 HTTP Streamable 连接 MCP 服务器""" + try: + try: + from mcp import ClientSession + from mcp.client.streamable_http import streamablehttp_client + except ImportError: + logger.error(f"[{self.server_name}] 未安装 mcp 库,请运行: pip install mcp") + return False + + if not self.config.url: + logger.error(f"[{self.server_name}] HTTP 传输需要配置 url") + return False + + logger.debug(f"[{self.server_name}] 正在连接 HTTP MCP 服务器: {self.config.url}") + + # v1.4.2: 支持 headers 鉴权 + http_kwargs = { + "url": self.config.url, + "timeout": 60.0, + "sse_read_timeout": 300.0, + } + if self.config.headers: + http_kwargs["headers"] = self.config.headers + + self._http_context = streamablehttp_client(**http_kwargs) + self._read_stream, self._write_stream, self._get_session_id = await self._http_context.__aenter__() + + self._session_context = ClientSession(self._read_stream, self._write_stream) + self._session = await self._session_context.__aenter__() + + await self._session.initialize() + await self._fetch_tools() + + self._connected = True + logger.info(f"[{self.server_name}] HTTP 连接成功,发现 {len(self._tools)} 个工具") + return True + + except Exception as e: + logger.error(f"[{self.server_name}] HTTP 连接失败: {e}") + import traceback + logger.debug(f"[{self.server_name}] 详细错误: {traceback.format_exc()}") + await self._cleanup() + return False + + async def _fetch_tools(self) -> None: + """获取 MCP 服务器的工具列表""" + if not self._session: + return + + try: + result = await self._session.list_tools() + self._tools = [] + + for tool in result.tools: + tool_info = MCPToolInfo( + name=tool.name, + description=tool.description or f"MCP tool: {tool.name}", + input_schema=tool.inputSchema if hasattr(tool, 'inputSchema') else {}, + server_name=self.server_name + ) + self._tools.append(tool_info) + # 初始化工具统计 + if tool.name not in self._tool_stats: + self._tool_stats[tool.name] = ToolCallStats(tool_key=tool.name) + logger.debug(f"[{self.server_name}] 发现工具: {tool.name}") + + except Exception as e: + logger.error(f"[{self.server_name}] 获取工具列表失败: {e}") + self._tools = [] + + async def fetch_resources(self) -> bool: + """v1.2.0: 获取 MCP 服务器的资源列表 + + Returns: + bool: 是否成功获取(服务器不支持时返回 False) + """ + if not self._session: + return False + + try: + result = await asyncio.wait_for( + self._session.list_resources(), + timeout=self.call_timeout + ) + self._resources = [] + + for resource in result.resources: + resource_info = MCPResourceInfo( + uri=str(resource.uri), + name=resource.name or str(resource.uri), + description=resource.description or "", + mime_type=resource.mimeType if hasattr(resource, 'mimeType') else None, + server_name=self.server_name + ) + self._resources.append(resource_info) + logger.debug(f"[{self.server_name}] 发现资源: {resource_info.uri}") + + self._supports_resources = True + logger.info(f"[{self.server_name}] 获取到 {len(self._resources)} 个资源") + return True + + except Exception as e: + # 服务器可能不支持 resources,这不是错误 + error_str = str(e).lower() + if "not supported" in error_str or "not implemented" in error_str or "method not found" in error_str: + logger.debug(f"[{self.server_name}] 服务器不支持 Resources 功能") + else: + logger.warning(f"[{self.server_name}] 获取资源列表失败: {e}") + self._supports_resources = False + self._resources = [] + return False + + async def fetch_prompts(self) -> bool: + """v1.2.0: 获取 MCP 服务器的提示模板列表 + + Returns: + bool: 是否成功获取(服务器不支持时返回 False) + """ + if not self._session: + return False + + try: + result = await asyncio.wait_for( + self._session.list_prompts(), + timeout=self.call_timeout + ) + self._prompts = [] + + for prompt in result.prompts: + # 解析参数 + arguments = [] + if hasattr(prompt, 'arguments') and prompt.arguments: + for arg in prompt.arguments: + arguments.append({ + "name": arg.name, + "description": arg.description or "", + "required": arg.required if hasattr(arg, 'required') else False, + }) + + prompt_info = MCPPromptInfo( + name=prompt.name, + description=prompt.description or f"MCP prompt: {prompt.name}", + arguments=arguments, + server_name=self.server_name + ) + self._prompts.append(prompt_info) + logger.debug(f"[{self.server_name}] 发现提示模板: {prompt.name}") + + self._supports_prompts = True + logger.info(f"[{self.server_name}] 获取到 {len(self._prompts)} 个提示模板") + return True + + except Exception as e: + # 服务器可能不支持 prompts,这不是错误 + error_str = str(e).lower() + if "not supported" in error_str or "not implemented" in error_str or "method not found" in error_str: + logger.debug(f"[{self.server_name}] 服务器不支持 Prompts 功能") + else: + logger.warning(f"[{self.server_name}] 获取提示模板列表失败: {e}") + self._supports_prompts = False + self._prompts = [] + return False + + async def read_resource(self, uri: str) -> MCPCallResult: + """v1.2.0: 读取指定资源的内容 + + Args: + uri: 资源 URI + + Returns: + MCPCallResult: 包含资源内容的结果 + """ + start_time = time.time() + + if not self._connected or not self._session: + return MCPCallResult( + success=False, + content=None, + error=f"服务器 {self.server_name} 未连接" + ) + + if not self._supports_resources: + return MCPCallResult( + success=False, + content=None, + error=f"服务器 {self.server_name} 不支持 Resources 功能" + ) + + try: + result = await asyncio.wait_for( + self._session.read_resource(uri), + timeout=self.call_timeout + ) + + duration_ms = (time.time() - start_time) * 1000 + + # 处理返回内容 + content_parts = [] + for content in result.contents: + if hasattr(content, 'text'): + content_parts.append(content.text) + elif hasattr(content, 'blob'): + # 二进制数据,返回 base64 或提示 + import base64 + blob_data = content.blob + if len(blob_data) < 10000: # 小于 10KB 返回 base64 + content_parts.append(f"[base64]{base64.b64encode(blob_data).decode()}") + else: + content_parts.append(f"[二进制数据: {len(blob_data)} bytes]") + else: + content_parts.append(str(content)) + + return MCPCallResult( + success=True, + content="\n".join(content_parts) if content_parts else "", + duration_ms=duration_ms + ) + + except asyncio.TimeoutError: + duration_ms = (time.time() - start_time) * 1000 + return MCPCallResult( + success=False, + content=None, + error=f"读取资源超时({self.call_timeout}秒)", + duration_ms=duration_ms + ) + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + logger.error(f"[{self.server_name}] 读取资源 {uri} 失败: {e}") + return MCPCallResult( + success=False, + content=None, + error=str(e), + duration_ms=duration_ms + ) + + async def get_prompt(self, name: str, arguments: Optional[Dict[str, str]] = None) -> MCPCallResult: + """v1.2.0: 获取提示模板的内容 + + Args: + name: 提示模板名称 + arguments: 模板参数 + + Returns: + MCPCallResult: 包含提示内容的结果 + """ + start_time = time.time() + + if not self._connected or not self._session: + return MCPCallResult( + success=False, + content=None, + error=f"服务器 {self.server_name} 未连接" + ) + + if not self._supports_prompts: + return MCPCallResult( + success=False, + content=None, + error=f"服务器 {self.server_name} 不支持 Prompts 功能" + ) + + try: + result = await asyncio.wait_for( + self._session.get_prompt(name, arguments=arguments or {}), + timeout=self.call_timeout + ) + + duration_ms = (time.time() - start_time) * 1000 + + # 处理返回的消息 + messages = [] + for msg in result.messages: + role = msg.role if hasattr(msg, 'role') else "unknown" + content_text = "" + if hasattr(msg, 'content'): + if hasattr(msg.content, 'text'): + content_text = msg.content.text + elif isinstance(msg.content, str): + content_text = msg.content + else: + content_text = str(msg.content) + messages.append(f"[{role}]: {content_text}") + + return MCPCallResult( + success=True, + content="\n\n".join(messages) if messages else "", + duration_ms=duration_ms + ) + + except asyncio.TimeoutError: + duration_ms = (time.time() - start_time) * 1000 + return MCPCallResult( + success=False, + content=None, + error=f"获取提示模板超时({self.call_timeout}秒)", + duration_ms=duration_ms + ) + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + logger.error(f"[{self.server_name}] 获取提示模板 {name} 失败: {e}") + return MCPCallResult( + success=False, + content=None, + error=str(e), + duration_ms=duration_ms + ) + + async def check_health(self) -> bool: + """检查连接健康状态(心跳检测) + + 通过调用 list_tools 来验证连接是否正常 + """ + if not self._connected or not self._session: + return False + + try: + # 使用 list_tools 作为心跳检测 + await asyncio.wait_for( + self._session.list_tools(), + timeout=10.0 + ) + self.stats.record_heartbeat() + return True + except Exception as e: + logger.warning(f"[{self.server_name}] 心跳检测失败: {e}") + # 标记为断开 + self._connected = False + self.stats.record_disconnect() + return False + + async def call_tool(self, tool_name: str, arguments: Dict[str, Any]) -> MCPCallResult: + """调用 MCP 工具""" + start_time = time.time() + + # v1.7.0: 断路器检查 + can_execute, reject_reason = self._circuit_breaker.can_execute() + if not can_execute: + return MCPCallResult( + success=False, + content=None, + error=f"⚡ {reject_reason}", + circuit_broken=True + ) + + # 半开状态下增加试探计数 + if self._circuit_breaker.state == CircuitState.HALF_OPEN: + self._circuit_breaker.half_open_calls += 1 + + if not self._connected or not self._session: + error_msg = f"服务器 {self.server_name} 未连接" + # 记录失败 + if tool_name in self._tool_stats: + self._tool_stats[tool_name].record_call(False, 0, error_msg) + self._circuit_breaker.record_failure() + return MCPCallResult(success=False, content=None, error=error_msg) + + try: + result = await asyncio.wait_for( + self._session.call_tool(tool_name, arguments=arguments), + timeout=self.call_timeout + ) + + duration_ms = (time.time() - start_time) * 1000 + + # 处理返回内容 + content_parts = [] + for content in result.content: + if hasattr(content, 'text'): + content_parts.append(content.text) + elif hasattr(content, 'data'): + content_parts.append(f"[二进制数据: {len(content.data)} bytes]") + else: + content_parts.append(str(content)) + + # 记录成功 + if tool_name in self._tool_stats: + self._tool_stats[tool_name].record_call(True, duration_ms) + + # v1.7.0: 断路器记录成功 + self._circuit_breaker.record_success() + + return MCPCallResult( + success=True, + content="\n".join(content_parts) if content_parts else "执行成功(无返回内容)", + duration_ms=duration_ms + ) + + except asyncio.TimeoutError: + duration_ms = (time.time() - start_time) * 1000 + error_msg = f"工具调用超时({self.call_timeout}秒)" + if tool_name in self._tool_stats: + self._tool_stats[tool_name].record_call(False, duration_ms, error_msg) + # v1.7.0: 断路器记录失败 + self._circuit_breaker.record_failure() + return MCPCallResult(success=False, content=None, error=error_msg, duration_ms=duration_ms) + + except Exception as e: + duration_ms = (time.time() - start_time) * 1000 + error_msg = str(e) + logger.error(f"[{self.server_name}] 调用工具 {tool_name} 失败: {e}") + if tool_name in self._tool_stats: + self._tool_stats[tool_name].record_call(False, duration_ms, error_msg) + # v1.7.0: 断路器记录失败 + self._circuit_breaker.record_failure() + # 检查是否是连接问题 + if "connection" in error_msg.lower() or "closed" in error_msg.lower(): + self._connected = False + self.stats.record_disconnect() + return MCPCallResult(success=False, content=None, error=error_msg, duration_ms=duration_ms) + + async def disconnect(self) -> None: + """断开连接""" + async with self._lock: + if self._connected: + self.stats.record_disconnect() + await self._cleanup() + + async def _cleanup(self) -> None: + """清理资源""" + self._connected = False + self._tools = [] + self._resources = [] # v1.2.0 + self._prompts = [] # v1.2.0 + self._supports_resources = False # v1.2.0 + self._supports_prompts = False # v1.2.0 + + try: + if hasattr(self, '_session_context') and self._session_context: + await self._session_context.__aexit__(None, None, None) + except Exception as e: + logger.debug(f"[{self.server_name}] 关闭会话时出错: {e}") + + try: + if hasattr(self, '_stdio_context') and self._stdio_context: + await self._stdio_context.__aexit__(None, None, None) + except Exception as e: + logger.debug(f"[{self.server_name}] 关闭 stdio 连接时出错: {e}") + + try: + if hasattr(self, '_http_context') and self._http_context: + await self._http_context.__aexit__(None, None, None) + except Exception as e: + logger.debug(f"[{self.server_name}] 关闭 HTTP 连接时出错: {e}") + + try: + if hasattr(self, '_sse_context') and self._sse_context: + await self._sse_context.__aexit__(None, None, None) + except Exception as e: + logger.debug(f"[{self.server_name}] 关闭 SSE 连接时出错: {e}") + + self._session = None + self._session_context = None + self._stdio_context = None + self._http_context = None + self._sse_context = None + self._read_stream = None + self._write_stream = None + + logger.debug(f"[{self.server_name}] 连接已关闭") + + +class MCPClientManager: + """MCP 客户端管理器,管理多个 MCP 服务器连接 + + 功能: + - 管理多个 MCP 服务器连接 + - 心跳检测和自动重连 + - 调用统计 + """ + + _instance: Optional["MCPClientManager"] = None + + def __new__(cls): + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._initialized = False + return cls._instance + + def __init__(self): + if self._initialized: + return + self._initialized = True + self._clients: Dict[str, MCPClientSession] = {} + self._all_tools: Dict[str, Tuple[MCPToolInfo, MCPClientSession]] = {} + self._all_resources: Dict[str, Tuple[MCPResourceInfo, MCPClientSession]] = {} # v1.2.0 + self._all_prompts: Dict[str, Tuple[MCPPromptInfo, MCPClientSession]] = {} # v1.2.0 + self._settings: Dict[str, Any] = {} + self._lock = asyncio.Lock() + + # 心跳检测任务 + self._heartbeat_task: Optional[asyncio.Task] = None + self._heartbeat_running = False + + # 状态变化回调 + self._on_status_change: Optional[callable] = None + + # 全局统计 + self._global_stats = { + "total_tool_calls": 0, + "successful_calls": 0, + "failed_calls": 0, + "start_time": time.time(), + } + + def configure(self, settings: Dict[str, Any]) -> None: + """配置管理器""" + self._settings = settings + + def set_status_change_callback(self, callback: callable) -> None: + """设置状态变化回调函数""" + self._on_status_change = callback + + def _notify_status_change(self) -> None: + """通知状态变化""" + if self._on_status_change: + try: + self._on_status_change() + except Exception as e: + logger.debug(f"状态变化回调出错: {e}") + + @property + def all_tools(self) -> Dict[str, Tuple[MCPToolInfo, MCPClientSession]]: + """获取所有已注册的工具""" + return self._all_tools.copy() + + @property + def all_resources(self) -> Dict[str, Tuple[MCPResourceInfo, MCPClientSession]]: + """v1.2.0: 获取所有已注册的资源""" + return self._all_resources.copy() + + @property + def all_prompts(self) -> Dict[str, Tuple[MCPPromptInfo, MCPClientSession]]: + """v1.2.0: 获取所有已注册的提示模板""" + return self._all_prompts.copy() + + @property + def connected_servers(self) -> List[str]: + """获取已连接的服务器列表""" + return [name for name, client in self._clients.items() if client.is_connected] + + @property + def disconnected_servers(self) -> List[str]: + """获取已断开的服务器列表""" + return [name for name, client in self._clients.items() if not client.is_connected and client.config.enabled] + + async def add_server(self, config: MCPServerConfig) -> bool: + """添加并连接 MCP 服务器""" + async with self._lock: + if config.name in self._clients: + logger.warning(f"服务器 {config.name} 已存在") + return False + + call_timeout = self._settings.get("call_timeout", 60.0) + client = MCPClientSession(config, call_timeout) + self._clients[config.name] = client + + if not config.enabled: + logger.info(f"服务器 {config.name} 已添加但未启用") + return True + + # 尝试连接 + retry_attempts = self._settings.get("retry_attempts", 3) + retry_interval = self._settings.get("retry_interval", 5.0) + + for attempt in range(1, retry_attempts + 1): + if await client.connect(): + self._register_tools(client) + return True + + if attempt < retry_attempts: + logger.warning(f"服务器 {config.name} 连接失败,{retry_interval}秒后重试 ({attempt}/{retry_attempts})") + await asyncio.sleep(retry_interval) + + logger.error(f"服务器 {config.name} 连接失败,已达最大重试次数 ({retry_attempts})") + # 连接失败,但保留在 _clients 中以便后续重连 + return False + + def _register_tools(self, client: MCPClientSession) -> None: + """注册客户端的工具""" + tool_prefix = self._settings.get("tool_prefix", "mcp") + + for tool in client.tools: + if tool.name.startswith(f"{tool_prefix}_{client.server_name}_"): + tool_key = tool.name + else: + tool_key = f"{tool_prefix}_{client.server_name}_{tool.name}" + self._all_tools[tool_key] = (tool, client) + logger.debug(f"注册 MCP 工具: {tool_key}") + + def _unregister_tools(self, server_name: str) -> List[str]: + """注销服务器的工具,返回被注销的工具键列表""" + tool_prefix = self._settings.get("tool_prefix", "mcp") + prefix = f"{tool_prefix}_{server_name}_" + + keys_to_remove = [k for k in self._all_tools.keys() if k.startswith(prefix)] + for key in keys_to_remove: + del self._all_tools[key] + logger.debug(f"注销 MCP 工具: {key}") + return keys_to_remove + + def _register_resources(self, client: MCPClientSession) -> None: + """v1.2.0: 注册客户端的资源""" + tool_prefix = self._settings.get("tool_prefix", "mcp") + + for resource in client.resources: + # 资源键格式: mcp_{server}_{uri_safe_name} + # 将 URI 转换为安全的键名 + safe_uri = resource.uri.replace("://", "_").replace("/", "_").replace(".", "_") + resource_key = f"{tool_prefix}_{client.server_name}_res_{safe_uri}" + self._all_resources[resource_key] = (resource, client) + logger.debug(f"注册 MCP 资源: {resource_key}") + + def _unregister_resources(self, server_name: str) -> List[str]: + """v1.2.0: 注销服务器的资源""" + tool_prefix = self._settings.get("tool_prefix", "mcp") + prefix = f"{tool_prefix}_{server_name}_res_" + + keys_to_remove = [k for k in self._all_resources.keys() if k.startswith(prefix)] + for key in keys_to_remove: + del self._all_resources[key] + logger.debug(f"注销 MCP 资源: {key}") + return keys_to_remove + + def _register_prompts(self, client: MCPClientSession) -> None: + """v1.2.0: 注册客户端的提示模板""" + tool_prefix = self._settings.get("tool_prefix", "mcp") + + for prompt in client.prompts: + prompt_key = f"{tool_prefix}_{client.server_name}_prompt_{prompt.name}" + self._all_prompts[prompt_key] = (prompt, client) + logger.debug(f"注册 MCP 提示模板: {prompt_key}") + + def _unregister_prompts(self, server_name: str) -> List[str]: + """v1.2.0: 注销服务器的提示模板""" + tool_prefix = self._settings.get("tool_prefix", "mcp") + prefix = f"{tool_prefix}_{server_name}_prompt_" + + keys_to_remove = [k for k in self._all_prompts.keys() if k.startswith(prefix)] + for key in keys_to_remove: + del self._all_prompts[key] + logger.debug(f"注销 MCP 提示模板: {key}") + return keys_to_remove + + async def remove_server(self, server_name: str) -> bool: + """移除 MCP 服务器""" + async with self._lock: + if server_name not in self._clients: + return False + + client = self._clients[server_name] + await client.disconnect() + self._unregister_tools(server_name) + self._unregister_resources(server_name) # v1.2.0 + self._unregister_prompts(server_name) # v1.2.0 + del self._clients[server_name] + + logger.info(f"服务器 {server_name} 已移除") + return True + + async def reconnect_server(self, server_name: str) -> bool: + """重新连接服务器""" + if server_name not in self._clients: + return False + + client = self._clients[server_name] + + async with self._lock: + self._unregister_tools(server_name) + self._unregister_resources(server_name) # v1.2.0 + self._unregister_prompts(server_name) # v1.2.0 + await client.disconnect() + + # 尝试重连 + retry_attempts = self._settings.get("retry_attempts", 3) + retry_interval = self._settings.get("retry_interval", 5.0) + + for attempt in range(1, retry_attempts + 1): + if await client.connect(): + async with self._lock: + self._register_tools(client) + # v1.2.0: 重连后也尝试获取 resources 和 prompts + if self._settings.get("enable_resources", False): + await client.fetch_resources() + self._register_resources(client) + if self._settings.get("enable_prompts", False): + await client.fetch_prompts() + self._register_prompts(client) + client.stats.record_reconnect() + logger.info(f"服务器 {server_name} 重连成功") + return True + + if attempt < retry_attempts: + logger.warning(f"服务器 {server_name} 重连失败,{retry_interval}秒后重试 ({attempt}/{retry_attempts})") + await asyncio.sleep(retry_interval) + + logger.error(f"服务器 {server_name} 重连失败") + return False + + async def call_tool(self, tool_key: str, arguments: Dict[str, Any]) -> MCPCallResult: + """调用 MCP 工具""" + if tool_key not in self._all_tools: + return MCPCallResult( + success=False, + content=None, + error=f"工具 {tool_key} 不存在" + ) + + tool_info, client = self._all_tools[tool_key] + + # 更新全局统计 + self._global_stats["total_tool_calls"] += 1 + + result = await client.call_tool(tool_info.name, arguments) + + if result.success: + self._global_stats["successful_calls"] += 1 + else: + self._global_stats["failed_calls"] += 1 + + return result + + async def fetch_resources_for_server(self, server_name: str) -> bool: + """v1.2.0: 获取指定服务器的资源列表""" + if server_name not in self._clients: + return False + + client = self._clients[server_name] + if not client.is_connected: + return False + + success = await client.fetch_resources() + if success: + async with self._lock: + self._register_resources(client) + return success + + async def fetch_prompts_for_server(self, server_name: str) -> bool: + """v1.2.0: 获取指定服务器的提示模板列表""" + if server_name not in self._clients: + return False + + client = self._clients[server_name] + if not client.is_connected: + return False + + success = await client.fetch_prompts() + if success: + async with self._lock: + self._register_prompts(client) + return success + + async def read_resource(self, uri: str, server_name: Optional[str] = None) -> MCPCallResult: + """v1.2.0: 读取资源内容 + + Args: + uri: 资源 URI + server_name: 指定服务器名称(可选,不指定则自动查找) + """ + # 如果指定了服务器 + if server_name: + if server_name not in self._clients: + return MCPCallResult( + success=False, + content=None, + error=f"服务器 {server_name} 不存在" + ) + client = self._clients[server_name] + return await client.read_resource(uri) + + # 自动查找拥有该资源的服务器 + for resource_key, (resource_info, client) in self._all_resources.items(): + if resource_info.uri == uri: + return await client.read_resource(uri) + + # 尝试在所有支持 resources 的服务器上查找 + for client in self._clients.values(): + if client.is_connected and client.supports_resources: + result = await client.read_resource(uri) + if result.success: + return result + + return MCPCallResult( + success=False, + content=None, + error=f"未找到资源: {uri}" + ) + + async def get_prompt(self, name: str, arguments: Optional[Dict[str, str]] = None, + server_name: Optional[str] = None) -> MCPCallResult: + """v1.2.0: 获取提示模板内容 + + Args: + name: 提示模板名称 + arguments: 模板参数 + server_name: 指定服务器名称(可选) + """ + # 如果指定了服务器 + if server_name: + if server_name not in self._clients: + return MCPCallResult( + success=False, + content=None, + error=f"服务器 {server_name} 不存在" + ) + client = self._clients[server_name] + return await client.get_prompt(name, arguments) + + # 自动查找拥有该提示模板的服务器 + for prompt_key, (prompt_info, client) in self._all_prompts.items(): + if prompt_info.name == name: + return await client.get_prompt(name, arguments) + + return MCPCallResult( + success=False, + content=None, + error=f"未找到提示模板: {name}" + ) + + # ==================== 心跳检测 ==================== + + async def start_heartbeat(self) -> None: + """启动心跳检测任务""" + if self._heartbeat_running: + logger.warning("心跳检测任务已在运行") + return + + heartbeat_enabled = self._settings.get("heartbeat_enabled", True) + if not heartbeat_enabled: + logger.info("心跳检测已禁用") + return + + self._heartbeat_running = True + self._heartbeat_task = asyncio.create_task(self._heartbeat_loop()) + logger.info("心跳检测任务已启动") + + async def stop_heartbeat(self) -> None: + """停止心跳检测任务""" + self._heartbeat_running = False + if self._heartbeat_task: + self._heartbeat_task.cancel() + try: + await self._heartbeat_task + except asyncio.CancelledError: + pass + self._heartbeat_task = None + logger.info("心跳检测任务已停止") + + async def _heartbeat_loop(self) -> None: + """心跳检测循环(v1.5.2: 智能心跳间隔)""" + base_interval = self._settings.get("heartbeat_interval", 60.0) + auto_reconnect = self._settings.get("auto_reconnect", True) + max_reconnect_attempts = self._settings.get("max_reconnect_attempts", 3) + + # v1.5.2: 智能心跳配置 + adaptive_enabled = self._settings.get("heartbeat_adaptive", True) + max_multiplier = self._settings.get("heartbeat_max_multiplier", 3.0) + + # 每个服务器独立的心跳间隔(根据稳定性动态调整) + server_intervals: Dict[str, float] = {} + min_interval = max(base_interval * 0.5, 30.0) # 最小间隔 + max_interval = base_interval * max_multiplier # 最大间隔 + + mode_str = "智能" if adaptive_enabled else "固定" + logger.info(f"心跳检测循环启动,{mode_str}模式,基准间隔: {base_interval}秒") + + while self._heartbeat_running: + try: + # 使用最小的服务器间隔作为循环间隔 + current_interval = min(server_intervals.values()) if server_intervals else base_interval + current_interval = max(current_interval, min_interval) + + await asyncio.sleep(current_interval) + + if not self._heartbeat_running: + break + + current_time = time.time() + + # 检查所有已启用的服务器 + for server_name, client in list(self._clients.items()): + if not client.config.enabled: + continue + + # 初始化服务器间隔 + if server_name not in server_intervals: + server_intervals[server_name] = base_interval + + # 检查是否到达该服务器的心跳时间 + last_heartbeat = client.stats.last_heartbeat_time or 0 + if current_time - last_heartbeat < server_intervals[server_name] * 0.9: + continue # 还没到心跳时间 + + if client.is_connected: + # 检查健康状态 + healthy = await client.check_health() + if healthy: + # v1.5.2: 智能心跳 - 稳定服务器逐渐增加间隔 + if adaptive_enabled and client.stats.consecutive_failures == 0: + new_interval = min(server_intervals[server_name] * 1.2, max_interval) + if new_interval != server_intervals[server_name]: + server_intervals[server_name] = new_interval + logger.debug(f"[{server_name}] 稳定,心跳间隔调整为 {new_interval:.0f}s") + else: + logger.warning(f"[{server_name}] 心跳检测失败,连接可能已断开") + # 失败后重置为基准间隔 + if adaptive_enabled: + server_intervals[server_name] = base_interval + self._notify_status_change() + if auto_reconnect: + await self._try_reconnect(server_name, max_reconnect_attempts) + else: + # 服务器未连接,尝试重连 + if adaptive_enabled: + # 智能心跳:断开的服务器使用较短间隔 + server_intervals[server_name] = min_interval + if auto_reconnect and client.stats.consecutive_failures < max_reconnect_attempts: + logger.info(f"[{server_name}] 检测到断开,尝试重连...") + await self._try_reconnect(server_name, max_reconnect_attempts) + elif client.stats.consecutive_failures >= max_reconnect_attempts: + if adaptive_enabled: + # 达到最大重连次数,降低检测频率 + server_intervals[server_name] = max_interval + logger.debug(f"[{server_name}] 已达最大重连次数,降低检测频率") + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"心跳检测循环出错: {e}") + await asyncio.sleep(5) + + async def _try_reconnect(self, server_name: str, max_attempts: int) -> bool: + """尝试重连服务器""" + client = self._clients.get(server_name) + if not client: + return False + + if client.stats.consecutive_failures >= max_attempts: + logger.warning(f"[{server_name}] 连续失败次数已达上限 ({max_attempts}),暂停重连") + return False + + logger.info(f"[{server_name}] 尝试重连 (失败次数: {client.stats.consecutive_failures}/{max_attempts})") + + success = await self.reconnect_server(server_name) + if not success: + client.stats.record_failure() + + self._notify_status_change() # 重连后更新状态 + return success + + # ==================== 统计和状态 ==================== + + def get_tool_stats(self, tool_key: str) -> Optional[Dict[str, Any]]: + """获取指定工具的统计信息""" + if tool_key not in self._all_tools: + return None + + tool_info, client = self._all_tools[tool_key] + stats = client.get_tool_stats(tool_info.name) + return stats.to_dict() if stats else None + + def get_all_stats(self) -> Dict[str, Any]: + """获取所有统计信息""" + server_stats = {} + tool_stats = {} + + for server_name, client in self._clients.items(): + server_stats[server_name] = client.stats.to_dict() + for tool_name, stats in client.get_all_tool_stats().items(): + full_key = f"{self._settings.get('tool_prefix', 'mcp')}_{server_name}_{tool_name}" + tool_stats[full_key] = stats.to_dict() + + uptime = time.time() - self._global_stats["start_time"] + + return { + "global": { + **self._global_stats, + "uptime_seconds": round(uptime, 2), + "calls_per_minute": round(self._global_stats["total_tool_calls"] / (uptime / 60), 2) if uptime > 0 else 0, + }, + "servers": server_stats, + "tools": tool_stats, + } + + async def shutdown(self) -> None: + """关闭所有连接""" + # 停止心跳检测 + await self.stop_heartbeat() + + async with self._lock: + for client in self._clients.values(): + await client.disconnect() + self._clients.clear() + self._all_tools.clear() + self._all_resources.clear() # v1.2.0 + self._all_prompts.clear() # v1.2.0 + logger.info("MCP 客户端管理器已关闭") + + def get_status(self) -> Dict[str, Any]: + """获取状态信息""" + return { + "total_servers": len(self._clients), + "connected_servers": len(self.connected_servers), + "disconnected_servers": len(self.disconnected_servers), + "total_tools": len(self._all_tools), + "total_resources": len(self._all_resources), # v1.2.0 + "total_prompts": len(self._all_prompts), # v1.2.0 + "heartbeat_running": self._heartbeat_running, + "servers": { + name: { + "connected": client.is_connected, + "enabled": client.config.enabled, + "tools_count": len(client.tools), + "resources_count": len(client.resources), # v1.2.0 + "prompts_count": len(client.prompts), # v1.2.0 + "supports_resources": client.supports_resources, # v1.2.0 + "supports_prompts": client.supports_prompts, # v1.2.0 + "transport": client.config.transport.value, + "consecutive_failures": client.stats.consecutive_failures, + "circuit_breaker": client.get_circuit_breaker_status(), # v1.7.0 + } + for name, client in self._clients.items() + }, + "global_stats": self._global_stats, + } + + +# 全局单例 +mcp_manager = MCPClientManager() diff --git a/plugins/MaiBot_MCPBridgePlugin/plugin.py b/plugins/MaiBot_MCPBridgePlugin/plugin.py new file mode 100644 index 00000000..28458719 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/plugin.py @@ -0,0 +1,3138 @@ +""" +MCP 桥接插件 v1.7.0 +将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot + +v1.7.0 稳定性与易用性优化: +- 断路器模式:故障服务器快速失败,避免拖慢整体响应 +- 状态实时刷新:WebUI 每 10 秒自动更新连接状态 +- 断路器状态显示:在状态面板显示熔断/试探状态 + +v1.6.0 配置导入导出: +- 新增 /mcp import 命令,支持从 Claude Desktop 格式导入配置 +- 新增 /mcp export 命令,导出为 Claude Desktop / Kiro / MaiBot 格式 +- 支持 stdio、sse、http、streamable_http 全部传输类型 +- 自动跳过同名服务器,防止重复导入 + +v1.5.4 易用性优化: +- 新增 MCP 服务器获取快捷入口(魔搭、Smithery、Glama 等) +- 优化快速入门指南,提供配置示例 +- 帮助新用户快速上手 MCP + +v1.5.3 配置优化: +- 新增智能心跳 WebUI 配置项:启用开关、最大间隔倍数 +- 支持在 WebUI 中开启/关闭智能心跳功能 + +v1.5.2 性能优化: +- 智能心跳间隔:根据服务器稳定性动态调整心跳频率 +- 稳定服务器逐渐增加间隔,减少不必要的网络请求 +- 断开的服务器使用较短间隔快速重连 + +v1.5.1 易用性优化: +- 新增「快速添加服务器」表单式配置,无需手写 JSON +- 支持填写名称、类型、URL、命令、参数、鉴权头 +- 保存后自动合并到服务器列表 + +v1.5.0 性能优化: +- 服务器并行连接:多个服务器同时连接,大幅减少启动时间 +- 连接耗时统计:日志显示并行连接总耗时 + +v1.4.4 修复: +- 修复首次生成默认配置文件时多行字符串导致 TOML 解析失败的问题 +- 简化 config_schema 默认值,避免主程序 json.dumps 产生无效 TOML + +v1.4.3 修复: +- 修复 WebUI 保存配置后多行字符串格式错误导致配置文件无法读取的问题 +- 清理未使用的导入 + +v1.4.0 新增功能: +- 工具禁用管理 +- 调用链路追踪 +- 工具调用缓存 +- 工具权限控制 +""" + +import asyncio +import fnmatch +import hashlib +import json +import re +import time +import uuid +from collections import OrderedDict, deque +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple, Type + +from src.common.logger import get_logger +from src.plugin_system import ( + BasePlugin, + register_plugin, + BaseTool, + BaseCommand, + ComponentInfo, + ConfigField, + ToolParamType, +) +from src.plugin_system.base.component_types import ToolInfo, ComponentType, EventType +from src.plugin_system.base.base_events_handler import BaseEventHandler + +from .mcp_client import ( + MCPServerConfig, + MCPToolInfo, + MCPResourceInfo, + MCPPromptInfo, + TransportType, + mcp_manager, +) +from .config_converter import ConfigConverter, ConversionResult + +logger = get_logger("mcp_bridge_plugin") + + +# ============================================================================ +# v1.4.0: 调用链路追踪 +# ============================================================================ + +@dataclass +class ToolCallRecord: + """工具调用记录""" + call_id: str + timestamp: float + tool_name: str + server_name: str + chat_id: str = "" + user_id: str = "" + user_query: str = "" + arguments: Dict = field(default_factory=dict) + raw_result: str = "" + processed_result: str = "" + duration_ms: float = 0.0 + success: bool = True + error: str = "" + post_processed: bool = False + cache_hit: bool = False + + +class ToolCallTracer: + """工具调用追踪器""" + + def __init__(self, max_records: int = 100): + self._records: deque[ToolCallRecord] = deque(maxlen=max_records) + self._enabled: bool = True + self._log_enabled: bool = False + self._log_path: Optional[Path] = None + + def configure(self, enabled: bool, max_records: int, log_enabled: bool, log_path: Optional[Path] = None) -> None: + """配置追踪器""" + self._enabled = enabled + self._records = deque(self._records, maxlen=max_records) + self._log_enabled = log_enabled + self._log_path = log_path + + def record(self, record: ToolCallRecord) -> None: + """添加调用记录""" + if not self._enabled: + return + + self._records.append(record) + + if self._log_enabled and self._log_path: + self._write_to_log(record) + + def get_recent(self, n: int = 10) -> List[ToolCallRecord]: + """获取最近 N 条记录""" + return list(self._records)[-n:] + + def get_by_tool(self, tool_name: str) -> List[ToolCallRecord]: + """按工具名筛选记录""" + return [r for r in self._records if r.tool_name == tool_name] + + def get_by_server(self, server_name: str) -> List[ToolCallRecord]: + """按服务器名筛选记录""" + return [r for r in self._records if r.server_name == server_name] + + def clear(self) -> None: + """清空记录""" + self._records.clear() + + def _write_to_log(self, record: ToolCallRecord) -> None: + """写入 JSONL 日志文件""" + try: + if self._log_path: + self._log_path.parent.mkdir(parents=True, exist_ok=True) + with open(self._log_path, "a", encoding="utf-8") as f: + f.write(json.dumps(asdict(record), ensure_ascii=False) + "\n") + except Exception as e: + logger.warning(f"写入追踪日志失败: {e}") + + @property + def total_records(self) -> int: + return len(self._records) + + +# 全局追踪器实例 +tool_call_tracer = ToolCallTracer() + + +# ============================================================================ +# v1.4.0: 工具调用缓存 +# ============================================================================ + +@dataclass +class CacheEntry: + """缓存条目""" + tool_name: str + args_hash: str + result: str + created_at: float + expires_at: float + hit_count: int = 0 + + +class ToolCallCache: + """工具调用缓存(LRU)""" + + def __init__(self, max_entries: int = 200, ttl: int = 300): + self._cache: OrderedDict[str, CacheEntry] = OrderedDict() + self._max_entries = max_entries + self._ttl = ttl + self._enabled = False + self._exclude_patterns: List[str] = [] + self._stats = {"hits": 0, "misses": 0} + + def configure(self, enabled: bool, ttl: int, max_entries: int, exclude_tools: str) -> None: + """配置缓存""" + self._enabled = enabled + self._ttl = ttl + self._max_entries = max_entries + self._exclude_patterns = [p.strip() for p in exclude_tools.strip().split("\n") if p.strip()] + + def get(self, tool_name: str, args: Dict) -> Optional[str]: + """获取缓存""" + if not self._enabled: + return None + + if self._is_excluded(tool_name): + return None + + key = self._generate_key(tool_name, args) + + if key not in self._cache: + self._stats["misses"] += 1 + return None + + entry = self._cache[key] + + # 检查是否过期 + if time.time() > entry.expires_at: + del self._cache[key] + self._stats["misses"] += 1 + return None + + # LRU: 移到末尾 + self._cache.move_to_end(key) + entry.hit_count += 1 + self._stats["hits"] += 1 + + return entry.result + + def set(self, tool_name: str, args: Dict, result: str) -> None: + """设置缓存""" + if not self._enabled: + return + + if self._is_excluded(tool_name): + return + + key = self._generate_key(tool_name, args) + now = time.time() + + entry = CacheEntry( + tool_name=tool_name, + args_hash=key, + result=result, + created_at=now, + expires_at=now + self._ttl, + ) + + # 如果已存在,更新 + if key in self._cache: + self._cache[key] = entry + self._cache.move_to_end(key) + else: + # 检查容量 + self._evict_if_needed() + self._cache[key] = entry + + def clear(self) -> None: + """清空缓存""" + self._cache.clear() + self._stats = {"hits": 0, "misses": 0} + + def _generate_key(self, tool_name: str, args: Dict) -> str: + """生成缓存键""" + args_str = json.dumps(args, sort_keys=True, ensure_ascii=False) + content = f"{tool_name}:{args_str}" + return hashlib.md5(content.encode()).hexdigest() + + def _is_excluded(self, tool_name: str) -> bool: + """检查是否在排除列表中""" + for pattern in self._exclude_patterns: + if fnmatch.fnmatch(tool_name, pattern): + return True + return False + + def _evict_if_needed(self) -> None: + """必要时淘汰条目""" + # 先清理过期的 + now = time.time() + expired_keys = [k for k, v in self._cache.items() if now > v.expires_at] + for k in expired_keys: + del self._cache[k] + + # LRU 淘汰 + while len(self._cache) >= self._max_entries: + self._cache.popitem(last=False) + + def get_stats(self) -> Dict[str, Any]: + """获取缓存统计""" + total = self._stats["hits"] + self._stats["misses"] + hit_rate = (self._stats["hits"] / total * 100) if total > 0 else 0 + return { + "enabled": self._enabled, + "entries": len(self._cache), + "max_entries": self._max_entries, + "ttl": self._ttl, + "hits": self._stats["hits"], + "misses": self._stats["misses"], + "hit_rate": f"{hit_rate:.1f}%", + } + + +# 全局缓存实例 +tool_call_cache = ToolCallCache() + + +# ============================================================================ +# v1.4.0: 工具权限控制 +# ============================================================================ + +class PermissionChecker: + """工具权限检查器""" + + def __init__(self): + self._enabled = False + self._default_mode = "allow_all" # allow_all 或 deny_all + self._rules: List[Dict] = [] + self._quick_deny_groups: set = set() + self._quick_allow_users: set = set() + + def configure( + self, + enabled: bool, + default_mode: str, + rules_json: str, + quick_deny_groups: str = "", + quick_allow_users: str = "", + ) -> None: + """配置权限检查器""" + self._enabled = enabled + self._default_mode = default_mode if default_mode in ("allow_all", "deny_all") else "allow_all" + + # 解析快捷配置 + self._quick_deny_groups = {g.strip() for g in quick_deny_groups.strip().split("\n") if g.strip()} + self._quick_allow_users = {u.strip() for u in quick_allow_users.strip().split("\n") if u.strip()} + + try: + self._rules = json.loads(rules_json) if rules_json.strip() else [] + except json.JSONDecodeError as e: + logger.warning(f"权限规则 JSON 解析失败: {e}") + self._rules = [] + + def check(self, tool_name: str, chat_id: str, user_id: str, is_group: bool) -> bool: + """检查权限 + + Args: + tool_name: 工具名称 + chat_id: 聊天 ID(群号或私聊 ID) + user_id: 用户 ID + is_group: 是否为群聊 + + Returns: + True 表示允许,False 表示拒绝 + """ + if not self._enabled: + return True + + # 快捷配置优先级最高 + # 1. 管理员白名单(始终允许) + if user_id and user_id in self._quick_allow_users: + return True + + # 2. 禁用群列表(始终拒绝) + if is_group and chat_id and chat_id in self._quick_deny_groups: + return False + + # 查找匹配的规则 + for rule in self._rules: + tool_pattern = rule.get("tool", "") + if not self._match_tool(tool_pattern, tool_name): + continue + + # 找到匹配的规则 + mode = rule.get("mode", "") + allowed = rule.get("allowed", []) + denied = rule.get("denied", []) + + # 构建当前上下文的 ID 列表 + context_ids = self._build_context_ids(chat_id, user_id, is_group) + + # 检查 denied 列表(优先级最高) + if denied: + for ctx_id in context_ids: + if self._match_id_list(denied, ctx_id): + return False + + # 检查 allowed 列表 + if allowed: + for ctx_id in context_ids: + if self._match_id_list(allowed, ctx_id): + return True + # 如果是 whitelist 模式且不在 allowed 中,拒绝 + if mode == "whitelist": + return False + + # 规则匹配但没有明确允许/拒绝,继续检查下一条规则 + + # 没有匹配的规则,使用默认模式 + return self._default_mode == "allow_all" + + def _match_tool(self, pattern: str, tool_name: str) -> bool: + """工具名通配符匹配""" + if not pattern: + return False + return fnmatch.fnmatch(tool_name, pattern) + + def _build_context_ids(self, chat_id: str, user_id: str, is_group: bool) -> List[str]: + """构建上下文 ID 列表""" + ids = [] + + # 用户级别(任何场景生效) + if user_id: + ids.append(f"qq:{user_id}:user") + + # 场景级别 + if is_group and chat_id: + ids.append(f"qq:{chat_id}:group") + elif chat_id: + ids.append(f"qq:{chat_id}:private") + + return ids + + def _match_id_list(self, id_list: List[str], context_id: str) -> bool: + """检查 ID 是否在列表中""" + for rule_id in id_list: + if fnmatch.fnmatch(context_id, rule_id): + return True + return False + + def get_rules_for_tool(self, tool_name: str) -> List[Dict]: + """获取特定工具的权限规则""" + return [r for r in self._rules if self._match_tool(r.get("tool", ""), tool_name)] + + +# 全局权限检查器实例 +permission_checker = PermissionChecker() + + +# ============================================================================ +# 工具类型转换 +# ============================================================================ + +def convert_json_type_to_tool_param_type(json_type: str) -> ToolParamType: + """将 JSON Schema 类型转换为 MaiBot 的 ToolParamType""" + type_mapping = { + "string": ToolParamType.STRING, + "integer": ToolParamType.INTEGER, + "number": ToolParamType.FLOAT, + "boolean": ToolParamType.BOOLEAN, + "array": ToolParamType.STRING, + "object": ToolParamType.STRING, + } + return type_mapping.get(json_type, ToolParamType.STRING) + + +def parse_mcp_parameters(input_schema: Dict[str, Any]) -> List[Tuple[str, ToolParamType, str, bool, Optional[List[str]]]]: + """解析 MCP 工具的参数 schema,转换为 MaiBot 的参数格式""" + parameters = [] + + if not input_schema: + return parameters + + properties = input_schema.get("properties", {}) + required = input_schema.get("required", []) + + for param_name, param_info in properties.items(): + json_type = param_info.get("type", "string") + param_type = convert_json_type_to_tool_param_type(json_type) + description = param_info.get("description", f"参数 {param_name}") + + if json_type == "array": + description = f"{description} (JSON 数组格式)" + elif json_type == "object": + description = f"{description} (JSON 对象格式)" + + is_required = param_name in required + enum_values = param_info.get("enum") + + if enum_values is not None: + enum_values = [str(v) for v in enum_values] + + parameters.append((param_name, param_type, description, is_required, enum_values)) + + return parameters + + +# ============================================================================ +# MCP 工具代理 +# ============================================================================ + +class MCPToolProxy(BaseTool): + """MCP 工具代理基类""" + + name: str = "" + description: str = "" + parameters: List[Tuple[str, ToolParamType, str, bool, Optional[List[str]]]] = [] + available_for_llm: bool = True + + _mcp_tool_key: str = "" + _mcp_original_name: str = "" + _mcp_server_name: str = "" + + async def execute(self, function_args: Dict[str, Any]) -> Dict[str, Any]: + """执行 MCP 工具调用""" + global _plugin_instance + + call_id = str(uuid.uuid4())[:8] + start_time = time.time() + + # 移除 MaiBot 内部标记 + args = {k: v for k, v in function_args.items() if k != "llm_called"} + + # 解析 JSON 字符串参数 + parsed_args = {} + for key, value in args.items(): + if isinstance(value, str): + try: + if value.startswith(("[", "{")): + parsed_args[key] = json.loads(value) + else: + parsed_args[key] = value + except json.JSONDecodeError: + parsed_args[key] = value + else: + parsed_args[key] = value + + # 获取上下文信息 + chat_id, user_id, is_group, user_query = self._get_context_info() + + # v1.4.0: 权限检查 + if not permission_checker.check(self.name, chat_id, user_id, is_group): + logger.warning(f"权限拒绝: 工具 {self.name}, chat={chat_id}, user={user_id}") + return { + "name": self.name, + "content": f"⛔ 权限不足:工具 {self.name} 在当前场景下不可用" + } + + logger.debug(f"调用 MCP 工具: {self._mcp_tool_key}, 参数: {parsed_args}") + + # v1.4.0: 检查缓存 + cache_hit = False + cached_result = tool_call_cache.get(self.name, parsed_args) + + if cached_result is not None: + cache_hit = True + content = cached_result + raw_result = cached_result + success = True + error = "" + logger.debug(f"MCP 工具 {self.name} 命中缓存") + else: + # 调用 MCP + result = await mcp_manager.call_tool(self._mcp_tool_key, parsed_args) + + if result.success: + content = result.content + raw_result = content + success = True + error = "" + + # 存入缓存 + tool_call_cache.set(self.name, parsed_args, content) + else: + content = self._format_error_message(result.error, result.duration_ms) + raw_result = result.error + success = False + error = result.error + logger.warning(f"MCP 工具 {self.name} 调用失败: {result.error}") + + # v1.3.0: 后处理 + post_processed = False + processed_result = content + if success: + processed_content = await self._post_process_result(content) + if processed_content != content: + post_processed = True + processed_result = processed_content + content = processed_content + + duration_ms = (time.time() - start_time) * 1000 + + # v1.4.0: 记录调用追踪 + record = ToolCallRecord( + call_id=call_id, + timestamp=start_time, + tool_name=self.name, + server_name=self._mcp_server_name, + chat_id=chat_id, + user_id=user_id, + user_query=user_query, + arguments=parsed_args, + raw_result=raw_result[:1000] if raw_result else "", + processed_result=processed_result[:1000] if processed_result else "", + duration_ms=duration_ms, + success=success, + error=error, + post_processed=post_processed, + cache_hit=cache_hit, + ) + tool_call_tracer.record(record) + + return {"name": self.name, "content": content} + + def _get_context_info(self) -> Tuple[str, str, bool, str]: + """获取上下文信息""" + chat_id = "" + user_id = "" + is_group = False + user_query = "" + + if self.chat_stream and hasattr(self.chat_stream, "context") and self.chat_stream.context: + try: + ctx = self.chat_stream.context + if hasattr(ctx, "chat_id"): + chat_id = str(ctx.chat_id) if ctx.chat_id else "" + if hasattr(ctx, "user_id"): + user_id = str(ctx.user_id) if ctx.user_id else "" + if hasattr(ctx, "is_group"): + is_group = bool(ctx.is_group) + + last_message = ctx.get_last_message() + if last_message and hasattr(last_message, "processed_plain_text"): + user_query = last_message.processed_plain_text or "" + except Exception as e: + logger.debug(f"获取上下文信息失败: {e}") + + return chat_id, user_id, is_group, user_query + + async def _post_process_result(self, content: str) -> str: + """v1.3.0: 对工具返回结果进行后处理(摘要提炼)""" + global _plugin_instance + + if _plugin_instance is None: + return content + + settings = _plugin_instance.config.get("settings", {}) + + if not settings.get("post_process_enabled", False): + return content + + server_post_config = self._get_server_post_process_config() + + if server_post_config is not None: + if not server_post_config.get("enabled", True): + return content + + threshold = settings.get("post_process_threshold", 500) + if server_post_config and "threshold" in server_post_config: + threshold = server_post_config["threshold"] + + content_length = len(content) if content else 0 + if content_length <= threshold: + return content + + user_query = self._get_context_info()[3] + if not user_query: + return content + + max_tokens = settings.get("post_process_max_tokens", 500) + if server_post_config and "max_tokens" in server_post_config: + max_tokens = server_post_config["max_tokens"] + + prompt_template = settings.get("post_process_prompt", "") + if server_post_config and "prompt" in server_post_config: + prompt_template = server_post_config["prompt"] + + if not prompt_template: + prompt_template = """用户问题:{query} + +工具返回内容: +{result} + +请从上述内容中提取与用户问题最相关的关键信息,简洁准确地输出:""" + + try: + prompt = prompt_template.format(query=user_query, result=content) + except KeyError as e: + logger.warning(f"后处理 prompt 模板格式错误: {e}") + return content + + try: + processed_content = await self._call_post_process_llm(prompt, max_tokens, settings, server_post_config) + if processed_content: + logger.info(f"MCP 工具 {self.name} 后处理完成: {content_length} -> {len(processed_content)} 字符") + return processed_content + return content + except Exception as e: + logger.error(f"MCP 工具 {self.name} 后处理失败: {e}") + return content + + def _get_server_post_process_config(self) -> Optional[Dict[str, Any]]: + """获取当前服务器的后处理配置""" + global _plugin_instance + + if _plugin_instance is None: + return None + + servers_section = _plugin_instance.config.get("servers", {}) + if isinstance(servers_section, dict): + servers_list = servers_section.get("list", "[]") + if isinstance(servers_list, str): + try: + servers = json.loads(servers_list) if servers_list.strip() else [] + except json.JSONDecodeError: + return None + elif isinstance(servers_list, list): + servers = servers_list + else: + return None + else: + servers = servers_section if isinstance(servers_section, list) else [] + + for server_conf in servers: + if server_conf.get("name") == self._mcp_server_name: + return server_conf.get("post_process") + + return None + + async def _call_post_process_llm( + self, + prompt: str, + max_tokens: int, + settings: Dict[str, Any], + server_config: Optional[Dict[str, Any]] + ) -> Optional[str]: + """调用 LLM 进行后处理""" + from src.config.config import model_config + from src.config.api_ada_configs import TaskConfig + from src.llm_models.utils_model import LLMRequest + + model_name = settings.get("post_process_model", "") + if server_config and "model" in server_config: + model_name = server_config["model"] + + if model_name: + task_config = TaskConfig( + model_list=[model_name], + max_tokens=max_tokens, + temperature=0.3, + slow_threshold=30.0, + ) + else: + task_config = model_config.model_task_config.utils + + llm_request = LLMRequest(model_set=task_config, request_type="mcp_post_process") + + response, (reasoning, model_used, _) = await llm_request.generate_response_async( + prompt=prompt, + max_tokens=max_tokens, + temperature=0.3, + ) + + return response.strip() if response else None + + def _format_error_message(self, error: str, duration_ms: float) -> str: + """格式化友好的错误消息""" + if not error: + return "工具调用失败(未知错误)" + + error_lower = error.lower() + + if "未连接" in error or "not connected" in error_lower: + return f"⚠️ MCP 服务器 [{self._mcp_server_name}] 未连接,请检查服务器状态或等待自动重连" + + if "超时" in error or "timeout" in error_lower: + return f"⏱️ 工具调用超时(耗时 {duration_ms:.0f}ms),服务器响应过慢,请稍后重试" + + if "connection" in error_lower and ("closed" in error_lower or "reset" in error_lower): + return f"🔌 与 MCP 服务器 [{self._mcp_server_name}] 的连接已断开,正在尝试重连..." + + if "invalid" in error_lower and "argument" in error_lower: + return f"❌ 参数错误: {error}" + + return f"❌ 工具调用失败: {error}" + + async def direct_execute(self, **function_args) -> Dict[str, Any]: + """直接执行(供其他插件调用)""" + return await self.execute(function_args) + + +def create_mcp_tool_class( + tool_key: str, + tool_info: MCPToolInfo, + tool_prefix: str, + disabled: bool = False +) -> Type[MCPToolProxy]: + """根据 MCP 工具信息动态创建 BaseTool 子类""" + parameters = parse_mcp_parameters(tool_info.input_schema) + + class_name = f"MCPTool_{tool_info.server_name}_{tool_info.name}".replace("-", "_").replace(".", "_") + tool_name = tool_key.replace("-", "_").replace(".", "_") + + description = tool_info.description + if not description.endswith(f"[来自 MCP 服务器: {tool_info.server_name}]"): + description = f"{description} [来自 MCP 服务器: {tool_info.server_name}]" + + tool_class = type( + class_name, + (MCPToolProxy,), + { + "name": tool_name, + "description": description, + "parameters": parameters, + "available_for_llm": not disabled, # v1.4.0: 禁用的工具不可被 LLM 调用 + "_mcp_tool_key": tool_key, + "_mcp_original_name": tool_info.name, + "_mcp_server_name": tool_info.server_name, + } + ) + + return tool_class + + +class MCPToolRegistry: + """MCP 工具注册表""" + + def __init__(self): + self._tool_classes: Dict[str, Type[MCPToolProxy]] = {} + self._tool_infos: Dict[str, ToolInfo] = {} + + def register_tool( + self, + tool_key: str, + tool_info: MCPToolInfo, + tool_prefix: str, + disabled: bool = False + ) -> Tuple[ToolInfo, Type[MCPToolProxy]]: + """注册 MCP 工具""" + tool_class = create_mcp_tool_class(tool_key, tool_info, tool_prefix, disabled) + + self._tool_classes[tool_key] = tool_class + + info = ToolInfo( + name=tool_class.name, + tool_description=tool_class.description, + enabled=True, + tool_parameters=tool_class.parameters, + component_type=ComponentType.TOOL, + ) + self._tool_infos[tool_key] = info + + return info, tool_class + + def unregister_tool(self, tool_key: str) -> bool: + """注销工具""" + if tool_key in self._tool_classes: + del self._tool_classes[tool_key] + del self._tool_infos[tool_key] + return True + return False + + def get_all_components(self) -> List[Tuple[ComponentInfo, Type]]: + """获取所有工具组件""" + return [(self._tool_infos[key], self._tool_classes[key]) for key in self._tool_classes.keys()] + + def clear(self) -> None: + """清空所有注册""" + self._tool_classes.clear() + self._tool_infos.clear() + + +# 全局工具注册表 +mcp_tool_registry = MCPToolRegistry() + +# 全局插件实例引用 +_plugin_instance: Optional["MCPBridgePlugin"] = None + + +# ============================================================================ +# 内置工具 +# ============================================================================ + +class MCPReadResourceTool(BaseTool): + """v1.2.0: MCP 资源读取工具""" + + name = "mcp_read_resource" + description = "读取 MCP 服务器提供的资源内容(如文件、数据库记录等)。使用前请先用 mcp_status 查看可用资源。" + parameters = [ + ("uri", ToolParamType.STRING, "资源 URI(如 file:///path/to/file 或自定义 URI)", True, None), + ("server_name", ToolParamType.STRING, "指定服务器名称(可选,不指定则自动查找)", False, None), + ] + available_for_llm = True + + async def execute(self, function_args: Dict[str, Any]) -> Dict[str, Any]: + uri = function_args.get("uri", "") + server_name = function_args.get("server_name") + + if not uri: + return {"name": self.name, "content": "❌ 请提供资源 URI"} + + result = await mcp_manager.read_resource(uri, server_name) + + if result.success: + return {"name": self.name, "content": result.content} + else: + return {"name": self.name, "content": f"❌ 读取资源失败: {result.error}"} + + async def direct_execute(self, **function_args) -> Dict[str, Any]: + return await self.execute(function_args) + + +class MCPGetPromptTool(BaseTool): + """v1.2.0: MCP 提示模板工具""" + + name = "mcp_get_prompt" + description = "获取 MCP 服务器提供的提示模板内容。使用前请先用 mcp_status 查看可用模板。" + parameters = [ + ("name", ToolParamType.STRING, "提示模板名称", True, None), + ("arguments", ToolParamType.STRING, "模板参数(JSON 对象格式)", False, None), + ("server_name", ToolParamType.STRING, "指定服务器名称(可选)", False, None), + ] + available_for_llm = True + + async def execute(self, function_args: Dict[str, Any]) -> Dict[str, Any]: + prompt_name = function_args.get("name", "") + arguments_str = function_args.get("arguments", "") + server_name = function_args.get("server_name") + + if not prompt_name: + return {"name": self.name, "content": "❌ 请提供提示模板名称"} + + arguments = None + if arguments_str: + try: + arguments = json.loads(arguments_str) + except json.JSONDecodeError: + return {"name": self.name, "content": "❌ 参数格式错误,请使用 JSON 对象格式"} + + result = await mcp_manager.get_prompt(prompt_name, arguments, server_name) + + if result.success: + return {"name": self.name, "content": result.content} + else: + return {"name": self.name, "content": f"❌ 获取提示模板失败: {result.error}"} + + async def direct_execute(self, **function_args) -> Dict[str, Any]: + return await self.execute(function_args) + + +class MCPStatusTool(BaseTool): + """MCP 状态查询工具""" + + name = "mcp_status" + description = "查询 MCP 桥接插件的状态,包括服务器连接状态、可用工具列表、资源列表、提示模板列表、调用统计、追踪记录等信息" + parameters = [ + ("query_type", ToolParamType.STRING, "查询类型", False, ["status", "tools", "resources", "prompts", "stats", "trace", "cache", "all"]), + ("server_name", ToolParamType.STRING, "指定服务器名称(可选)", False, None), + ] + available_for_llm = True + + async def execute(self, function_args: Dict[str, Any]) -> Dict[str, Any]: + query_type = function_args.get("query_type", "status") + server_name = function_args.get("server_name") + + result_parts = [] + + if query_type in ("status", "all"): + result_parts.append(self._format_status(server_name)) + + if query_type in ("tools", "all"): + result_parts.append(self._format_tools(server_name)) + + if query_type in ("resources", "all"): + result_parts.append(self._format_resources(server_name)) + + if query_type in ("prompts", "all"): + result_parts.append(self._format_prompts(server_name)) + + if query_type in ("stats", "all"): + result_parts.append(self._format_stats(server_name)) + + # v1.4.0: 追踪记录 + if query_type in ("trace",): + result_parts.append(self._format_trace()) + + # v1.4.0: 缓存状态 + if query_type in ("cache",): + result_parts.append(self._format_cache()) + + return { + "name": self.name, + "content": "\n\n".join(result_parts) if result_parts else "未知的查询类型" + } + + def _format_status(self, server_name: Optional[str] = None) -> str: + status = mcp_manager.get_status() + lines = ["📊 MCP 桥接插件状态"] + lines.append(f" 总服务器数: {status['total_servers']}") + lines.append(f" 已连接: {status['connected_servers']}") + lines.append(f" 已断开: {status['disconnected_servers']}") + lines.append(f" 可用工具数: {status['total_tools']}") + lines.append(f" 心跳检测: {'运行中' if status['heartbeat_running'] else '已停止'}") + + lines.append("\n🔌 服务器详情:") + for name, info in status['servers'].items(): + if server_name and name != server_name: + continue + status_icon = "✅" if info['connected'] else "❌" + enabled_text = "" if info['enabled'] else " (已禁用)" + lines.append(f" {status_icon} {name}{enabled_text}") + lines.append(f" 传输: {info['transport']}, 工具数: {info['tools_count']}") + if info['consecutive_failures'] > 0: + lines.append(f" ⚠️ 连续失败: {info['consecutive_failures']} 次") + + return "\n".join(lines) + + def _format_tools(self, server_name: Optional[str] = None) -> str: + tools = mcp_manager.all_tools + lines = ["🔧 可用 MCP 工具"] + + by_server: Dict[str, List[str]] = {} + for tool_key, (tool_info, _) in tools.items(): + if server_name and tool_info.server_name != server_name: + continue + if tool_info.server_name not in by_server: + by_server[tool_info.server_name] = [] + by_server[tool_info.server_name].append(f" • {tool_key}: {tool_info.description[:50]}...") + + for srv_name, tool_list in by_server.items(): + lines.append(f"\n📦 {srv_name} ({len(tool_list)} 个工具):") + lines.extend(tool_list) + + if not by_server: + lines.append(" (无可用工具)") + + return "\n".join(lines) + + def _format_stats(self, server_name: Optional[str] = None) -> str: + stats = mcp_manager.get_all_stats() + lines = ["📈 调用统计"] + + g = stats['global'] + lines.append(f" 总调用次数: {g['total_tool_calls']}") + lines.append(f" 成功: {g['successful_calls']}, 失败: {g['failed_calls']}") + if g['total_tool_calls'] > 0: + success_rate = (g['successful_calls'] / g['total_tool_calls']) * 100 + lines.append(f" 成功率: {success_rate:.1f}%") + lines.append(f" 运行时间: {g['uptime_seconds']:.0f} 秒") + + return "\n".join(lines) + + def _format_resources(self, server_name: Optional[str] = None) -> str: + resources = mcp_manager.all_resources + if not resources: + return "📦 当前没有可用的 MCP 资源" + + lines = ["📦 可用 MCP 资源"] + by_server: Dict[str, List[MCPResourceInfo]] = {} + for key, (resource_info, _) in resources.items(): + if server_name and resource_info.server_name != server_name: + continue + if resource_info.server_name not in by_server: + by_server[resource_info.server_name] = [] + by_server[resource_info.server_name].append(resource_info) + + for srv_name, resource_list in by_server.items(): + lines.append(f"\n🔌 {srv_name} ({len(resource_list)} 个资源):") + for res in resource_list: + lines.append(f" • {res.name}: {res.uri}") + + return "\n".join(lines) + + def _format_prompts(self, server_name: Optional[str] = None) -> str: + prompts = mcp_manager.all_prompts + if not prompts: + return "📝 当前没有可用的 MCP 提示模板" + + lines = ["📝 可用 MCP 提示模板"] + by_server: Dict[str, List[MCPPromptInfo]] = {} + for key, (prompt_info, _) in prompts.items(): + if server_name and prompt_info.server_name != server_name: + continue + if prompt_info.server_name not in by_server: + by_server[prompt_info.server_name] = [] + by_server[prompt_info.server_name].append(prompt_info) + + for srv_name, prompt_list in by_server.items(): + lines.append(f"\n🔌 {srv_name} ({len(prompt_list)} 个模板):") + for prompt in prompt_list: + lines.append(f" • {prompt.name}") + + return "\n".join(lines) + + def _format_trace(self) -> str: + """v1.4.0: 格式化追踪记录""" + records = tool_call_tracer.get_recent(10) + if not records: + return "🔍 暂无调用追踪记录" + + lines = ["🔍 最近调用追踪记录"] + for r in reversed(records): + status = "✅" if r.success else "❌" + cache = "📦" if r.cache_hit else "" + post = "🔄" if r.post_processed else "" + lines.append(f" {status}{cache}{post} {r.tool_name} ({r.duration_ms:.0f}ms)") + if r.error: + lines.append(f" 错误: {r.error[:50]}") + + return "\n".join(lines) + + def _format_cache(self) -> str: + """v1.4.0: 格式化缓存状态""" + stats = tool_call_cache.get_stats() + lines = ["🗄️ 缓存状态"] + lines.append(f" 启用: {'是' if stats['enabled'] else '否'}") + lines.append(f" 条目数: {stats['entries']}/{stats['max_entries']}") + lines.append(f" TTL: {stats['ttl']}秒") + lines.append(f" 命中: {stats['hits']}, 未命中: {stats['misses']}") + lines.append(f" 命中率: {stats['hit_rate']}") + return "\n".join(lines) + + async def direct_execute(self, **function_args) -> Dict[str, Any]: + return await self.execute(function_args) + + +# ============================================================================ +# 命令处理 +# ============================================================================ + +class MCPStatusCommand(BaseCommand): + """MCP 状态查询命令 - 通过 /mcp 命令查看服务器状态""" + + command_name = "mcp_status_command" + command_description = "查看 MCP 服务器连接状态和统计信息" + command_pattern = r"^[//]mcp(?:\s+(?Pstatus|tools|stats|reconnect|trace|cache|perm|export|search))?(?:\s+(?P.+))?$" + + async def execute(self) -> Tuple[bool, Optional[str], bool]: + """执行命令""" + subcommand = self.matched_groups.get("subcommand", "status") or "status" + arg = self.matched_groups.get("arg") + + if subcommand == "reconnect": + return await self._handle_reconnect(arg) + + # v1.4.0: 追踪命令 + if subcommand == "trace": + return await self._handle_trace(arg) + + # v1.4.0: 缓存命令 + if subcommand == "cache": + return await self._handle_cache(arg) + + # v1.4.0: 权限命令 + if subcommand == "perm": + return await self._handle_perm(arg) + + # v1.6.0: 导出命令 + if subcommand == "export": + return await self._handle_export(arg) + + # v1.7.0: 工具搜索命令 + if subcommand == "search": + return await self._handle_search(arg) + + result = self._format_output(subcommand, arg) + await self.send_text(result) + return (True, None, True) + + def _find_similar_servers(self, name: str, max_results: int = 3) -> List[str]: + """查找相似的服务器名称""" + name_lower = name.lower() + all_servers = list(mcp_manager._clients.keys()) + + # 简单的相似度匹配:包含关系或前缀匹配 + similar = [] + for srv in all_servers: + srv_lower = srv.lower() + if name_lower in srv_lower or srv_lower in name_lower: + similar.append(srv) + elif srv_lower.startswith(name_lower[:3]) if len(name_lower) >= 3 else False: + similar.append(srv) + + return similar[:max_results] + + async def _handle_reconnect(self, server_name: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: + """处理重连请求""" + if server_name: + if server_name not in mcp_manager._clients: + # 提示相似的服务器名 + similar = self._find_similar_servers(server_name) + msg = f"❌ 服务器 '{server_name}' 不存在" + if similar: + msg += f"\n💡 你是不是想找: {', '.join(similar)}" + await self.send_text(msg) + return (True, None, True) + + await self.send_text(f"🔄 正在重连服务器 {server_name}...") + success = await mcp_manager.reconnect_server(server_name) + if success: + await self.send_text(f"✅ 服务器 {server_name} 重连成功") + else: + await self.send_text(f"❌ 服务器 {server_name} 重连失败") + else: + disconnected = mcp_manager.disconnected_servers + if not disconnected: + await self.send_text("✅ 所有服务器都已连接") + return (True, None, True) + + await self.send_text(f"🔄 正在重连 {len(disconnected)} 个断开的服务器...") + for srv in disconnected: + success = await mcp_manager.reconnect_server(srv) + status = "✅" if success else "❌" + await self.send_text(f"{status} {srv}") + + return (True, None, True) + + async def _handle_trace(self, arg: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: + """v1.4.0: 处理追踪命令""" + if arg and arg.isdigit(): + # /mcp trace 20 - 最近 N 条 + n = int(arg) + records = tool_call_tracer.get_recent(n) + elif arg: + # /mcp trace - 特定工具 + records = tool_call_tracer.get_by_tool(arg) + else: + # /mcp trace - 最近 10 条 + records = tool_call_tracer.get_recent(10) + + if not records: + await self.send_text("🔍 暂无调用追踪记录\n\n用法: /mcp trace [数量|工具名]") + return (True, None, True) + + lines = [f"🔍 调用追踪记录 ({len(records)} 条)"] + lines.append("-" * 30) + for i, r in enumerate(reversed(records)): + status_icon = "✅" if r.success else "❌" + cache_tag = " [缓存]" if r.cache_hit else "" + post_tag = " [后处理]" if r.post_processed else "" + ts = time.strftime("%H:%M:%S", time.localtime(r.timestamp)) + lines.append(f"{status_icon} [{ts}] {r.tool_name}") + lines.append(f" {r.duration_ms:.0f}ms | {r.server_name}{cache_tag}{post_tag}") + if r.error: + lines.append(f" 错误: {r.error[:50]}") + if i < len(records) - 1: + lines.append("") + + await self.send_text("\n".join(lines)) + return (True, None, True) + + async def _handle_cache(self, arg: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: + """v1.4.0: 处理缓存命令""" + if arg == "clear": + tool_call_cache.clear() + await self.send_text("✅ 缓存已清空") + return (True, None, True) + + stats = tool_call_cache.get_stats() + lines = ["🗄️ 缓存状态"] + lines.append(f"├ 启用: {'是' if stats['enabled'] else '否'}") + lines.append(f"├ 条目: {stats['entries']}/{stats['max_entries']}") + lines.append(f"├ TTL: {stats['ttl']}秒") + lines.append(f"├ 命中: {stats['hits']}") + lines.append(f"├ 未命中: {stats['misses']}") + lines.append(f"└ 命中率: {stats['hit_rate']}") + + await self.send_text("\n".join(lines)) + return (True, None, True) + + async def _handle_perm(self, arg: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: + """v1.4.0: 处理权限命令""" + global _plugin_instance + + if _plugin_instance is None: + await self.send_text("❌ 插件未初始化") + return (True, None, True) + + perm_config = _plugin_instance.config.get("permissions", {}) + enabled = perm_config.get("perm_enabled", False) + default_mode = perm_config.get("perm_default_mode", "allow_all") + + if arg: + # 查看特定工具的权限 + rules = permission_checker.get_rules_for_tool(arg) + if not rules: + await self.send_text(f"🔐 工具 {arg} 无特定权限规则\n默认模式: {default_mode}") + else: + lines = [f"🔐 工具 {arg} 的权限规则:"] + for r in rules: + lines.append(f" • 模式: {r.get('mode', 'default')}") + if r.get("allowed"): + lines.append(f" 允许: {', '.join(r['allowed'][:3])}...") + if r.get("denied"): + lines.append(f" 拒绝: {', '.join(r['denied'][:3])}...") + await self.send_text("\n".join(lines)) + else: + # 查看权限配置概览 + lines = ["🔐 权限控制配置"] + lines.append(f"├ 启用: {'是' if enabled else '否'}") + lines.append(f"├ 默认模式: {default_mode}") + # 快捷配置 + deny_count = len(permission_checker._quick_deny_groups) + allow_count = len(permission_checker._quick_allow_users) + if deny_count > 0: + lines.append(f"├ 禁用群: {deny_count} 个") + if allow_count > 0: + lines.append(f"├ 管理员白名单: {allow_count} 人") + lines.append(f"└ 高级规则: {len(permission_checker._rules)} 条") + await self.send_text("\n".join(lines)) + + return (True, None, True) + + async def _handle_export(self, format_type: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: + """v1.6.0: 处理导出命令""" + global _plugin_instance + + if _plugin_instance is None: + await self.send_text("❌ 插件未初始化") + return (True, None, True) + + # 获取当前服务器列表 + servers_section = _plugin_instance.config.get("servers", {}) + servers_list_str = servers_section.get("list", "[]") if isinstance(servers_section, dict) else "[]" + + try: + servers = json.loads(servers_list_str) if servers_list_str.strip() else [] + except json.JSONDecodeError: + await self.send_text("❌ 当前服务器配置格式错误,无法导出") + return (True, None, True) + + if not servers: + await self.send_text("📤 当前没有配置任何服务器") + return (True, None, True) + + # 确定导出格式 + format_type = (format_type or "claude").lower() + if format_type not in ("claude", "kiro", "maibot"): + format_type = "claude" + + # 导出 + try: + exported = ConfigConverter.export_to_string(servers, format_type, pretty=True) + + format_name = {"claude": "Claude Desktop", "kiro": "Kiro MCP", "maibot": "MaiBot"}.get(format_type, format_type) + lines = [f"📤 导出为 {format_name} 格式 ({len(servers)} 个服务器):"] + lines.append("") + lines.append(exported) + + await self.send_text("\n".join(lines)) + except Exception as e: + logger.error(f"导出配置失败: {e}") + await self.send_text(f"❌ 导出失败: {str(e)}") + + return (True, None, True) + + async def _handle_search(self, query: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: + """v1.7.0: 处理工具搜索命令""" + if not query or not query.strip(): + # 显示使用帮助 + help_text = """🔍 工具搜索 + +用法: /mcp search <关键词> + +示例: + /mcp search time 搜索包含 time 的工具 + /mcp search fetch 搜索包含 fetch 的工具 + /mcp search * 列出所有工具 + +支持模糊匹配工具名称和描述""" + await self.send_text(help_text) + return (True, None, True) + + query = query.strip().lower() + tools = mcp_manager.all_tools + + if not tools: + await self.send_text("🔍 当前没有可用的 MCP 工具") + return (True, None, True) + + # 搜索匹配的工具 + matched = [] + for tool_key, (tool_info, client) in tools.items(): + tool_name = tool_key.lower() + tool_desc = (tool_info.description or "").lower() + + # * 表示列出所有 + if query == "*": + matched.append((tool_key, tool_info, client)) + elif query in tool_name or query in tool_desc: + matched.append((tool_key, tool_info, client)) + + if not matched: + await self.send_text(f"🔍 未找到匹配 '{query}' 的工具") + return (True, None, True) + + # 按服务器分组显示 + by_server: Dict[str, List[Tuple[str, Any]]] = {} + for tool_key, tool_info, client in matched: + server_name = tool_info.server_name + if server_name not in by_server: + by_server[server_name] = [] + by_server[server_name].append((tool_key, tool_info)) + + # 如果只有一个服务器或结果较少,显示全部;否则折叠 + single_server = len(by_server) == 1 + lines = [f"🔍 搜索结果: {len(matched)} 个工具匹配 '{query}'"] + + for srv_name, tool_list in by_server.items(): + lines.append(f"\n📦 {srv_name} ({len(tool_list)} 个):") + + # 单服务器或结果少于 15 个时显示全部 + show_all = single_server or len(matched) <= 15 + display_limit = len(tool_list) if show_all else 5 + + for tool_key, tool_info in tool_list[:display_limit]: + desc = tool_info.description[:40] + "..." if len(tool_info.description) > 40 else tool_info.description + lines.append(f" • {tool_key}") + lines.append(f" {desc}") + if len(tool_list) > display_limit: + lines.append(f" ... 还有 {len(tool_list) - display_limit} 个,用 /mcp search {query} {srv_name} 筛选") + + await self.send_text("\n".join(lines)) + return (True, None, True) + + def _format_output(self, subcommand: str, server_name: str = None) -> str: + """格式化输出""" + status = mcp_manager.get_status() + stats = mcp_manager.get_all_stats() + lines = [] + + if subcommand in ("status", "all"): + lines.append("📊 MCP 桥接插件状态") + lines.append(f"├ 服务器: {status['connected_servers']}/{status['total_servers']} 已连接") + lines.append(f"├ 工具数: {status['total_tools']}") + lines.append(f"└ 心跳: {'运行中' if status['heartbeat_running'] else '已停止'}") + + if status["servers"]: + lines.append("\n🔌 服务器列表:") + for name, info in status["servers"].items(): + if server_name and name != server_name: + continue + icon = "✅" if info["connected"] else "❌" + enabled = "" if info["enabled"] else " (禁用)" + lines.append(f" {icon} {name}{enabled}") + lines.append(f" {info['transport']} | {info['tools_count']} 工具") + # 显示断路器状态 + cb = info.get("circuit_breaker", {}) + cb_state = cb.get("state", "closed") + if cb_state == "open": + lines.append(f" ⚡ 断路器熔断中") + elif cb_state == "half_open": + lines.append(f" ⚡ 断路器试探中") + if info["consecutive_failures"] > 0: + lines.append(f" ⚠️ 连续失败 {info['consecutive_failures']} 次") + + if subcommand in ("tools", "all"): + tools = mcp_manager.all_tools + if tools: + lines.append("\n🔧 可用工具:") + by_server = {} + for key, (info, _) in tools.items(): + if server_name and info.server_name != server_name: + continue + by_server.setdefault(info.server_name, []).append(info.name) + + # 如果指定了服务器名,显示全部工具;否则折叠显示 + show_all = server_name is not None + + for srv, tool_list in by_server.items(): + lines.append(f" 📦 {srv} ({len(tool_list)})") + if show_all: + # 指定服务器时显示全部 + for t in tool_list: + lines.append(f" • {t}") + else: + # 未指定时折叠显示 + for t in tool_list[:5]: + lines.append(f" • {t}") + if len(tool_list) > 5: + lines.append(f" ... 还有 {len(tool_list) - 5} 个,用 /mcp tools {srv} 查看全部") + + if subcommand in ("stats", "all"): + g = stats["global"] + lines.append("\n📈 调用统计:") + lines.append(f" 总调用: {g['total_tool_calls']}") + if g["total_tool_calls"] > 0: + rate = (g["successful_calls"] / g["total_tool_calls"]) * 100 + lines.append(f" 成功率: {rate:.1f}%") + lines.append(f" 运行: {g['uptime_seconds']:.0f}秒") + + if not lines: + lines.append("📖 MCP 桥接插件命令帮助") + lines.append("") + lines.append("状态查询:") + lines.append(" /mcp 查看连接状态") + lines.append(" /mcp tools 查看所有工具") + lines.append(" /mcp tools <服务器> 查看指定服务器工具") + lines.append(" /mcp stats 查看调用统计") + lines.append("") + lines.append("工具搜索:") + lines.append(" /mcp search <关键词> 搜索工具") + lines.append(" /mcp search * 列出所有工具") + lines.append("") + lines.append("服务器管理:") + lines.append(" /mcp reconnect 重连断开的服务器") + lines.append(" /mcp reconnect <名称> 重连指定服务器") + lines.append("") + lines.append("配置导入导出:") + lines.append(" /mcp import 导入配置") + lines.append(" /mcp export [格式] 导出配置") + lines.append("") + lines.append("其他:") + lines.append(" /mcp trace 查看调用追踪") + lines.append(" /mcp cache 查看缓存状态") + lines.append(" /mcp perm 查看权限配置") + + return "\n".join(lines) + + +class MCPImportCommand(BaseCommand): + """v1.6.0: MCP 配置导入命令 - 支持从 Claude Desktop 格式导入""" + + command_name = "mcp_import_command" + command_description = "从 Claude Desktop 或其他格式导入 MCP 服务器配置" + # 匹配 /mcp import 后面的所有内容(包括多行 JSON) + command_pattern = r"^[//]mcp\s+import(?:\s+(?P.+))?$" + + async def execute(self) -> Tuple[bool, Optional[str], bool]: + """执行导入命令""" + global _plugin_instance + + if _plugin_instance is None: + await self.send_text("❌ 插件未初始化") + return (True, None, True) + + content = self.matched_groups.get("content", "") + + if not content or not content.strip(): + # 显示使用帮助 + help_text = """📥 MCP 配置导入 + +用法: /mcp import + +支持的格式: +• Claude Desktop 格式 (mcpServers 对象) +• Kiro MCP 格式 +• MaiBot 格式 (数组) + +示例: +/mcp import {"mcpServers":{"time":{"command":"uvx","args":["mcp-server-time"]}}} + +/mcp import {"mcpServers":{"api":{"url":"https://example.com/mcp","transport":"sse"}}}""" + await self.send_text(help_text) + return (True, None, True) + + # 获取现有服务器名称 + servers_section = _plugin_instance.config.get("servers", {}) + servers_list_str = servers_section.get("list", "[]") if isinstance(servers_section, dict) else "[]" + + try: + existing_servers = json.loads(servers_list_str) if servers_list_str.strip() else [] + except json.JSONDecodeError: + existing_servers = [] + + existing_names = {srv.get("name", "") for srv in existing_servers if isinstance(srv, dict)} + + # 执行导入 + result = ConfigConverter.import_from_string(content.strip(), existing_names) + + # 构建响应 + lines = [] + + if not result.success: + lines.append("❌ 导入失败:") + for err in result.errors: + lines.append(f" • {err}") + await self.send_text("\n".join(lines)) + return (True, None, True) + + if not result.servers: + lines.append("⚠️ 没有新服务器可导入") + if result.skipped: + lines.append("\n跳过的服务器:") + for s in result.skipped: + lines.append(f" • {s}") + if result.warnings: + lines.append("\n警告:") + for w in result.warnings: + lines.append(f" • {w}") + await self.send_text("\n".join(lines)) + return (True, None, True) + + # 合并到现有列表 + new_servers = existing_servers + result.servers + new_list_str = json.dumps(new_servers, ensure_ascii=False, indent=2) + + # 更新配置 + if "servers" not in _plugin_instance.config: + _plugin_instance.config["servers"] = {} + _plugin_instance.config["servers"]["list"] = new_list_str + + # 保存到配置文件 + _plugin_instance._save_servers_list(new_list_str) + + # 构建成功响应 + lines.append(f"✅ 成功导入 {len(result.servers)} 个服务器:") + for srv in result.servers: + transport = srv.get("transport", "stdio") + lines.append(f" • {srv.get('name')} ({transport})") + + if result.skipped: + lines.append(f"\n⏭️ 跳过 {len(result.skipped)} 个:") + for s in result.skipped[:5]: + lines.append(f" • {s}") + if len(result.skipped) > 5: + lines.append(f" ... 还有 {len(result.skipped) - 5} 个") + + if result.warnings: + lines.append("\n⚠️ 警告:") + for w in result.warnings[:3]: + lines.append(f" • {w}") + + if result.errors: + lines.append("\n❌ 部分失败:") + for e in result.errors[:3]: + lines.append(f" • {e}") + + lines.append("\n💡 发送 /mcp reconnect 使配置生效") + + await self.send_text("\n".join(lines)) + return (True, None, True) + + +# ============================================================================ +# 事件处理器 +# ============================================================================ + +class MCPStartupHandler(BaseEventHandler): + """MCP 启动事件处理器""" + + event_type = EventType.ON_START + handler_name = "mcp_startup_handler" + handler_description = "MCP 桥接插件启动处理器" + weight = 0 + intercept_message = False + + async def execute(self, message: Optional[Any]) -> Tuple[bool, bool, Optional[str], None, None]: + """处理启动事件""" + global _plugin_instance + + if _plugin_instance is None: + logger.warning("MCP 桥接插件实例未初始化") + return (False, True, None, None, None) + + logger.info("MCP 桥接插件收到 ON_START 事件,开始连接 MCP 服务器...") + await _plugin_instance._async_connect_servers() + + await mcp_manager.start_heartbeat() + + # v1.6.0: 启动配置文件监控(用于 WebUI 导入) + await _plugin_instance._start_config_watcher() + + return (True, True, None, None, None) + + +class MCPStopHandler(BaseEventHandler): + """MCP 停止事件处理器""" + + event_type = EventType.ON_STOP + handler_name = "mcp_stop_handler" + handler_description = "MCP 桥接插件停止处理器" + weight = 0 + intercept_message = False + + async def execute(self, message: Optional[Any]) -> Tuple[bool, bool, Optional[str], None, None]: + """处理停止事件""" + global _plugin_instance + + logger.info("MCP 桥接插件收到 ON_STOP 事件,正在关闭...") + + # v1.6.0: 停止配置文件监控 + if _plugin_instance: + await _plugin_instance._stop_config_watcher() + + await mcp_manager.shutdown() + mcp_tool_registry.clear() + + logger.info("MCP 桥接插件已关闭所有连接") + return (True, True, None, None, None) + + +# ============================================================================ +# 主插件类 +# ============================================================================ + +@register_plugin +class MCPBridgePlugin(BasePlugin): + """MCP 桥接插件 v1.4.0 - 将 MCP 服务器的工具桥接到 MaiBot""" + + plugin_name: str = "mcp_bridge_plugin" + enable_plugin: bool = False # 默认禁用,用户需在 WebUI 手动启用 + dependencies: List[str] = [] + python_dependencies: List[str] = ["mcp"] + config_file_name: str = "config.toml" + + config_section_descriptions = { + "guide": "📖 快速入门", + "plugin": "🔘 插件开关", + "import_export": "📥 导入导出", + "quick_add": "➕ 快速添加服务器", + "servers": "🔌 服务器列表", + "status": "📊 运行状态", + "settings": "⚙️ 高级设置", + "tools": "🔧 工具管理", + "permissions": "🔐 权限控制", + } + + config_schema: dict = { + # 新手引导区(只读) + "guide": { + "quick_start": ConfigField( + type=str, + default="1. 从下方链接获取 MCP 服务器 2. 在「快速添加」填写信息 3. 保存后发送 /mcp reconnect", + description="三步开始使用", + label="🚀 快速入门", + disabled=True, + order=1, + ), + "mcp_sources": ConfigField( + type=str, + default="https://modelscope.cn/mcp (魔搭·推荐) | https://smithery.ai | https://glama.ai | https://mcp.so", + description="复制链接到浏览器打开,获取免费 MCP 服务器", + label="🌐 获取 MCP 服务器", + disabled=True, + hint="魔搭 ModelScope 国内免费推荐,复制服务器 URL 到「快速添加」即可", + order=2, + ), + "example_config": ConfigField( + type=str, + default='{"name": "time", "enabled": true, "transport": "streamable_http", "url": "https://mcp.api-inference.modelscope.cn/server/mcp-server-time"}', + description="复制到服务器列表可直接使用(免费时间服务器)", + label="📝 配置示例", + disabled=True, + order=3, + ), + }, + "plugin": { + "enabled": ConfigField( + type=bool, + default=True, + description="是否启用插件", + label="启用插件", + ), + }, + # v1.6.0: 导入导出配置 + "import_export": { + "import_config": ConfigField( + type=str, + default="", + description="粘贴 Claude Desktop 或其他格式的 MCP 配置 JSON", + label="📥 导入配置", + input_type="textarea", + rows=8, + placeholder='{"mcpServers":{"time":{"command":"uvx","args":["mcp-server-time"]}}}', + hint="粘贴配置后点击保存,2秒内自动导入。查看下方「导入结果」确认状态", + order=1, + ), + "import_result": ConfigField( + type=str, + default="", + description="导入结果(只读)", + label="📋 导入结果", + input_type="textarea", + disabled=True, + rows=4, + order=2, + ), + "export_format": ConfigField( + type=str, + default="claude", + description="导出格式", + label="📤 导出格式", + choices=["claude", "kiro", "maibot"], + hint="claude: Claude Desktop 格式 | kiro: Kiro MCP 格式 | maibot: 本插件格式", + order=3, + ), + "export_result": ConfigField( + type=str, + default="(点击保存后生成)", + description="导出的配置(只读,可复制)", + label="📤 导出结果", + input_type="textarea", + disabled=True, + rows=10, + hint="复制此内容到 Claude Desktop 或其他支持 MCP 的应用", + order=4, + ), + }, + "settings": { + "tool_prefix": ConfigField( + type=str, + default="mcp", + description="🏷️ 工具前缀 - 生成的工具名格式: {前缀}_{服务器名}_{工具名}", + label="🏷️ 工具前缀", + placeholder="mcp", + order=1, + ), + "connect_timeout": ConfigField( + type=float, + default=30.0, + description="⏱️ 连接超时(秒)", + label="⏱️ 连接超时(秒)", + min=5.0, + max=120.0, + step=5.0, + order=2, + ), + "call_timeout": ConfigField( + type=float, + default=60.0, + description="⏱️ 调用超时(秒)", + label="⏱️ 调用超时(秒)", + min=10.0, + max=300.0, + step=10.0, + order=3, + ), + "auto_connect": ConfigField( + type=bool, + default=True, + description="🔄 启动时自动连接所有已启用的服务器", + label="🔄 自动连接", + order=4, + ), + "retry_attempts": ConfigField( + type=int, + default=3, + description="🔁 连接失败时的重试次数", + label="🔁 重试次数", + min=0, + max=10, + order=5, + ), + "retry_interval": ConfigField( + type=float, + default=5.0, + description="⏳ 重试间隔(秒)", + label="⏳ 重试间隔(秒)", + min=1.0, + max=60.0, + step=1.0, + order=6, + ), + "heartbeat_enabled": ConfigField( + type=bool, + default=True, + description="💓 定期检测服务器连接状态", + label="💓 启用心跳检测", + order=7, + ), + "heartbeat_interval": ConfigField( + type=float, + default=60.0, + description="💓 基准心跳间隔(秒)", + label="💓 心跳间隔(秒)", + min=10.0, + max=300.0, + step=10.0, + hint="智能心跳会根据服务器稳定性自动调整", + order=8, + ), + "heartbeat_adaptive": ConfigField( + type=bool, + default=True, + description="🧠 根据服务器稳定性自动调整心跳间隔", + label="🧠 智能心跳", + hint="稳定服务器逐渐增加间隔,断开的服务器缩短间隔", + order=9, + ), + "heartbeat_max_multiplier": ConfigField( + type=float, + default=3.0, + description="稳定服务器的最大间隔倍数", + label="📈 最大间隔倍数", + min=1.5, + max=5.0, + step=0.5, + hint="稳定服务器心跳间隔最高可达 基准间隔 × 此值", + order=10, + ), + "auto_reconnect": ConfigField( + type=bool, + default=True, + description="🔄 检测到断开时自动尝试重连", + label="🔄 自动重连", + order=11, + ), + "max_reconnect_attempts": ConfigField( + type=int, + default=3, + description="🔄 连续重连失败后暂停重连", + label="🔄 最大重连次数", + min=1, + max=10, + order=12, + ), + # v1.7.0: 状态刷新配置 + "status_refresh_enabled": ConfigField( + type=bool, + default=True, + description="📊 定期更新 WebUI 状态显示", + label="📊 启用状态实时刷新", + hint="关闭后 WebUI 状态仅在启动时更新", + order=13, + ), + "status_refresh_interval": ConfigField( + type=float, + default=10.0, + description="📊 状态刷新间隔(秒)", + label="📊 状态刷新间隔(秒)", + min=5.0, + max=60.0, + step=5.0, + hint="值越小刷新越频繁,但会增加少量磁盘写入", + order=14, + ), + "enable_resources": ConfigField( + type=bool, + default=False, + description="📦 允许读取 MCP 服务器提供的资源", + label="📦 启用 Resources(实验性)", + order=11, + ), + "enable_prompts": ConfigField( + type=bool, + default=False, + description="📝 允许使用 MCP 服务器提供的提示模板", + label="📝 启用 Prompts(实验性)", + order=12, + ), + # v1.3.0 后处理配置 + "post_process_enabled": ConfigField( + type=bool, + default=False, + description="🔄 使用 LLM 对长结果进行摘要提炼", + label="🔄 启用结果后处理", + order=20, + ), + "post_process_threshold": ConfigField( + type=int, + default=500, + description="📏 结果长度超过此值才触发后处理", + label="📏 后处理阈值(字符)", + min=100, + max=5000, + step=100, + order=21, + ), + "post_process_max_tokens": ConfigField( + type=int, + default=500, + description="📝 LLM 摘要输出的最大 token 数", + label="📝 后处理最大输出 token", + min=100, + max=2000, + step=50, + order=22, + ), + "post_process_model": ConfigField( + type=str, + default="", + description="🤖 指定用于后处理的模型名称", + label="🤖 后处理模型(可选)", + placeholder="留空则使用 Utils 模型组", + order=23, + ), + "post_process_prompt": ConfigField( + type=str, + default="用户问题:{query}\\n\\n工具返回内容:\\n{result}\\n\\n请从上述内容中提取与用户问题最相关的关键信息,简洁准确地输出:", + description="📋 后处理提示词模板", + label="📋 后处理提示词模板", + input_type="textarea", + rows=8, + order=24, + ), + # v1.4.0 追踪配置 + "trace_enabled": ConfigField( + type=bool, + default=True, + description="🔍 记录工具调用详情", + label="🔍 启用调用追踪", + order=30, + ), + "trace_max_records": ConfigField( + type=int, + default=100, + description="内存中保留的最大记录数", + label="📊 追踪记录上限", + min=10, + max=1000, + order=31, + ), + "trace_log_enabled": ConfigField( + type=bool, + default=False, + description="是否将追踪记录写入日志文件", + label="📝 追踪日志文件", + hint="启用后记录写入 plugins/MaiBot_MCPBridgePlugin/logs/trace.jsonl", + order=32, + ), + # v1.4.0 缓存配置 + "cache_enabled": ConfigField( + type=bool, + default=False, + description="🗄️ 缓存相同参数的调用结果", + label="🗄️ 启用调用缓存", + hint="相同参数的调用会返回缓存结果,减少重复请求", + order=40, + ), + "cache_ttl": ConfigField( + type=int, + default=300, + description="缓存有效期(秒)", + label="⏱️ 缓存有效期(秒)", + min=60, + max=3600, + order=41, + ), + "cache_max_entries": ConfigField( + type=int, + default=200, + description="最大缓存条目数(超出后 LRU 淘汰)", + label="📦 最大缓存条目", + min=50, + max=1000, + order=42, + ), + "cache_exclude_tools": ConfigField( + type=str, + default="", + description="不缓存的工具(每行一个,支持通配符 *)", + label="🚫 缓存排除列表", + input_type="textarea", + rows=4, + hint="时间类、随机类工具建议排除,如 mcp_time_*", + order=43, + ), + }, + # v1.4.0 工具管理 + "tools": { + "tool_list": ConfigField( + type=str, + default="(启动后自动生成)", + description="当前已注册的 MCP 工具列表(只读)", + label="📋 工具清单", + input_type="textarea", + disabled=True, + rows=12, + hint="从此处复制工具名到下方禁用列表", + order=1, + ), + "disabled_tools": ConfigField( + type=str, + default="", + description="要禁用的工具名(每行一个)", + label="🚫 禁用工具列表", + input_type="textarea", + rows=6, + hint="从上方工具清单复制工具名,每行一个。禁用后该工具不会被 LLM 调用", + order=2, + ), + }, + # v1.4.0 权限控制 + "permissions": { + "perm_enabled": ConfigField( + type=bool, + default=False, + description="🔐 按群/用户限制工具使用", + label="🔐 启用权限控制", + order=1, + ), + "perm_default_mode": ConfigField( + type=str, + default="allow_all", + description="默认模式:allow_all(默认允许)或 deny_all(默认禁止)", + label="📋 默认模式", + placeholder="allow_all", + hint="allow_all: 未配置的默认允许;deny_all: 未配置的默认禁止", + order=2, + ), + # 快捷配置(简化版) + "quick_deny_groups": ConfigField( + type=str, + default="", + description="禁止使用所有 MCP 工具的群号(每行一个)", + label="🚫 禁用群列表(快捷)", + input_type="textarea", + rows=4, + hint="填入群号,该群将无法使用任何 MCP 工具", + order=3, + ), + "quick_allow_users": ConfigField( + type=str, + default="", + description="始终允许使用所有工具的用户 QQ 号(管理员白名单,每行一个)", + label="✅ 管理员白名单(快捷)", + input_type="textarea", + rows=3, + hint="填入 QQ 号,该用户在任何场景都可使用 MCP 工具", + order=4, + ), + # 高级配置 + "perm_rules": ConfigField( + type=str, + default="[]", + description="高级权限规则(JSON 格式,可针对特定工具配置)", + label="📜 高级权限规则(可选)", + input_type="textarea", + rows=10, + placeholder='''[ + {"tool": "mcp_*_delete_*", "denied": ["qq:123456:group"]} +]''', + hint="格式: qq:ID:group/private/user,工具名支持通配符 *", + order=10, + ), + }, + # v1.5.1: 快速添加服务器(表单式配置) + "quick_add": { + "server_name": ConfigField( + type=str, + default="", + description="服务器唯一名称(英文,如 time-server)", + label="📛 服务器名称", + placeholder="my-mcp-server", + hint="必填,用于标识服务器", + order=1, + ), + "server_type": ConfigField( + type=str, + default="streamable_http", + description="传输类型", + label="📡 传输类型", + choices=["streamable_http", "http", "sse", "stdio"], + hint="远程服务器选 streamable_http/http/sse,本地选 stdio", + order=2, + ), + "server_url": ConfigField( + type=str, + default="", + description="服务器 URL(远程服务器必填)", + label="🌐 服务器 URL", + placeholder="https://mcp.api-inference.modelscope.cn/server/xxx", + hint="streamable_http/http/sse 类型必填", + order=3, + ), + "server_command": ConfigField( + type=str, + default="", + description="启动命令(stdio 类型必填)", + label="⌨️ 启动命令", + placeholder="uvx 或 npx", + hint="stdio 类型必填,如 uvx、npx、python", + order=4, + ), + "server_args": ConfigField( + type=str, + default="", + description="命令参数(每行一个)", + label="📝 命令参数", + input_type="textarea", + rows=3, + placeholder="mcp-server-fetch", + hint="stdio 类型使用,每行一个参数", + order=5, + ), + "server_headers": ConfigField( + type=str, + default="", + description="鉴权头(JSON 格式,可选)", + label="🔑 鉴权头(可选)", + placeholder='{"Authorization": "Bearer xxx"}', + hint="需要鉴权的服务器填写,如 ModelScope 的 API Key", + order=6, + ), + "add_button": ConfigField( + type=str, + default="填写上方信息后,点击保存将自动添加到服务器列表", + description="", + label="💡 使用说明", + disabled=True, + hint="保存配置后,新服务器会自动添加到下方列表。重启 MaiBot 或发送 /mcp reconnect 生效", + order=7, + ), + }, + "servers": { + "list": ConfigField( + type=str, + default="[]", + description="MCP 服务器列表(JSON 格式,高级用户可直接编辑)", + label="🔌 服务器列表(高级)", + input_type="textarea", + rows=15, + hint="⚠️ JSON 数组格式。新手建议使用上方「快速添加」", + order=1, + ), + }, + "status": { + "connection_status": ConfigField( + type=str, + default="未初始化", + description="当前 MCP 服务器连接状态和工具列表", + label="📊 连接状态", + input_type="textarea", + disabled=True, + rows=15, + hint="此状态仅在插件启动时更新。查询实时状态请发送 /mcp 命令", + order=1, + ), + }, + } + + @staticmethod + def _fix_config_multiline_strings(config_path: Path) -> bool: + """修复配置文件中的多行字符串格式问题 + + 处理两种情况: + 1. 带转义 \\n 的单行字符串(json.dumps 生成) + 2. 跨越多行但使用普通双引号的字符串(控制字符错误) + + Returns: + bool: 是否进行了修复 + """ + if not config_path.exists(): + return False + + try: + content = config_path.read_text(encoding="utf-8") + + # 情况1: 修复带转义 \n 的单行字符串 + # 匹配: key = "内容包含\n的字符串" + pattern1 = r'^(\s*\w+\s*=\s*)"((?:[^"\\]|\\.)*\\n(?:[^"\\]|\\.)*)"(\s*)$' + + # 情况2: 修复跨越多行的普通双引号字符串 + # 匹配: key = "第一行 + # 第二行 + # 第三行" + pattern2_start = r'^(\s*\w+\s*=\s*)"([^"]*?)$' # 开始行 + pattern2_end = r'^([^"]*)"(\s*)$' # 结束行 + + lines = content.split("\n") + fixed_lines = [] + modified = False + + i = 0 + while i < len(lines): + line = lines[i] + + # 情况1: 单行带转义换行符 + match1 = re.match(pattern1, line) + if match1: + prefix = match1.group(1) + value = match1.group(2) + suffix = match1.group(3) + # 将转义的换行符还原为实际换行符 + unescaped = value.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"').replace("\\\\", "\\") + fixed_line = f'{prefix}"""{unescaped}"""{suffix}' + fixed_lines.append(fixed_line) + modified = True + i += 1 + continue + + # 情况2: 跨越多行的字符串 + match2_start = re.match(pattern2_start, line) + if match2_start: + prefix = match2_start.group(1) + first_part = match2_start.group(2) + + # 收集后续行直到找到结束引号 + multiline_parts = [first_part] + j = i + 1 + found_end = False + + while j < len(lines): + next_line = lines[j] + match2_end = re.match(pattern2_end, next_line) + if match2_end: + multiline_parts.append(match2_end.group(1)) + suffix = match2_end.group(2) + found_end = True + j += 1 + break + else: + multiline_parts.append(next_line) + j += 1 + + if found_end and len(multiline_parts) > 1: + # 合并为三引号字符串 + full_value = "\n".join(multiline_parts) + fixed_line = f'{prefix}"""{full_value}"""{suffix}' + fixed_lines.append(fixed_line) + modified = True + i = j + continue + + fixed_lines.append(line) + i += 1 + + if modified: + config_path.write_text("\n".join(fixed_lines), encoding="utf-8") + logger.info("已自动修复配置文件中的多行字符串格式") + return True + + return False + except Exception as e: + logger.warning(f"修复配置文件格式失败: {e}") + return False + + def __init__(self, *args, **kwargs): + global _plugin_instance + + # 在父类初始化前尝试修复配置文件格式 + config_path = Path(__file__).parent / "config.toml" + self._fix_config_multiline_strings(config_path) + + super().__init__(*args, **kwargs) + self._initialized = False + _plugin_instance = self + + # 配置 MCP 管理器 + settings = self.config.get("settings", {}) + mcp_manager.configure(settings) + + # v1.4.0: 配置追踪器 + trace_log_path = Path(__file__).parent / "logs" / "trace.jsonl" + tool_call_tracer.configure( + enabled=settings.get("trace_enabled", True), + max_records=settings.get("trace_max_records", 100), + log_enabled=settings.get("trace_log_enabled", False), + log_path=trace_log_path, + ) + + # v1.4.0: 配置缓存 + tool_call_cache.configure( + enabled=settings.get("cache_enabled", False), + ttl=settings.get("cache_ttl", 300), + max_entries=settings.get("cache_max_entries", 200), + exclude_tools=settings.get("cache_exclude_tools", ""), + ) + + # v1.4.0: 配置权限检查器 + perm_config = self.config.get("permissions", {}) + permission_checker.configure( + enabled=perm_config.get("perm_enabled", False), + default_mode=perm_config.get("perm_default_mode", "allow_all"), + rules_json=perm_config.get("perm_rules", "[]"), + quick_deny_groups=perm_config.get("quick_deny_groups", ""), + quick_allow_users=perm_config.get("quick_allow_users", ""), + ) + + # 注册状态变化回调 + mcp_manager.set_status_change_callback(self._update_status_display) + + # v1.6.0: 处理 WebUI 导入导出 + self._process_webui_import_export() + + # v1.5.1: 处理快速添加服务器 + self._process_quick_add_server() + + def _process_webui_import_export(self) -> None: + """v1.6.0: 处理 WebUI 导入导出""" + import_export = self.config.get("import_export", {}) + import_config = import_export.get("import_config", "").strip() + export_format = import_export.get("export_format", "claude") + + # 处理导入 + if import_config: + self._do_webui_import(import_config) + + # 处理导出(每次都更新) + self._do_webui_export(export_format) + + def _do_webui_import(self, import_config: str) -> None: + """执行 WebUI 导入""" + # 获取现有服务器 + servers_section = self.config.get("servers", {}) + servers_list_str = servers_section.get("list", "[]") if isinstance(servers_section, dict) else "[]" + + try: + existing_servers = json.loads(servers_list_str) if servers_list_str.strip() else [] + except json.JSONDecodeError: + existing_servers = [] + + existing_names = {srv.get("name", "") for srv in existing_servers if isinstance(srv, dict)} + + # 执行导入 + result = ConfigConverter.import_from_string(import_config, existing_names) + + # 构建结果消息 + lines = [] + + if not result.success: + lines.append("❌ 导入失败:") + for err in result.errors: + lines.append(f" • {err}") + elif not result.servers: + lines.append("⚠️ 没有新服务器可导入") + if result.skipped: + lines.append(f"跳过: {', '.join(result.skipped[:5])}") + else: + # 合并到现有列表 + new_servers = existing_servers + result.servers + new_list_str = json.dumps(new_servers, ensure_ascii=False, indent=2) + + # 更新配置 + if "servers" not in self.config: + self.config["servers"] = {} + self.config["servers"]["list"] = new_list_str + + # 保存到配置文件 + self._save_servers_list(new_list_str) + + lines.append(f"✅ 成功导入 {len(result.servers)} 个服务器:") + for srv in result.servers[:5]: + lines.append(f" • {srv.get('name')} ({srv.get('transport', 'stdio')})") + if len(result.servers) > 5: + lines.append(f" ... 还有 {len(result.servers) - 5} 个") + + if result.skipped: + lines.append(f"跳过: {len(result.skipped)} 个已存在") + + lines.append("") + lines.append("💡 发送 /mcp reconnect 生效") + + # 更新导入结果显示 + if "import_export" not in self.config: + self.config["import_export"] = {} + self.config["import_export"]["import_result"] = "\n".join(lines) + + # 清空导入框 + self.config["import_export"]["import_config"] = "" + + # 保存结果到配置文件 + self._save_import_export_result("\n".join(lines)) + + def _do_webui_export(self, export_format: str) -> None: + """执行 WebUI 导出""" + # 获取当前服务器列表 + servers_section = self.config.get("servers", {}) + servers_list_str = servers_section.get("list", "[]") if isinstance(servers_section, dict) else "[]" + + try: + servers = json.loads(servers_list_str) if servers_list_str.strip() else [] + except json.JSONDecodeError: + servers = [] + + if not servers: + export_result = "(当前没有配置任何服务器)" + else: + try: + export_result = ConfigConverter.export_to_string(servers, export_format, pretty=True) + except Exception as e: + export_result = f"(导出失败: {e})" + + # 更新导出结果 + if "import_export" not in self.config: + self.config["import_export"] = {} + self.config["import_export"]["export_result"] = export_result + + def _save_import_export_result(self, result: str) -> None: + """保存导入导出结果到配置文件""" + import tomlkit + from tomlkit.items import String, StringType, Trivia + + try: + config_path = Path(__file__).parent / "config.toml" + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + + if "import_export" not in doc: + doc["import_export"] = tomlkit.table() + + # 清空导入框 + doc["import_export"]["import_config"] = "" + + # 更新结果 + if "\n" in result: + ml_string = String(StringType.MLB, result, result, Trivia()) + doc["import_export"]["import_result"] = ml_string + else: + doc["import_export"]["import_result"] = result + + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(doc, f) + except Exception as e: + logger.warning(f"保存导入结果失败: {e}") + + async def _start_config_watcher(self) -> None: + """v1.6.0: 启动配置文件监控(用于 WebUI 实时导入)""" + self._config_watcher_running = True + self._config_watcher_task = asyncio.create_task(self._config_watcher_loop()) + logger.info("配置文件监控已启动") + + async def _stop_config_watcher(self) -> None: + """v1.6.0: 停止配置文件监控""" + self._config_watcher_running = False + if hasattr(self, "_config_watcher_task") and self._config_watcher_task: + self._config_watcher_task.cancel() + try: + await self._config_watcher_task + except asyncio.CancelledError: + pass + self._config_watcher_task = None + logger.info("配置文件监控已停止") + + async def _config_watcher_loop(self) -> None: + """v1.6.0: 配置文件监控循环 + v1.7.0: 状态实时刷新""" + import tomlkit + + config_path = Path(__file__).parent / "config.toml" + last_mtime = config_path.stat().st_mtime if config_path.exists() else 0 + last_status_update = time.time() + + while self._config_watcher_running: + try: + await asyncio.sleep(2) # 每 2 秒检查一次 + + # v1.7.0: 定期更新状态显示(从配置读取) + settings = self.config.get("settings", {}) + status_refresh_enabled = settings.get("status_refresh_enabled", True) + status_refresh_interval = settings.get("status_refresh_interval", 10.0) + + current_time = time.time() + if status_refresh_enabled and current_time - last_status_update >= status_refresh_interval: + self._update_status_display() + last_status_update = current_time + + if not config_path.exists(): + continue + + current_mtime = config_path.stat().st_mtime + if current_mtime <= last_mtime: + continue + + last_mtime = current_mtime + logger.debug("检测到配置文件变化,检查是否有导入请求...") + + # 读取配置文件 + try: + with open(config_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + except Exception as e: + logger.warning(f"读取配置文件失败: {e}") + continue + + # 检查是否有导入配置 + import_export = doc.get("import_export", {}) + import_config = import_export.get("import_config", "") + + if not import_config or not str(import_config).strip(): + continue + + import_config_str = str(import_config).strip() + logger.info(f"检测到 WebUI 导入请求,开始处理...") + + # 执行导入 + await self._execute_webui_import(import_config_str, doc, config_path) + + except asyncio.CancelledError: + break + except Exception as e: + logger.error(f"配置监控循环出错: {e}") + await asyncio.sleep(5) + + async def _execute_webui_import(self, import_config: str, doc, config_path: Path) -> None: + """v1.6.0: 执行 WebUI 导入""" + import tomlkit + from tomlkit.items import String, StringType, Trivia + + # 获取现有服务器 + servers_section = doc.get("servers", {}) + servers_list_str = str(servers_section.get("list", "[]")) + + try: + existing_servers = json.loads(servers_list_str) if servers_list_str.strip() else [] + except json.JSONDecodeError: + existing_servers = [] + + existing_names = {srv.get("name", "") for srv in existing_servers if isinstance(srv, dict)} + + # 执行导入 + result = ConfigConverter.import_from_string(import_config, existing_names) + + # 构建结果消息 + lines = [] + + if not result.success: + lines.append("❌ 导入失败:") + for err in result.errors: + lines.append(f" • {err}") + elif not result.servers: + lines.append("⚠️ 没有新服务器可导入") + if result.skipped: + lines.append(f"跳过: {', '.join(result.skipped[:5])}") + else: + # 合并到现有列表 + new_servers = existing_servers + result.servers + new_list_str = json.dumps(new_servers, ensure_ascii=False, indent=2) + + # 更新 servers.list + if "servers" not in doc: + doc["servers"] = tomlkit.table() + ml_string = String(StringType.MLB, new_list_str, new_list_str, Trivia()) + doc["servers"]["list"] = ml_string + + lines.append(f"✅ 成功导入 {len(result.servers)} 个服务器:") + for srv in result.servers[:5]: + lines.append(f" • {srv.get('name')} ({srv.get('transport', 'stdio')})") + if len(result.servers) > 5: + lines.append(f" ... 还有 {len(result.servers) - 5} 个") + + if result.skipped: + lines.append(f"跳过: {len(result.skipped)} 个已存在") + + lines.append("") + lines.append("💡 发送 /mcp reconnect 使新服务器生效") + + logger.info(f"WebUI 导入成功: {len(result.servers)} 个服务器") + + # 更新导入结果并清空导入框 + if "import_export" not in doc: + doc["import_export"] = tomlkit.table() + + doc["import_export"]["import_config"] = "" + result_text = "\n".join(lines) + if "\n" in result_text: + ml_result = String(StringType.MLB, result_text, result_text, Trivia()) + doc["import_export"]["import_result"] = ml_result + else: + doc["import_export"]["import_result"] = result_text + + # 保存配置文件 + try: + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(doc, f) + logger.info("WebUI 导入结果已保存") + except Exception as e: + logger.error(f"保存导入结果失败: {e}") + + def _process_quick_add_server(self) -> None: + """v1.5.1: 处理快速添加服务器表单,将新服务器合并到列表""" + quick_add = self.config.get("quick_add", {}) + server_name = quick_add.get("server_name", "").strip() + + if not server_name: + return # 没有填写名称,跳过 + + server_type = quick_add.get("server_type", "streamable_http") + server_url = quick_add.get("server_url", "").strip() + server_command = quick_add.get("server_command", "").strip() + server_args_str = quick_add.get("server_args", "").strip() + server_headers_str = quick_add.get("server_headers", "").strip() + + # 构建新服务器配置 + new_server = { + "name": server_name, + "enabled": True, + "transport": server_type, + } + + if server_type == "stdio": + if not server_command: + logger.warning(f"快速添加: stdio 类型需要填写命令,跳过 {server_name}") + return + new_server["command"] = server_command + if server_args_str: + new_server["args"] = [arg.strip() for arg in server_args_str.split("\n") if arg.strip()] + else: + if not server_url: + logger.warning(f"快速添加: {server_type} 类型需要填写 URL,跳过 {server_name}") + return + new_server["url"] = server_url + + # 解析鉴权头 + if server_headers_str: + try: + headers = json.loads(server_headers_str) + if isinstance(headers, dict): + new_server["headers"] = headers + except json.JSONDecodeError: + logger.warning("快速添加: 鉴权头 JSON 格式错误,已忽略") + + # 获取现有服务器列表 + servers_section = self.config.get("servers", {}) + servers_list_str = servers_section.get("list", "[]") if isinstance(servers_section, dict) else "[]" + + try: + servers_list = json.loads(servers_list_str) if servers_list_str.strip() else [] + except json.JSONDecodeError: + servers_list = [] + + # 检查是否已存在同名服务器 + for existing in servers_list: + if existing.get("name") == server_name: + logger.info(f"快速添加: 服务器 {server_name} 已存在,跳过") + self._clear_quick_add_fields() + return + + # 添加新服务器 + servers_list.append(new_server) + logger.info(f"快速添加: 已添加服务器 {server_name} ({server_type})") + + # 更新配置 + new_list_str = json.dumps(servers_list, ensure_ascii=False, indent=2) + if "servers" not in self.config: + self.config["servers"] = {} + self.config["servers"]["list"] = new_list_str + + # 清空快速添加字段 + self._clear_quick_add_fields() + + # 保存到配置文件 + self._save_servers_list(new_list_str) + + def _clear_quick_add_fields(self) -> None: + """清空快速添加表单字段""" + if "quick_add" not in self.config: + self.config["quick_add"] = {} + self.config["quick_add"]["server_name"] = "" + self.config["quick_add"]["server_url"] = "" + self.config["quick_add"]["server_command"] = "" + self.config["quick_add"]["server_args"] = "" + self.config["quick_add"]["server_headers"] = "" + + def _save_servers_list(self, servers_json: str) -> None: + """保存服务器列表到配置文件""" + import tomlkit + from tomlkit.items import String, StringType, Trivia + + try: + config_path = Path(__file__).parent / "config.toml" + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + + if "servers" not in doc: + doc["servers"] = tomlkit.table() + + # 使用多行字符串 + ml_string = String(StringType.MLB, servers_json, servers_json, Trivia()) + doc["servers"]["list"] = ml_string + + # 清空快速添加字段 + if "quick_add" in doc: + doc["quick_add"]["server_name"] = "" + doc["quick_add"]["server_url"] = "" + doc["quick_add"]["server_command"] = "" + doc["quick_add"]["server_args"] = "" + doc["quick_add"]["server_headers"] = "" + + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(doc, f) + + logger.info("服务器列表已保存到配置文件") + except Exception as e: + logger.warning(f"保存服务器列表失败: {e}") + + def _get_disabled_tools(self) -> set: + """v1.4.0: 获取禁用的工具列表""" + tools_config = self.config.get("tools", {}) + disabled_str = tools_config.get("disabled_tools", "") + return {t.strip() for t in disabled_str.strip().split("\n") if t.strip()} + + async def _async_connect_servers(self) -> None: + """异步连接所有配置的 MCP 服务器(v1.5.0: 并行连接优化)""" + import asyncio + settings = self.config.get("settings", {}) + + servers_section = self.config.get("servers", []) + + if isinstance(servers_section, dict): + servers_list = servers_section.get("list", []) + if isinstance(servers_list, str): + servers_config = self._parse_servers_json(servers_list) + elif isinstance(servers_list, list): + servers_config = servers_list + else: + servers_config = [] + else: + servers_config = servers_section + + if not servers_config: + logger.warning("未配置任何 MCP 服务器") + self._initialized = True + return + + auto_connect = settings.get("auto_connect", True) + if not auto_connect: + logger.info("auto_connect 已禁用,跳过自动连接") + self._initialized = True + return + + tool_prefix = settings.get("tool_prefix", "mcp") + disabled_tools = self._get_disabled_tools() + enable_resources = settings.get("enable_resources", False) + enable_prompts = settings.get("enable_prompts", False) + + # 解析所有服务器配置 + enabled_configs: List[MCPServerConfig] = [] + for idx, server_conf in enumerate(servers_config): + server_name = server_conf.get("name", f"unknown_{idx}") + + if not server_conf.get("enabled", True): + logger.info(f"服务器 {server_name} 已禁用,跳过") + continue + + try: + config = self._parse_server_config(server_conf) + enabled_configs.append(config) + except Exception as e: + logger.error(f"解析服务器 {server_name} 配置失败: {e}") + + if not enabled_configs: + logger.warning("没有已启用的 MCP 服务器") + self._initialized = True + return + + logger.info(f"准备并行连接 {len(enabled_configs)} 个 MCP 服务器") + + # v1.5.0: 并行连接所有服务器 + async def connect_single_server(config: MCPServerConfig) -> Tuple[MCPServerConfig, bool]: + """连接单个服务器""" + logger.info(f"正在连接服务器: {config.name} ({config.transport.value})") + try: + success = await mcp_manager.add_server(config) + if success: + logger.info(f"✅ 服务器 {config.name} 连接成功") + # 获取资源和提示模板 + if enable_resources: + try: + await mcp_manager.fetch_resources_for_server(config.name) + except Exception as e: + logger.warning(f"服务器 {config.name} 获取资源列表失败: {e}") + if enable_prompts: + try: + await mcp_manager.fetch_prompts_for_server(config.name) + except Exception as e: + logger.warning(f"服务器 {config.name} 获取提示模板列表失败: {e}") + else: + logger.warning(f"❌ 服务器 {config.name} 连接失败") + return config, success + except Exception as e: + logger.error(f"❌ 服务器 {config.name} 连接异常: {e}") + return config, False + + # 并行执行所有连接 + start_time = time.time() + results = await asyncio.gather( + *[connect_single_server(cfg) for cfg in enabled_configs], + return_exceptions=True + ) + connect_duration = time.time() - start_time + + # 统计连接结果 + success_count = 0 + failed_count = 0 + for result in results: + if isinstance(result, Exception): + failed_count += 1 + logger.error(f"连接任务异常: {result}") + elif isinstance(result, tuple): + _, success = result + if success: + success_count += 1 + else: + failed_count += 1 + + logger.info(f"并行连接完成: {success_count} 成功, {failed_count} 失败, 耗时 {connect_duration:.2f}s") + + # 注册所有工具 + from src.plugin_system.core.component_registry import component_registry + registered_count = 0 + + for tool_key, (tool_info, _) in mcp_manager.all_tools.items(): + tool_name = tool_key.replace("-", "_").replace(".", "_") + is_disabled = tool_name in disabled_tools + + info, tool_class = mcp_tool_registry.register_tool( + tool_key, tool_info, tool_prefix, disabled=is_disabled + ) + info.plugin_name = self.plugin_name + + if component_registry.register_component(info, tool_class): + registered_count += 1 + status = "🚫" if is_disabled else "✅" + logger.info(f"{status} 注册 MCP 工具: {tool_class.name}") + else: + logger.warning(f"❌ 注册 MCP 工具失败: {tool_class.name}") + + self._initialized = True + logger.info(f"MCP 桥接插件初始化完成,已注册 {registered_count} 个工具") + + # 更新状态显示 + self._update_status_display() + self._update_tool_list_display() + + def _parse_servers_json(self, servers_list: str) -> List[Dict]: + """解析服务器列表 JSON 字符串""" + if not servers_list.strip(): + return [] + + content = servers_list.strip() + + try: + parsed = json.loads(content) + if isinstance(parsed, list): + return parsed + elif isinstance(parsed, dict): + logger.warning("服务器配置是单个对象,已自动转换为数组") + return [parsed] + else: + logger.error("服务器配置格式错误: 期望数组或对象") + return [] + except json.JSONDecodeError as e: + logger.warning(f"JSON 解析失败: {e}") + + if content.startswith("{") and not content.startswith("["): + try: + fixed_content = f"[{content}]" + parsed = json.loads(fixed_content) + if isinstance(parsed, list): + logger.warning("✅ 自动修复成功!请修正配置格式") + return parsed + except json.JSONDecodeError: + pass + + logger.error("❌ 服务器配置 JSON 格式错误") + return [] + + def _parse_server_config(self, conf: Dict) -> MCPServerConfig: + """解析服务器配置字典""" + transport_str = conf.get("transport", "stdio").lower() + + transport_map = { + "stdio": TransportType.STDIO, + "sse": TransportType.SSE, + "http": TransportType.HTTP, + "streamable_http": TransportType.STREAMABLE_HTTP, + } + transport = transport_map.get(transport_str, TransportType.STDIO) + + return MCPServerConfig( + name=conf.get("name", "unnamed"), + enabled=conf.get("enabled", True), + transport=transport, + command=conf.get("command", ""), + args=conf.get("args", []), + env=conf.get("env", {}), + url=conf.get("url", ""), + headers=conf.get("headers", {}), # v1.4.2: 鉴权头支持 + ) + + def _update_tool_list_display(self) -> None: + """v1.4.0: 更新工具列表显示""" + import tomlkit + + tools = mcp_manager.all_tools + disabled_tools = self._get_disabled_tools() + + lines = [] + by_server: Dict[str, List[str]] = {} + + for tool_key, (tool_info, _) in tools.items(): + tool_name = tool_key.replace("-", "_").replace(".", "_") + if tool_info.server_name not in by_server: + by_server[tool_info.server_name] = [] + + is_disabled = tool_name in disabled_tools + status = " ❌" if is_disabled else "" + by_server[tool_info.server_name].append(f" • {tool_name}{status}") + + for srv_name, tool_list in by_server.items(): + lines.append(f"📦 {srv_name} ({len(tool_list)}个工具):") + lines.extend(tool_list) + lines.append("") + + if not by_server: + lines.append("(无已注册工具)") + + tool_list_text = "\n".join(lines) + + # 更新内存配置 + if "tools" not in self.config: + self.config["tools"] = {} + self.config["tools"]["tool_list"] = tool_list_text + + # 写入配置文件 + try: + config_path = Path(__file__).parent / "config.toml" + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + + if "tools" not in doc: + doc["tools"] = tomlkit.table() + # 使用 tomlkit 多行字符串避免控制字符问题 + from tomlkit.items import String, StringType, Trivia + ml_string = String(StringType.MLB, tool_list_text, tool_list_text, Trivia()) + doc["tools"]["tool_list"] = ml_string + + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(doc, f) + except Exception as e: + logger.warning(f"更新工具列表显示失败: {e}") + + def _update_status_display(self) -> None: + """更新配置文件中的状态显示字段""" + import tomlkit + + status = mcp_manager.get_status() + settings = self.config.get("settings", {}) + lines = [] + + lines.append(f"服务器: {status['connected_servers']}/{status['total_servers']} 已连接") + lines.append(f"工具数: {status['total_tools']}") + if settings.get("enable_resources", False): + lines.append(f"资源数: {status.get('total_resources', 0)}") + if settings.get("enable_prompts", False): + lines.append(f"模板数: {status.get('total_prompts', 0)}") + lines.append(f"心跳: {'运行中' if status['heartbeat_running'] else '已停止'}") + lines.append("") + + tools = mcp_manager.all_tools + + for name, info in status.get("servers", {}).items(): + icon = "✅" if info["connected"] else "❌" + lines.append(f"{icon} {name} ({info['transport']})") + + # v1.7.0: 显示断路器状态 + cb_status = info.get("circuit_breaker", {}) + cb_state = cb_status.get("state", "closed") + if cb_state == "open": + lines.append(" ⚡ 断路器: 熔断中") + elif cb_state == "half_open": + lines.append(" ⚡ 断路器: 试探中") + + server_tools = [t.name for key, (t, _) in tools.items() if t.server_name == name] + if server_tools: + for tool_name in server_tools: + lines.append(f" • {tool_name}") + else: + lines.append(" (无工具)") + + if not status.get("servers"): + lines.append("(无服务器)") + + status_text = "\n".join(lines) + + if "status" not in self.config: + self.config["status"] = {} + self.config["status"]["connection_status"] = status_text + + try: + config_path = Path(__file__).parent / "config.toml" + if config_path.exists(): + with open(config_path, "r", encoding="utf-8") as f: + doc = tomlkit.load(f) + + if "status" not in doc: + doc["status"] = tomlkit.table() + # 使用 tomlkit 多行字符串避免控制字符问题 + from tomlkit.items import String, StringType, Trivia + ml_string = String(StringType.MLB, status_text, status_text, Trivia()) + doc["status"]["connection_status"] = ml_string + + with open(config_path, "w", encoding="utf-8") as f: + tomlkit.dump(doc, f) + except Exception as e: + logger.warning(f"更新配置文件状态失败: {e}") + + def get_plugin_components(self) -> List[Tuple[ComponentInfo, Type]]: + """返回插件的所有组件""" + components: List[Tuple[ComponentInfo, Type]] = [] + + # 事件处理器 + components.append((MCPStartupHandler.get_handler_info(), MCPStartupHandler)) + components.append((MCPStopHandler.get_handler_info(), MCPStopHandler)) + + # 命令 + components.append((MCPStatusCommand.get_command_info(), MCPStatusCommand)) + components.append((MCPImportCommand.get_command_info(), MCPImportCommand)) + + # 内置工具 + status_tool_info = ToolInfo( + name=MCPStatusTool.name, + tool_description=MCPStatusTool.description, + enabled=True, + tool_parameters=MCPStatusTool.parameters, + component_type=ComponentType.TOOL, + ) + components.append((status_tool_info, MCPStatusTool)) + + settings = self.config.get("settings", {}) + + if settings.get("enable_resources", False): + read_resource_info = ToolInfo( + name=MCPReadResourceTool.name, + tool_description=MCPReadResourceTool.description, + enabled=True, + tool_parameters=MCPReadResourceTool.parameters, + component_type=ComponentType.TOOL, + ) + components.append((read_resource_info, MCPReadResourceTool)) + + if settings.get("enable_prompts", False): + get_prompt_info = ToolInfo( + name=MCPGetPromptTool.name, + tool_description=MCPGetPromptTool.description, + enabled=True, + tool_parameters=MCPGetPromptTool.parameters, + component_type=ComponentType.TOOL, + ) + components.append((get_prompt_info, MCPGetPromptTool)) + + return components + + def get_status(self) -> Dict[str, Any]: + """获取插件状态""" + return { + "initialized": self._initialized, + "mcp_manager": mcp_manager.get_status(), + "registered_tools": len(mcp_tool_registry._tool_classes), + "trace_records": tool_call_tracer.total_records, + "cache_stats": tool_call_cache.get_stats(), + } + + def get_stats(self) -> Dict[str, Any]: + """获取详细统计信息""" + return mcp_manager.get_all_stats() diff --git a/plugins/MaiBot_MCPBridgePlugin/requirements.txt b/plugins/MaiBot_MCPBridgePlugin/requirements.txt new file mode 100644 index 00000000..7580f09e --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/requirements.txt @@ -0,0 +1,2 @@ +# MCP 桥接插件依赖 +mcp>=1.0.0 diff --git a/plugins/MaiBot_MCPBridgePlugin/test_mcp_client.py b/plugins/MaiBot_MCPBridgePlugin/test_mcp_client.py new file mode 100644 index 00000000..d2264314 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/test_mcp_client.py @@ -0,0 +1,270 @@ +#!/usr/bin/env python3 +""" +MCP 客户端测试脚本 +测试 mcp_client.py 的基本功能 +""" + +import asyncio +import sys +import os + +# 确保当前目录在 path 中 +sys.path.insert(0, os.path.dirname(__file__)) + +from mcp_client import ( + MCPClientManager, + MCPServerConfig, + TransportType, + ToolCallStats, + ServerStats, +) + + +async def test_stats(): + """测试统计类""" + print("\n=== 测试统计类 ===") + + # 测试 ToolCallStats + stats = ToolCallStats(tool_key="test_tool") + stats.record_call(True, 100.0) + stats.record_call(True, 200.0) + stats.record_call(False, 50.0, "timeout") + + assert stats.total_calls == 3 + assert stats.success_calls == 2 + assert stats.failed_calls == 1 + assert stats.success_rate == (2/3) * 100 + assert stats.avg_duration_ms == 150.0 + assert stats.last_error == "timeout" + + print(f"✅ ToolCallStats: {stats.to_dict()}") + + # 测试 ServerStats + server_stats = ServerStats(server_name="test_server") + server_stats.record_connect() + server_stats.record_heartbeat() + server_stats.record_disconnect() + server_stats.record_failure() + server_stats.record_failure() + + assert server_stats.connect_count == 1 + assert server_stats.disconnect_count == 1 + assert server_stats.consecutive_failures == 2 + + print(f"✅ ServerStats: {server_stats.to_dict()}") + + return True + + +async def test_manager_basic(): + """测试管理器基本功能""" + print("\n=== 测试管理器基本功能 ===") + + # 创建新的管理器实例(绕过单例) + manager = MCPClientManager.__new__(MCPClientManager) + manager._initialized = False + manager.__init__() + + # 配置 + manager.configure({ + "tool_prefix": "mcp", + "call_timeout": 30.0, + "retry_attempts": 1, + "retry_interval": 1.0, + "heartbeat_enabled": False, + }) + + # 测试状态 + status = manager.get_status() + assert status["total_servers"] == 0 + assert status["connected_servers"] == 0 + print(f"✅ 初始状态: {status}") + + # 测试添加禁用的服务器 + config = MCPServerConfig( + name="disabled_server", + enabled=False, + transport=TransportType.HTTP, + url="https://example.com/mcp" + ) + result = await manager.add_server(config) + assert result == True + assert "disabled_server" in manager._clients + assert manager._clients["disabled_server"].is_connected == False + print("✅ 添加禁用服务器成功") + + # 测试重复添加 + result = await manager.add_server(config) + assert result == False + print("✅ 重复添加被拒绝") + + # 测试移除 + result = await manager.remove_server("disabled_server") + assert result == True + assert "disabled_server" not in manager._clients + print("✅ 移除服务器成功") + + # 清理 + await manager.shutdown() + print("✅ 管理器关闭成功") + + return True + + +async def test_http_connection(): + """测试 HTTP 连接(使用真实的 MCP 服务器)""" + print("\n=== 测试 HTTP 连接 ===") + + # 创建新的管理器实例 + manager = MCPClientManager.__new__(MCPClientManager) + manager._initialized = False + manager.__init__() + + manager.configure({ + "tool_prefix": "mcp", + "call_timeout": 30.0, + "retry_attempts": 2, + "retry_interval": 2.0, + "heartbeat_enabled": False, + }) + + # 使用 HowToCook MCP 服务器测试 + config = MCPServerConfig( + name="howtocook", + enabled=True, + transport=TransportType.HTTP, + url="https://mcp.api-inference.modelscope.net/c9b55951d4ed47/mcp" + ) + + print(f"正在连接 {config.url} ...") + result = await manager.add_server(config) + + if result: + print(f"✅ 连接成功!") + + # 检查工具 + tools = manager.all_tools + print(f"✅ 发现 {len(tools)} 个工具:") + for tool_key in tools: + print(f" - {tool_key}") + + # 测试心跳 + client = manager._clients["howtocook"] + healthy = await client.check_health() + print(f"✅ 心跳检测: {'健康' if healthy else '异常'}") + + # 测试工具调用 + if "mcp_howtocook_whatToEat" in tools: + print("\n正在调用 whatToEat 工具...") + call_result = await manager.call_tool("mcp_howtocook_whatToEat", {}) + if call_result.success: + print(f"✅ 工具调用成功 (耗时: {call_result.duration_ms:.0f}ms)") + print(f" 结果: {call_result.content[:200]}..." if len(str(call_result.content)) > 200 else f" 结果: {call_result.content}") + else: + print(f"❌ 工具调用失败: {call_result.error}") + + # 查看统计 + stats = manager.get_all_stats() + print(f"\n📊 统计信息:") + print(f" 全局调用: {stats['global']['total_tool_calls']}") + print(f" 成功: {stats['global']['successful_calls']}") + print(f" 失败: {stats['global']['failed_calls']}") + + else: + print(f"❌ 连接失败") + + # 清理 + await manager.shutdown() + return result + + +async def test_heartbeat(): + """测试心跳检测功能""" + print("\n=== 测试心跳检测 ===") + + # 创建新的管理器实例 + manager = MCPClientManager.__new__(MCPClientManager) + manager._initialized = False + manager.__init__() + + manager.configure({ + "tool_prefix": "mcp", + "call_timeout": 30.0, + "retry_attempts": 1, + "retry_interval": 1.0, + "heartbeat_enabled": True, + "heartbeat_interval": 5.0, # 5秒间隔用于测试 + "auto_reconnect": True, + "max_reconnect_attempts": 2, + }) + + # 添加一个测试服务器 + config = MCPServerConfig( + name="heartbeat_test", + enabled=True, + transport=TransportType.HTTP, + url="https://mcp.api-inference.modelscope.net/c9b55951d4ed47/mcp" + ) + + print("正在连接服务器...") + result = await manager.add_server(config) + + if result: + print("✅ 服务器连接成功") + + # 启动心跳检测 + await manager.start_heartbeat() + print("✅ 心跳检测已启动") + + # 等待一个心跳周期 + print("等待心跳检测...") + await asyncio.sleep(2) + + # 检查状态 + status = manager.get_status() + print(f"✅ 心跳运行状态: {status['heartbeat_running']}") + + # 停止心跳 + await manager.stop_heartbeat() + print("✅ 心跳检测已停止") + else: + print("❌ 服务器连接失败,跳过心跳测试") + + await manager.shutdown() + return True + + +async def main(): + """运行所有测试""" + print("=" * 50) + print("MCP 客户端测试") + print("=" * 50) + + try: + # 基础测试 + await test_stats() + await test_manager_basic() + + # 网络测试 + print("\n是否进行网络连接测试? (需要网络) [y/N]: ", end="") + # 自动进行网络测试 + await test_http_connection() + + # 心跳测试 + await test_heartbeat() + + print("\n" + "=" * 50) + print("✅ 所有测试通过!") + print("=" * 50) + + except Exception as e: + print(f"\n❌ 测试失败: {e}") + import traceback + traceback.print_exc() + return False + + return True + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/test_edge.py b/test_edge.py deleted file mode 100644 index 7981bb30..00000000 --- a/test_edge.py +++ /dev/null @@ -1,30 +0,0 @@ -from src.chat.knowledge.kg_manager import KGManager - -kg = KGManager() -kg.load_from_file() - -edges = kg.graph.get_edge_list() -if edges: - e = edges[0] - print(f"Edge tuple: {e}") - print(f"Edge tuple type: {type(e)}") - - edge_data = kg.graph[e[0], e[1]] - print(f"\nEdge data type: {type(edge_data)}") - print(f"Edge data: {edge_data}") - print(f"Has 'get' method: {hasattr(edge_data, 'get')}") - print(f"Is dict: {isinstance(edge_data, dict)}") - - # 尝试不同的访问方式 - try: - print(f"\nUsing []: {edge_data['weight']}") - except Exception as e: - print(f"Using [] failed: {e}") - - try: - print(f"Using .get(): {edge_data.get('weight')}") - except Exception as e: - print(f"Using .get() failed: {e}") - - # 查看所有属性 - print(f"\nDir: {[x for x in dir(edge_data) if not x.startswith('_')]}")