From b2ac0559211c53971a62dc8b6b81ed84f53db621 Mon Sep 17 00:00:00 2001 From: CharTyr Date: Sat, 20 Dec 2025 01:56:18 +0000 Subject: [PATCH] feat: vendor MCPBridgePlugin v2.0.0 - Update built-in MCP bridge plugin to v2.0.0 (Claude mcpServers config)\n- Preserve Workflow (toolchains) + ReAct dual-track\n- Fix WebUI status display persistence and reduce workflow registration noise\n- Default plugin disabled in WebUI\n- Add CHANGELOG.md and refactor docs; remove test scripts --- plugins/MaiBot_MCPBridgePlugin/.gitignore | 30 + plugins/MaiBot_MCPBridgePlugin/CHANGELOG.md | 24 + plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md | 777 ++--- plugins/MaiBot_MCPBridgePlugin/README.md | 225 +- plugins/MaiBot_MCPBridgePlugin/_manifest.json | 15 +- .../config.example.toml | 255 +- .../config_converter.py | 436 --- .../MaiBot_MCPBridgePlugin/core/__init__.py | 2 + .../core/claude_config.py | 170 ++ plugins/MaiBot_MCPBridgePlugin/mcp_client.py | 641 ++-- plugins/MaiBot_MCPBridgePlugin/plugin.py | 2707 ++++++++++------- .../MaiBot_MCPBridgePlugin/test_mcp_client.py | 278 -- plugins/MaiBot_MCPBridgePlugin/tool_chain.py | 582 ++++ 13 files changed, 3388 insertions(+), 2754 deletions(-) create mode 100644 plugins/MaiBot_MCPBridgePlugin/.gitignore create mode 100644 plugins/MaiBot_MCPBridgePlugin/CHANGELOG.md delete mode 100644 plugins/MaiBot_MCPBridgePlugin/config_converter.py create mode 100644 plugins/MaiBot_MCPBridgePlugin/core/__init__.py create mode 100644 plugins/MaiBot_MCPBridgePlugin/core/claude_config.py delete mode 100644 plugins/MaiBot_MCPBridgePlugin/test_mcp_client.py create mode 100644 plugins/MaiBot_MCPBridgePlugin/tool_chain.py diff --git a/plugins/MaiBot_MCPBridgePlugin/.gitignore b/plugins/MaiBot_MCPBridgePlugin/.gitignore new file mode 100644 index 00000000..ebef83b0 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/.gitignore @@ -0,0 +1,30 @@ +# 运行时配置(包含用户敏感信息) +config.toml + +# 备份文件 +*.backup.* +*.bak + +# 日志 +logs/ +*.log +*.jsonl + +# Python 缓存 +__pycache__/ +*.py[cod] +*$py.class +*.so + +# 本地测试脚本(仓库不提交) +test_*.py + +# IDE +.idea/ +.vscode/ +*.swp +*.swo + +# 系统文件 +.DS_Store +Thumbs.db diff --git a/plugins/MaiBot_MCPBridgePlugin/CHANGELOG.md b/plugins/MaiBot_MCPBridgePlugin/CHANGELOG.md new file mode 100644 index 00000000..0c3feb46 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/CHANGELOG.md @@ -0,0 +1,24 @@ +# Changelog + +本文件记录 `MaiBot_MCPBridgePlugin` 的用户可感知变更。 + +## 2.0.0 + +- 配置入口统一:MCP 服务器仅使用 Claude Desktop `mcpServers` JSON(`servers.claude_config_json`) +- 兼容迁移:自动识别旧版 `servers.list` 并迁移为 `mcpServers`(需在 WebUI 保存一次固化) +- 保持功能不变:保留 Workflow(硬流程/工具链)与 ReAct(软流程)双轨制能力 +- 精简实现:移除旧的 WebUI 导入导出/快速添加服务器实现与 `tomlkit` 依赖 +- 易用性:完善 Workflow 变量替换(支持数组下标与 bracket 写法),并优化 WebUI 配置区顺序 + +## 1.9.0 + +- 双轨制架构:ReAct(软流程)+ Workflow(硬流程/工具链) + +## 1.8.0 + +- Workflow(工具链):多工具顺序执行、变量替换、自定义 Workflow 并注册为组合工具 + +## 1.7.0 + +- 断路器模式、状态刷新、工具搜索等易用性增强 + diff --git a/plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md b/plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md index b9e05238..7299fe13 100644 --- a/plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md +++ b/plugins/MaiBot_MCPBridgePlugin/DEVELOPMENT.md @@ -1,569 +1,356 @@ -# MCP 桥接插件 - 开发文档 +# 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 # 开发文档(本文件) +MaiBot_MCPBridgePlugin/ +├── plugin.py # 主插件文件,包含所有核心逻辑 +├── mcp_client.py # MCP 客户端封装 +├── tool_chain.py # 工具链(Workflow)模块 +├── core/ +│ └── claude_config.py # Claude Desktop mcpServers 解析/迁移 +├── config.toml # 运行时配置 +└── _manifest.json # 插件元数据 ``` ---- +## 核心模块 -## 核心模块详解 +### 1. MCP 客户端 (`mcp_client.py`) -### 1. mcp_client.py - MCP 客户端 - -负责与 MCP 服务器通信,可独立于 MaiBot 运行测试。 - -#### 数据类 +封装了与 MCP 服务器的通信逻辑。 ```python -class TransportType(Enum): - STDIO = "stdio" # 本地进程 - SSE = "sse" # Server-Sent Events - HTTP = "http" # HTTP - STREAMABLE_HTTP = "streamable_http" # HTTP Streamable(推荐) +from .mcp_client import mcp_manager, MCPServerConfig, TransportType -@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 +# 添加服务器 +config = MCPServerConfig( + name="my-server", + transport=TransportType.STREAMABLE_HTTP, + url="https://mcp.example.com/mcp" +) +await mcp_manager.add_server(config) -@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 = "" +# 调用工具 +result = await mcp_manager.call_tool("server_tool_name", {"param": "value"}) +if result.success: + print(result.content) ``` -#### MCPClientSession +**支持的传输类型:** +- `STDIO`: 本地进程通信 +- `SSE`: Server-Sent Events +- `HTTP`: HTTP 请求 +- `STREAMABLE_HTTP`: 流式 HTTP(推荐) -管理单个 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() -``` - -#### 工具代理 +### 2. 工具注册系统 + +MCP 工具通过动态类创建注册到 MaiBot: ```python +# 创建工具代理类 class MCPToolProxy(BaseTool): - """所有 MCP 工具的基类""" + name = "mcp_server_tool" + description = "工具描述" + parameters = [("param", ToolParamType.STRING, "参数描述", True, None)] + available_for_llm = True - # 类属性(动态子类覆盖) - 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]: - """动态创建工具类""" + async def execute(self, function_args): + result = await mcp_manager.call_tool(self._mcp_tool_key, function_args) + return {"name": self.name, "content": result.content} ``` -#### 内置工具 +### 3. 工具链模块 (`tool_chain.py`) + +实现 Workflow 硬流程,支持多工具顺序执行。 ```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), +from .tool_chain import ToolChainDefinition, ToolChainStep, tool_chain_manager + +# 定义工具链 +chain = ToolChainDefinition( + name="search_and_detail", + description="搜索并获取详情", + input_params={"query": "搜索关键词"}, + steps=[ + ToolChainStep( + tool_name="mcp_server_search", + args_template={"keyword": "${input.query}"}, + output_key="search_result" + ), + ToolChainStep( + tool_name="mcp_server_detail", + args_template={"id": "${prev}"} + ) ] +) -class MCPReadResourceTool(BaseTool): - """mcp_read_resource - 读取资源""" - name = "mcp_read_resource" - -class MCPGetPromptTool(BaseTool): - """mcp_get_prompt - 获取提示模板""" - name = "mcp_get_prompt" +# 注册并执行 +tool_chain_manager.add_chain(chain) +result = await tool_chain_manager.execute_chain("search_and_detail", {"query": "test"}) ``` -#### 命令 +**变量替换语法:** +- `${input.参数名}`: 用户输入 +- `${step.输出键}`: 指定步骤的输出 +- `${prev}`: 上一步输出 +- `${prev.字段}`: 上一步输出(JSON)的字段 +- `${step.geo.return.0.location}` / `${step.geo.return[0].location}`: 数组下标访问 +- `${step.geo['return'][0]['location']}`: bracket 写法(最通用) + +## 双轨制架构 + +### ReAct 软流程 + +将 MCP 工具注册到 MaiBot 的记忆检索 ReAct 系统,LLM 自主决策调用。 ```python -class MCPStatusCommand(BaseCommand): - """处理 /mcp 命令""" - command_pattern = r"^[//]mcp(?:\s+(?Pstatus|tools|stats|reconnect|trace|cache|perm))?(?:\s+(?P\S+))?$" +def _register_tools_to_react(self) -> int: + from src.memory_system.retrieval_tools import register_memory_retrieval_tool - # 子命令处理 - 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): ... + def make_execute_func(tool_key: str): + async def execute_func(**kwargs) -> str: + result = await mcp_manager.call_tool(tool_key, kwargs) + return result.content if result.success else f"失败: {result.error}" + return execute_func + + register_memory_retrieval_tool( + name="mcp_tool_name", + description="工具描述", + parameters=[{"name": "param", "type": "string", "required": True}], + execute_func=make_execute_func("tool_key") + ) ``` -#### 事件处理器 +### Workflow 硬流程 + +用户预定义的固定执行流程,注册为组合工具。 + +```python +def _register_tool_chains(self) -> None: + from src.plugin_system.core.component_registry import component_registry + + for chain_name, chain in tool_chain_manager.get_enabled_chains().items(): + info, tool_class = tool_chain_registry.register_chain(chain) + info.plugin_name = self.plugin_name + component_registry.register_component(info, tool_class) +``` + +## 配置系统 + +### MCP 服务器配置(Claude Desktop 规范) + +插件只接受 Claude Desktop 的 `mcpServers` JSON(见 `core/claude_config.py`)。配置入口统一为: + +- WebUI/配置文件:`[servers].claude_config_json` +- 命令:`/mcp import`(合并 `mcpServers`)与 `/mcp export`(导出当前 `mcpServers`) + +兼容迁移: +- 若检测到旧版 `servers.list`,会自动迁移为 `servers.claude_config_json`(仅迁移到内存配置,需 WebUI 保存一次固化)。 + +### WebUI 配置 Schema + +使用 `ConfigField` 定义 WebUI 配置项: + +```python +config_schema = { + "section_name": { + "field_name": ConfigField( + type=str, # 类型: str, bool, int, float + default="default_value", # 默认值 + description="字段描述", + label="显示标签", + input_type="textarea", # 输入类型: text, textarea, password + rows=5, # textarea 行数 + disabled=True, # 只读 + choices=["a", "b"], # 下拉选项 + hint="提示信息", + order=1, # 排序 + ), + }, +} +``` + +### 配置读取 + +```python +# 在组件中读取配置 +value = self.get_config("section.key", default="fallback") + +# 在插件类中读取 +value = self.config.get("section", {}).get("key", "default") +``` + +## 事件处理 + +### 启动事件 ```python class MCPStartupHandler(BaseEventHandler): - """ON_START - 连接服务器、注册工具""" event_type = EventType.ON_START + handler_name = "mcp_startup" + + async def execute(self, message): + global _plugin_instance + if _plugin_instance: + await _plugin_instance._async_connect_servers() + return (True, True, None, None, None) +``` +### 停止事件 + +```python class MCPStopHandler(BaseEventHandler): - """ON_STOP - 关闭连接""" event_type = EventType.ON_STOP + handler_name = "mcp_stop" + + async def execute(self, message): + await mcp_manager.shutdown() + return (True, True, None, None, None) ``` -#### 主插件类 +## 命令系统 ```python -@register_plugin -class MCPBridgePlugin(BasePlugin): - plugin_name = "mcp_bridge_plugin" - python_dependencies = ["mcp"] +class MCPStatusCommand(BaseCommand): + command_name = "mcp_status" + command_pattern = r"^/mcp(?:\s+(?P\S+))?(?:\s+(?P.+))?$" - 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): - # 更新工具清单显示 + async def execute(self) -> Tuple[bool, str, bool]: + action = self.matched_groups.get("action", "") + arg = self.matched_groups.get("arg", "") + + if action == "tools": + await self.send_text("工具列表...") + elif action == "reconnect": + await self._handle_reconnect(arg) + + return (True, None, True) # (成功, 消息, 拦截) ``` ---- +## 高级功能 -## 数据流 - -``` -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))?..." +from plugin import tool_call_tracer, ToolCallRecord -# 2. 在 execute() 添加分支 -if subcommand == "newcmd": - return await self._handle_newcmd(arg) +# 记录调用 +record = ToolCallRecord( + call_id="xxx", + timestamp=time.time(), + tool_name="tool", + server_name="server", + arguments={"key": "value"}, + success=True, + duration_ms=100.0 +) +tool_call_tracer.record(record) -# 3. 实现处理方法 -async def _handle_newcmd(self, arg: str = None): - # 处理逻辑 - await self.send_text("结果") - return (True, None, True) +# 查询记录 +recent = tool_call_tracer.get_recent(10) +by_tool = tool_call_tracer.get_by_tool("tool_name") ``` -### 添加新配置项 +### 调用缓存 ```python -# 1. config_schema 添加 -"settings": { - "new_option": ConfigField( - type=bool, - default=False, - description="新选项说明", - label="🆕 新选项", - order=50, - ), -} +from plugin import tool_call_cache -# 2. 在 __init__ 或相应方法中读取 -new_option = settings.get("new_option", False) +# 配置缓存 +tool_call_cache.configure( + enabled=True, + ttl=300, # 秒 + max_entries=200, + exclude_tools="mcp_*_time_*" # 排除模式 +) + +# 使用缓存 +cached = tool_call_cache.get("tool_name", {"param": "value"}) +if cached is None: + result = await call_tool(...) + tool_call_cache.set("tool_name", {"param": "value"}, result) ``` -### 添加新的全局模块 +### 权限控制 ```python -# 1. 定义数据类和管理类 -@dataclass -class NewRecord: - ... +from plugin import permission_checker -class NewManager: - def configure(self, ...): ... - def do_something(self, ...): ... +# 配置权限 +permission_checker.configure( + enabled=True, + default_mode="allow_all", # 或 "deny_all" + rules_json='[{"tool": "mcp_*_delete_*", "denied": ["qq:123:group"]}]', + quick_deny_groups="123456789", + quick_allow_users="111111111" +) -new_manager = NewManager() - -# 2. 在 MCPBridgePlugin.__init__ 中配置 -new_manager.configure(...) - -# 3. 在 MCPToolProxy.execute() 中使用 -result = new_manager.do_something(...) +# 检查权限 +allowed = permission_checker.check( + tool_name="mcp_server_delete", + chat_id="123456", + user_id="789", + is_group=True +) ``` ---- +### 断路器模式 -## 调试 +MCP 客户端内置断路器,故障服务器快速失败: + +- 连续失败 N 次后熔断 +- 熔断期间直接返回错误 +- 定期尝试恢复 + +## 扩展开发 + +### 添加新的传输类型 + +1. 在 `mcp_client.py` 中添加 `TransportType` 枚举值 +2. 实现对应的连接逻辑 +3. 更新 `_create_transport()` 方法 + +### 添加新的工具类型 + +1. 继承 `BaseTool` 创建新类 +2. 在 `get_plugin_components()` 中注册 +3. 实现 `execute()` 方法 + +### 添加新的命令 + +1. 在 `MCPStatusCommand.execute()` 中添加新的 action 分支 +2. 或创建新的 `BaseCommand` 子类 + +## 调试技巧 + +### 日志级别 ```python -# 导入 -from plugins.MCPBridgePlugin.mcp_client import mcp_manager -from plugins.MCPBridgePlugin.plugin import tool_call_tracer, tool_call_cache, permission_checker +from src.common.logger import get_logger +logger = get_logger("mcp_bridge_plugin") -# 检查状态 -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"}) +logger.debug("详细调试信息") +logger.info("一般信息") +logger.warning("警告") +logger.error("错误") ``` ---- +### 常用调试命令 -## 依赖 +```bash +/mcp # 查看状态 +/mcp tools # 查看工具列表 +/mcp trace # 查看调用记录 +/mcp cache # 查看缓存状态 +/mcp chain # 查看工具链 +``` -- MaiBot >= 0.11.6 -- Python >= 3.10 -- mcp >= 1.0.0 +## 更新日志 -## 许可证 +见 `plugins/MaiBot_MCPBridgePlugin/CHANGELOG.md` -AGPL-3.0 +## 开发约定 + +- 本仓库不提交测试脚本/临时复现文件;如需本地验证,可自行在工作区创建未跟踪文件(建议放到 `.local/` 并加入 `.gitignore`)。 diff --git a/plugins/MaiBot_MCPBridgePlugin/README.md b/plugins/MaiBot_MCPBridgePlugin/README.md index f1c676ef..61aca8f5 100644 --- a/plugins/MaiBot_MCPBridgePlugin/README.md +++ b/plugins/MaiBot_MCPBridgePlugin/README.md @@ -23,21 +23,19 @@ cp config.example.toml config.toml ### 2. 添加服务器 -编辑 `config.toml`,在 `[servers]` 的 `list` 中添加服务器: +编辑 `config.toml`,在 `[servers]` 的 `claude_config_json` 中填写 Claude Desktop 的 `mcpServers` JSON: -**免费服务器:** -```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"]} +```toml +[servers] +claude_config_json = ''' +{ + "mcpServers": { + "time": { "transport": "streamable_http", "url": "https://mcp.api-inference.modelscope.cn/server/mcp-server-time" }, + "my-server": { "transport": "streamable_http", "url": "https://mcp.xxx.com/mcp", "headers": { "Authorization": "Bearer 你的密钥" } }, + "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] } + } +} +''' ``` ### 3. 启动 @@ -67,8 +65,11 @@ cp config.example.toml config.toml | `/mcp cache` | 查看缓存状态 | | `/mcp perm` | 查看权限配置 | | `/mcp import ` | 🆕 导入 Claude Desktop 配置 | -| `/mcp export [claude]` | 🆕 导出配置 | +| `/mcp export` | 🆕 导出配置 | | `/mcp search <关键词>` | 🆕 搜索工具 | +| `/mcp chain` | 🆕 查看工具链 | +| `/mcp chain <名称>` | 🆕 查看工具链详情 | +| `/mcp chain test <名称> <参数>` | 🆕 测试执行工具链 | --- @@ -80,25 +81,18 @@ cp config.example.toml config.toml - 🔄 自动重试、心跳检测、断线重连 - 🖥️ WebUI 完整配置支持 -### v1.7.0 新增 -- ⚡ **断路器模式** - 故障服务器快速失败,避免拖慢整体响应 -- 🔄 **状态实时刷新** - WebUI 自动更新连接状态(可配置间隔) -- 🔍 **工具搜索** - `/mcp search <关键词>` 快速查找工具 - -### v1.6.0 新增 -- 📥 **配置导入** - 从 Claude Desktop 格式一键导入 -- 📤 **配置导出** - 导出为 Claude Desktop / Kiro / MaiBot 格式 - -### v1.4.0 新增 -- 🚫 **工具禁用** - WebUI 直接禁用不想用的工具 -- 🔍 **调用追踪** - 记录每次调用详情,便于调试 -- 🗄️ **调用缓存** - 相同请求自动缓存 -- 🔐 **权限控制** - 按群/用户限制工具使用 +### 双轨制架构 +- 🔄 **ReAct(软流程)**:LLM 自主决策,多轮动态调用 MCP 工具(适合探索式场景) +- 🔗 **Workflow(硬流程/工具链)**:用户预定义步骤顺序与参数传递(适合可控可复用场景) ### 高级功能 - 📦 Resources 支持(实验性) - 📝 Prompts 支持(实验性) - 🔄 结果后处理(LLM 摘要提炼) +- 🔍 调用追踪 / 🗄️ 调用缓存 / 🔐 权限控制 / 🚫 工具禁用 + +### 更新日志 +- 见 `plugins/MaiBot_MCPBridgePlugin/CHANGELOG.md` --- @@ -107,26 +101,26 @@ cp config.example.toml config.toml ### 服务器配置 ```json -[ - { - "name": "服务器名", - "enabled": true, - "transport": "streamable_http", - "url": "https://..." +{ + "mcpServers": { + "server_name": { + "transport": "streamable_http", + "url": "https://..." + } } -] +} ``` | 字段 | 说明 | |------|------| -| `name` | 服务器名称(唯一) | -| `enabled` | 是否启用 | +| `mcpServers.` | 服务器名称(唯一) | +| `enabled` | 是否启用(可选,默认 true) | | `transport` | `stdio` / `sse` / `http` / `streamable_http` | | `url` | 远程服务器地址 | | `headers` | 🆕 鉴权头(如 `{"Authorization": "Bearer xxx"}`) | | `command` / `args` | 本地服务器启动命令 | -### 权限控制(v1.4.0) +### 权限控制 **快捷配置(推荐):** ```toml @@ -178,7 +172,7 @@ cache_exclude_tools = "mcp_*_time_*" --- -## 📥 配置导入导出(v1.6.0) +## 📥 配置导入导出(Claude mcpServers) ### 从 Claude Desktop 导入 @@ -190,16 +184,13 @@ cache_exclude_tools = "mcp_*_time_*" 支持的格式: - Claude Desktop 格式(`mcpServers` 对象) -- Kiro MCP 格式 -- MaiBot 格式(数组) +- 兼容旧版:MaiBot servers 列表数组(将自动迁移为 `mcpServers`) ### 导出配置 ``` /mcp export # 导出为 Claude Desktop 格式(默认) /mcp export claude # 导出为 Claude Desktop 格式 -/mcp export kiro # 导出为 Kiro MCP 格式 -/mcp export maibot # 导出为 MaiBot 格式 ``` ### 注意事项 @@ -209,6 +200,152 @@ cache_exclude_tools = "mcp_*_time_*" --- +## 🔗 Workflow(硬流程/工具链) + +工具链允许你将多个 MCP 工具按顺序执行,后续工具可以使用前序工具的输出作为输入。 + +### 1 分钟上手(推荐 WebUI) +1. 先完成 MCP 服务器配置并 `/mcp reconnect` +2. 发送 `/mcp tools`,复制你要用的工具名 +3. 打开 WebUI → 「Workflow(硬流程/工具链)」→ 用“快速添加”表单填入: + - 名称/描述 + - 输入参数(每行 `参数名=描述`) + - 执行步骤(每行 `工具名|参数JSON|输出键`) +4. 在“确认添加”中输入 `ADD` 并保存 + +### 快速添加工具链(推荐) + +在 WebUI 的「工具链」配置区,使用表单快速添加: + +1. **名称**: 填写工具链名称(英文,如 `search_and_detail`) +2. **描述**: 填写工具链用途(供 LLM 理解何时使用) +3. **输入参数**: 每行一个,格式 `参数名=描述` + ``` + query=搜索关键词 + max_results=最大结果数 + ``` +4. **执行步骤**: 每行一个,格式 `工具名|参数JSON|输出键` + ``` + mcp_server_search|{"keyword":"${input.query}"}|search_result + mcp_server_detail|{"id":"${prev}"}| + ``` +5. **确认添加**: 输入 `ADD` 并保存 + +### JSON 配置方式 + +也可以直接在「工具链列表」中编写 JSON: + +```json +[ + { + "name": "search_and_detail", + "description": "先搜索模组,再获取详情", + "input_params": { + "query": "搜索关键词" + }, + "steps": [ + { + "tool_name": "mcp_mcmod_search_mod", + "args_template": {"keyword": "${input.query}", "limit": 1}, + "output_key": "search_result", + "description": "搜索模组" + }, + { + "tool_name": "mcp_mcmod_get_mod_detail", + "args_template": {"mod_id": "${prev}"}, + "description": "获取详情" + } + ] + } +] +``` + +### 变量替换 + +| 变量格式 | 说明 | +|---------|------| +| `${input.参数名}` | 用户输入的参数 | +| `${step.输出键}` | 某个步骤的输出(通过 `output_key` 指定) | +| `${prev}` | 上一步的输出 | +| `${prev.字段}` | 上一步输出(JSON)的某个字段 | +| `${step.geo.return.0.location}` | 数组下标访问(dot) | +| `${step.geo.return[0].location}` | 数组下标访问([]) | +| `${step.geo['return'][0]['location']}` | bracket 写法(最通用) | + +### 工具链字段说明 + +| 字段 | 说明 | +|------|------| +| `name` | 工具链名称,将生成 `chain_xxx` 工具 | +| `description` | 描述,供 LLM 理解何时使用 | +| `input_params` | 输入参数定义 `{参数名: 描述}` | +| `steps` | 执行步骤数组 | +| `steps[].tool_name` | 要调用的工具名 | +| `steps[].args_template` | 参数模板,支持变量替换 | +| `steps[].output_key` | 输出存储键名(可选) | +| `steps[].optional` | 是否可选,失败时继续执行(默认 false) | + +### 命令 + +```bash +/mcp chain # 查看所有工具链 +/mcp chain list # 列出工具链 +/mcp chain <名称> # 查看详情 +/mcp chain test <名称> {"query": "JEI"} # 测试执行 +/mcp chain reload # 重新加载配置 +``` + +--- + +## 🔄 双轨制架构 + +MCP 桥接插件支持两种工具调用模式,可根据场景选择: + +### ReAct 软流程 + +LLM 自主决策的多轮工具调用模式,适合复杂、不确定的场景。 + +**工作原理:** +1. 用户提问 → LLM 分析需要什么信息 +2. LLM 选择调用工具 → 获取结果 +3. LLM 观察结果 → 决定是否需要更多信息 +4. 重复 2-3 直到信息足够 → 生成最终回答 + +**启用方式:** +在 WebUI「ReAct (软流程)」配置区启用,MCP 工具将自动注册到 MaiBot 的记忆检索 ReAct 系统。 + +**适用场景:** +- 复杂问题需要多步推理 +- 不确定需要调用哪些工具 +- 需要根据中间结果动态调整 + +### Workflow 硬流程 + +用户预定义的工作流,固定执行顺序,适合可靠、可控的场景。 + +**工作原理:** +1. 用户定义步骤顺序和参数传递 +2. 按顺序执行每个步骤 +3. 后续步骤可使用前序步骤的输出 +4. 返回最终结果 + +**适用场景:** +- 流程固定、可预测 +- 需要可靠、可重复的执行 +- 希望精确控制工具调用顺序 + +### 对比 + +| 特性 | ReAct 软流程 | Workflow 硬流程 | +|------|-------------|----------------| +| 决策者 | LLM 自主决策 | 用户预定义 | +| 灵活性 | 高,动态调整 | 低,固定流程 | +| 可预测性 | 低 | 高 | +| 适用场景 | 复杂、探索性任务 | 固定、重复性任务 | +| 配置方式 | 启用即可 | 需要定义步骤 | + +--- + ## 📋 依赖 - MaiBot >= 0.11.6 diff --git a/plugins/MaiBot_MCPBridgePlugin/_manifest.json b/plugins/MaiBot_MCPBridgePlugin/_manifest.json index 002bc782..85225a43 100644 --- a/plugins/MaiBot_MCPBridgePlugin/_manifest.json +++ b/plugins/MaiBot_MCPBridgePlugin/_manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 1, "name": "MCP桥接插件", - "version": "1.7.0", + "version": "2.0.0", "description": "将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot,使麦麦能够调用外部 MCP 工具", "author": { "name": "CharTyr", @@ -26,7 +26,10 @@ "permissions", "import", "export", - "claude-desktop" + "claude-desktop", + "workflow", + "react", + "agent" ], "categories": [ "工具扩展", @@ -51,9 +54,13 @@ "调用链路追踪", "工具调用缓存(LRU)", "工具权限控制(群/用户级别)", - "配置导入导出(Claude Desktop / Kiro 格式)", + "配置导入导出(Claude Desktop mcpServers)", "断路器模式(故障快速失败)", - "状态实时刷新" + "状态实时刷新", + "Workflow 硬流程(顺序执行多个工具)", + "Workflow 快速添加(表单式配置)", + "ReAct 软流程(LLM 自主多轮调用)", + "双轨制架构(软流程 + 硬流程)" ] }, "id": "MaiBot Community.MCPBridgePlugin" diff --git a/plugins/MaiBot_MCPBridgePlugin/config.example.toml b/plugins/MaiBot_MCPBridgePlugin/config.example.toml index d66953db..4edac27a 100644 --- a/plugins/MaiBot_MCPBridgePlugin/config.example.toml +++ b/plugins/MaiBot_MCPBridgePlugin/config.example.toml @@ -1,4 +1,4 @@ -# MCP桥接插件 v1.7.0 - 配置文件示例 +# MCP桥接插件 - 配置文件示例 # 将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot # # 使用方法:复制此文件为 config.toml,然后根据需要修改配置 @@ -24,31 +24,123 @@ # # ============================================================ +# ============================================================ +# 🔌 MCP 服务器配置 +# ============================================================ +# +# ⚠️ 重要:配置格式(Claude Desktop 规范) +# ──────────────────────────────────────────────────────────── +# 统一使用 Claude Desktop 的 mcpServers JSON。 +# +# claude_config_json 的内容应为 JSON 对象: +# { +# "mcpServers": { +# "server_name": { ...server config... }, +# "another": { ... } +# } +# } +# +# 每个服务器支持字段: +# transport - 传输方式: "stdio" / "sse" / "http" / "streamable_http"(可选) +# url - 服务器地址(sse/http/streamable_http 模式) +# command - 启动命令(stdio 模式,如 "npx" / "uvx") +# args - 命令参数数组(stdio 模式) +# env - 环境变量对象(stdio 模式,可选) +# headers - 鉴权头(可选,如 {"Authorization": "Bearer xxx"}) +# enabled - 是否启用(可选,默认 true) +# post_process - 服务器级别后处理配置(可选) +# +# ============================================================ + +[servers] +claude_config_json = ''' +{ + "mcpServers": { + "time-mcp-server": { + "enabled": false, + "transport": "streamable_http", + "url": "https://mcp.api-inference.modelscope.cn/server/mcp-server-time" + }, + "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" + } + }, + "fetch-local": { + "enabled": false, + "command": "uvx", + "args": ["mcp-server-fetch"] + } + } +} +''' + # ============================================================ # 插件基本信息 # ============================================================ [plugin] name = "mcp_bridge_plugin" -version = "1.7.0" -config_version = "1.7.0" +version = "2.0.0" +config_version = "2.0.0" enabled = false # 默认禁用,在 WebUI 中启用 # ============================================================ -# 🆕 v1.5.4 快速入门(只读,WebUI 显示) +# Workflow(硬流程/工具链) +# ============================================================ +# +# 作用:把多个工具按顺序执行;后续步骤可引用前序输出。 +# +# ✅ 推荐配置方式:WebUI「Workflow(硬流程/工具链)」里用“快速添加”表单。 +# ✅ 也可以直接写 chains_list(JSON 数组)。 +# +# 变量替换: +# ${input.xxx} - 用户输入 +# ${step.} - 指定步骤输出(需设置 output_key) +# ${prev} - 上一步输出 +# ${prev.字段} - 上一步输出(JSON)的字段 +# ${step.geo.return.0.location} - 数组/下标访问(dot) +# ${step.geo.return[0].location} - 数组/下标访问([]) +# ${step.geo['return'][0]['location']} - bracket 写法 +# # ============================================================ -[guide] -# 🚀 快速入门 - 三步开始使用 -quick_start = "1. 从下方链接获取 MCP 服务器 2. 在「快速添加」填写信息 3. 保存后发送 /mcp reconnect" -# 🌐 获取 MCP 服务器 - 复制链接到浏览器打开,获取免费 MCP 服务器 -# 魔搭 ModelScope 国内免费推荐,复制服务器 URL 到「快速添加」即可 -mcp_sources = "https://modelscope.cn/mcp (魔搭·推荐) | https://smithery.ai | https://glama.ai | https://mcp.so" +[tool_chains] +chains_enabled = true -# 📝 配置示例 - 复制到服务器列表可直接使用(免费时间服务器) -example_config = '{"name": "time", "enabled": true, "transport": "streamable_http", "url": "https://mcp.api-inference.modelscope.cn/server/mcp-server-time"}' +chains_list = ''' +[ + { + "name": "search_and_detail", + "description": "先搜索,再根据结果获取详情", + "input_params": { "query": "搜索关键词" }, + "steps": [ + { "tool_name": "把这里替换成你的搜索工具名", "args_template": { "keyword": "${input.query}" }, "output_key": "search" }, + { "tool_name": "把这里替换成你的详情工具名", "args_template": { "id": "${prev}" } } + ] + } +] +''' # ============================================================ -# 全局设置 +# ReAct(软流程) +# ============================================================ +# +# 作用:把 MCP 工具注册到 MaiBot 的 ReAct 系统,LLM 可自主多轮调用。 +# +# 注意:ReAct 适合“探索式/不确定”场景;Workflow 适合“固定/可控”场景。 +# +# ============================================================ + +[react] +react_enabled = false +filter_mode = "whitelist" # whitelist / blacklist +tool_filter = "" # 每行一个工具名,支持通配符 * + +# ============================================================ +# 全局设置(高级设置建议保持默认) # ============================================================ [settings] # 🏷️ 工具前缀 - 用于区分 MCP 工具和原生工具 @@ -75,13 +167,6 @@ heartbeat_enabled = true # 💓 心跳间隔(秒)- 建议 30-120 秒 heartbeat_interval = 60.0 -# 🧠 智能心跳 - 根据服务器稳定性自动调整心跳间隔(v1.5.2) -# 稳定服务器逐渐增加间隔,断开的服务器缩短间隔 -heartbeat_adaptive = true - -# 📈 最大间隔倍数 - 稳定服务器心跳间隔最高可达 基准间隔 × 此值(v1.5.3) -heartbeat_max_multiplier = 3.0 - # 🔄 自动重连 - 检测到断开时自动尝试重连 auto_reconnect = true @@ -89,16 +174,7 @@ auto_reconnect = true max_reconnect_attempts = 3 # ============================================================ -# v1.7.0 状态实时刷新 -# ============================================================ -# 📊 启用状态实时刷新 - 定期更新 WebUI 状态显示 -status_refresh_enabled = true - -# 📊 状态刷新间隔(秒)- 值越小刷新越频繁,但会增加少量磁盘写入 -status_refresh_interval = 10.0 - -# ============================================================ -# v1.2.0 高级功能(实验性) +# 高级功能(实验性) # ============================================================ # 📦 启用 Resources - 允许读取 MCP 服务器提供的资源 enable_resources = false @@ -107,7 +183,7 @@ enable_resources = false enable_prompts = false # ============================================================ -# v1.3.0 结果后处理功能 +# 结果后处理功能 # ============================================================ # 当 MCP 工具返回的内容过长时,使用 LLM 对结果进行摘要提炼 @@ -117,13 +193,13 @@ post_process_enabled = false # 📏 后处理阈值(字符数)- 结果长度超过此值才触发后处理 post_process_threshold = 500 -# � 后处理输e出限制 - LLM 摘要输出的最大 token 数 +# 🔢 后处理输出限制 - LLM 摘要输出的最大 token 数 post_process_max_tokens = 500 # 🤖 后处理模型(可选)- 留空则使用 utils 模型组 post_process_model = "" -# � 后处理提示词模板- +# 🧠 后处理提示词模板 post_process_prompt = '''用户问题:{query} 工具返回内容: @@ -132,7 +208,7 @@ post_process_prompt = '''用户问题:{query} 请从上述内容中提取与用户问题最相关的关键信息,简洁准确地输出:''' # ============================================================ -# 🆕 v1.4.0 调用链路追踪 +# 调用链路追踪 # ============================================================ # 记录工具调用详情,便于调试和分析 @@ -140,14 +216,14 @@ post_process_prompt = '''用户问题:{query} trace_enabled = true # 📊 追踪记录上限 - 内存中保留的最大记录数 -trace_max_records = 100 +trace_max_records = 50 # 📝 追踪日志文件 - 是否将追踪记录写入日志文件 # 启用后记录写入 plugins/MaiBot_MCPBridgePlugin/logs/trace.jsonl trace_log_enabled = false # ============================================================ -# 🆕 v1.4.0 工具调用缓存 +# 工具调用缓存 # ============================================================ # 缓存相同参数的调用结果,减少重复请求 @@ -160,7 +236,7 @@ cache_ttl = 300 # 📦 最大缓存条目 - 超出后 LRU 淘汰 cache_max_entries = 200 -# � 缓存排除列表 - 即不缓存的工具(每行一个,支持通配符 *) +# 🚫 缓存排除列表 - 即不缓存的工具(每行一个,支持通配符 *) # 时间类、随机类工具建议排除 cache_exclude_tools = ''' mcp_*_time_* @@ -168,7 +244,7 @@ mcp_*_random_* ''' # ============================================================ -# 🆕 v1.4.0 工具管理 +# 工具管理 # ============================================================ [tools] # 📋 工具清单(只读)- 启动后自动生成 @@ -184,48 +260,7 @@ tool_list = "(启动后自动生成)" disabled_tools = "" # ============================================================ -# 🆕 v1.5.1 快速添加服务器 -# ============================================================ -# 表单式配置,无需手写 JSON -[quick_add] -# 📛 服务器名称 - 服务器唯一名称(英文,如 time-server) -server_name = "" - -# 📡 传输类型 - 远程服务器选 streamable_http/http/sse,本地选 stdio -server_type = "streamable_http" - -# 🌐 服务器 URL - 远程服务器必填(streamable_http/http/sse 类型) -server_url = "" - -# ⌨️ 启动命令 - stdio 类型必填(如 uvx、npx、python) -server_command = "" - -# 📝 命令参数 - stdio 类型使用,每行一个参数 -server_args = "" - -# 🔑 鉴权头(可选)- JSON 格式,如 {"Authorization": "Bearer xxx"} -server_headers = "" - -# ============================================================ -# 🆕 v1.6.0 配置导入导出 -# ============================================================ -# 支持从 Claude Desktop / Kiro / MaiBot 格式导入导出 -[import_export] -# 📥 导入配置 - 粘贴 Claude Desktop 或其他格式的 MCP 配置 JSON -# 粘贴配置后点击保存,2秒内自动导入。查看下方「导入结果」确认状态 -import_config = "" - -# 📋 导入结果(只读)- 显示导入操作的结果 -import_result = "" - -# 📤 导出格式 - claude: Claude Desktop 格式 | kiro: Kiro MCP 格式 | maibot: 本插件格式 -export_format = "claude" - -# 📤 导出结果(只读,可复制)- 点击保存后生成,可复制到 Claude Desktop 或其他支持 MCP 的应用 -export_result = "(点击保存后生成)" - -# ============================================================ -# 🆕 v1.4.0 权限控制 +# 权限控制 # ============================================================ [permissions] # 🔐 启用权限控制 - 按群/用户限制工具使用 @@ -267,66 +302,6 @@ quick_allow_users = "" # ''' 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"] - } -] -''' - # ============================================================ # 状态显示(只读) # ============================================================ diff --git a/plugins/MaiBot_MCPBridgePlugin/config_converter.py b/plugins/MaiBot_MCPBridgePlugin/config_converter.py deleted file mode 100644 index eb036991..00000000 --- a/plugins/MaiBot_MCPBridgePlugin/config_converter.py +++ /dev/null @@ -1,436 +0,0 @@ -""" -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/core/__init__.py b/plugins/MaiBot_MCPBridgePlugin/core/__init__.py new file mode 100644 index 00000000..8e85539f --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/core/__init__.py @@ -0,0 +1,2 @@ +"""Core helpers for MCP Bridge Plugin.""" + diff --git a/plugins/MaiBot_MCPBridgePlugin/core/claude_config.py b/plugins/MaiBot_MCPBridgePlugin/core/claude_config.py new file mode 100644 index 00000000..6ae5ff97 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/core/claude_config.py @@ -0,0 +1,170 @@ +import json +from dataclasses import dataclass, field +from typing import Any, Dict, List, Literal, Optional + + +class ClaudeConfigError(ValueError): + pass + + +Transport = Literal["stdio", "sse", "http", "streamable_http"] + + +@dataclass(frozen=True) +class ClaudeMcpServer: + name: str + transport: Transport + command: str = "" + args: List[str] = field(default_factory=list) + env: Dict[str, str] = field(default_factory=dict) + url: str = "" + headers: Dict[str, str] = field(default_factory=dict) + enabled: bool = True + + +def _normalize_transport(value: Optional[str]) -> Transport: + if not value: + return "streamable_http" + v = value.strip().lower().replace("-", "_") + if v in ("streamable_http", "streamablehttp", "streamable"): + return "streamable_http" + if v in ("http",): + return "http" + if v in ("sse",): + return "sse" + if v in ("stdio",): + return "stdio" + raise ClaudeConfigError(f"unsupported transport: {value}") + + +def _coerce_str_list(value: Any, field_name: str) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + return [str(v) for v in value] + raise ClaudeConfigError(f"{field_name} must be a list") + + +def _coerce_str_dict(value: Any, field_name: str) -> Dict[str, str]: + if value is None: + return {} + if isinstance(value, dict): + return {str(k): str(v) for k, v in value.items()} + raise ClaudeConfigError(f"{field_name} must be an object") + + +def parse_claude_mcp_config(config_json: str) -> List[ClaudeMcpServer]: + """Parse Claude Desktop style MCP config JSON. + + Supported: + - Full object: {"mcpServers": {...}} + - Direct mapping: {...} treated as mcpServers + """ + text = (config_json or "").strip() + if not text: + return [] + + try: + data = json.loads(text) + except json.JSONDecodeError as e: + raise ClaudeConfigError(f"invalid JSON: {e}") from e + + if not isinstance(data, dict): + raise ClaudeConfigError("config must be a JSON object") + + servers_obj = data.get("mcpServers", data) + if not isinstance(servers_obj, dict): + raise ClaudeConfigError("mcpServers must be an object") + + servers: List[ClaudeMcpServer] = [] + for name, raw in servers_obj.items(): + if not isinstance(name, str) or not name.strip(): + raise ClaudeConfigError("server name must be a non-empty string") + if not isinstance(raw, dict): + raise ClaudeConfigError(f"server '{name}' must be an object") + + enabled = bool(raw.get("enabled", True)) + command = str(raw.get("command", "") or "") + url = str(raw.get("url", "") or "") + args = _coerce_str_list(raw.get("args"), "args") + env = _coerce_str_dict(raw.get("env"), "env") + headers = _coerce_str_dict(raw.get("headers"), "headers") + + transport_hint = raw.get("transport", raw.get("type")) + + if command: + transport: Transport = "stdio" + elif url: + try: + transport = _normalize_transport(str(transport_hint) if transport_hint is not None else None) + except ClaudeConfigError: + transport = "streamable_http" + else: + raise ClaudeConfigError(f"server '{name}' must have either 'command' or 'url'") + + servers.append( + ClaudeMcpServer( + name=name, + transport=transport, + command=command, + args=args, + env=env, + url=url, + headers=headers, + enabled=enabled, + ) + ) + + return servers + + +def legacy_servers_list_to_claude_config(servers_list_json: str) -> str: + """Convert legacy v1.x servers list (JSON array) to Claude mcpServers JSON. + + Legacy item schema: + {"name","enabled","transport","url","headers","command","args","env"} + """ + text = (servers_list_json or "").strip() + if not text: + return "" + try: + data = json.loads(text) + except json.JSONDecodeError: + return "" + if isinstance(data, dict): + data = [data] + if not isinstance(data, list): + return "" + + mcp_servers: Dict[str, Any] = {} + for item in data: + if not isinstance(item, dict): + continue + name = str(item.get("name", "") or "").strip() + if not name: + continue + enabled = bool(item.get("enabled", True)) + transport = str(item.get("transport", "") or "").strip().lower().replace("-", "_") + + if transport == "stdio" or item.get("command"): + entry: Dict[str, Any] = { + "enabled": enabled, + "command": item.get("command", "") or "", + "args": item.get("args", []) or [], + } + if item.get("env"): + entry["env"] = item.get("env") + mcp_servers[name] = entry + continue + + entry = {"enabled": enabled, "url": item.get("url", "") or ""} + if item.get("headers"): + entry["headers"] = item.get("headers") + if transport: + entry["transport"] = transport + mcp_servers[name] = entry + + if not mcp_servers: + return "" + return json.dumps({"mcpServers": mcp_servers}, ensure_ascii=False, indent=2) + diff --git a/plugins/MaiBot_MCPBridgePlugin/mcp_client.py b/plugins/MaiBot_MCPBridgePlugin/mcp_client.py index d2eed62b..0d4eebff 100644 --- a/plugins/MaiBot_MCPBridgePlugin/mcp_client.py +++ b/plugins/MaiBot_MCPBridgePlugin/mcp_client.py @@ -34,31 +34,28 @@ 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")) + 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 (新版,推荐) + 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] @@ -68,7 +65,6 @@ class MCPToolInfo: @dataclass class MCPResourceInfo: """MCP 资源信息""" - uri: str name: str description: str @@ -79,7 +75,6 @@ class MCPResourceInfo: @dataclass class MCPPromptInfo: """MCP 提示模板信息""" - name: str description: str arguments: List[Dict[str, Any]] # [{name, description, required}] @@ -89,7 +84,6 @@ class MCPPromptInfo: @dataclass class MCPServerConfig: """MCP 服务器配置""" - name: str enabled: bool = True transport: TransportType = TransportType.STDIO @@ -105,7 +99,6 @@ class MCPServerConfig: @dataclass class MCPCallResult: """MCP 工具调用结果""" - success: bool content: Any error: Optional[str] = None @@ -115,28 +108,27 @@ class MCPCallResult: class CircuitState(Enum): """断路器状态""" - - CLOSED = "closed" # 正常状态,允许请求 - OPEN = "open" # 熔断状态,拒绝请求 + 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 # 半开状态最多允许几次试探调用 - + 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 @@ -144,18 +136,18 @@ class CircuitBreaker: 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 @@ -166,20 +158,20 @@ class CircuitBreaker: 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) @@ -187,12 +179,12 @@ class CircuitBreaker: 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) @@ -202,21 +194,21 @@ class CircuitBreaker: 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 @@ -224,7 +216,7 @@ class CircuitBreaker: self.success_count = 0 self.half_open_calls = 0 self.last_state_change = time.time() - + def get_status(self) -> Dict[str, Any]: """获取断路器状态""" return { @@ -240,7 +232,6 @@ class CircuitBreaker: @dataclass class ToolCallStats: """工具调用统计""" - tool_key: str total_calls: int = 0 success_calls: int = 0 @@ -248,21 +239,21 @@ class ToolCallStats: 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 @@ -273,7 +264,7 @@ class ToolCallStats: else: self.failed_calls += 1 self.last_error = error - + def to_dict(self) -> Dict[str, Any]: """转换为字典""" return { @@ -291,7 +282,6 @@ class ToolCallStats: @dataclass class ServerStats: """服务器统计""" - server_name: str connect_count: int = 0 # 连接次数 disconnect_count: int = 0 # 断开次数 @@ -300,26 +290,26 @@ class ServerStats: 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, @@ -335,7 +325,7 @@ class ServerStats: class MCPClientSession: """MCP 客户端会话,管理与单个 MCP 服务器的连接""" - + def __init__(self, config: MCPServerConfig, call_timeout: float = 60.0): self.config = config self.call_timeout = call_timeout @@ -348,63 +338,63 @@ class MCPClientSession: 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() @@ -414,7 +404,7 @@ class MCPClientSession: async with self._lock: if self._connected: return True - + try: success = False if self.config.transport == TransportType.STDIO: @@ -426,7 +416,7 @@ class MCPClientSession: else: logger.error(f"[{self.server_name}] 不支持的传输类型: {self.config.transport}") return False - + if success: self.stats.record_connect() # v1.7.0: 连接成功时重置断路器 @@ -434,13 +424,13 @@ class MCPClientSession: 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: @@ -450,29 +440,31 @@ class MCPClientSession: 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 + 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: @@ -482,13 +474,13 @@ class MCPClientSession: 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, @@ -497,24 +489,23 @@ class MCPClientSession: } 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 @@ -528,13 +519,13 @@ class MCPClientSession: 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, @@ -543,24 +534,23 @@ class MCPClientSession: } 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 @@ -569,56 +559,59 @@ class MCPClientSession: """获取 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, + 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) + 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, + 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() @@ -632,43 +625,44 @@ class MCPClientSession: 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) + 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: + 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, - } - ) - + 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, + 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() @@ -682,35 +676,45 @@ class MCPClientSession: 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} 未连接") - + 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 功能") - + 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) - + 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"): + if hasattr(content, 'text'): content_parts.append(content.text) - elif hasattr(content, "blob"): + 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()}") @@ -718,85 +722,117 @@ class MCPClientSession: 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 + 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 + 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) + 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} 未连接") - + 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 功能") - + 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 + 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" + role = msg.role if hasattr(msg, 'role') else "unknown" content_text = "" - if hasattr(msg, "content"): - if hasattr(msg.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 + 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 + 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) - + 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) + await asyncio.wait_for( + self._session.list_tools(), + timeout=10.0 + ) self.stats.record_heartbeat() return True except Exception as e: @@ -805,20 +841,25 @@ class MCPClientSession: 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) - + 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} 未连接" # 记录失败 @@ -826,37 +867,38 @@ class MCPClientSession: 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 + 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"): + if hasattr(content, 'text'): content_parts.append(content.text) - elif hasattr(content, "data"): + 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, + duration_ms=duration_ms ) - + except asyncio.TimeoutError: duration_ms = (time.time() - start_time) * 1000 error_msg = f"工具调用超时({self.call_timeout}秒)" @@ -865,7 +907,7 @@ class MCPClientSession: # 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) @@ -886,7 +928,7 @@ class MCPClientSession: if self._connected: self.stats.record_disconnect() await self._cleanup() - + async def _cleanup(self) -> None: """清理资源""" self._connected = False @@ -895,31 +937,31 @@ class MCPClientSession: 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: + 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: + 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: + 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: + 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 @@ -927,27 +969,27 @@ class MCPClientSession: 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 @@ -958,14 +1000,14 @@ class MCPClientManager: 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, @@ -973,15 +1015,15 @@ class MCPClientManager: "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: @@ -989,27 +1031,27 @@ class MCPClientManager: 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]: """获取已断开的服务器列表""" @@ -1021,38 +1063,36 @@ class MCPClientManager: 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})" - ) + 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 @@ -1060,12 +1100,12 @@ class MCPClientManager: 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] @@ -1075,7 +1115,7 @@ class MCPClientManager: 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 转换为安全的键名 @@ -1083,12 +1123,12 @@ class MCPClientManager: 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] @@ -1098,56 +1138,56 @@ class MCPClientManager: 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: @@ -1162,42 +1202,46 @@ class MCPClientManager: 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} 不存在") - + 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: @@ -1208,11 +1252,11 @@ class MCPClientManager: """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: @@ -1221,7 +1265,7 @@ class MCPClientManager: async def read_resource(self, uri: str, server_name: Optional[str] = None) -> MCPCallResult: """v1.2.0: 读取资源内容 - + Args: uri: 资源 URI server_name: 指定服务器名称(可选,不指定则自动查找) @@ -1229,29 +1273,36 @@ class MCPClientManager: # 如果指定了服务器 if server_name: if server_name not in self._clients: - return MCPCallResult(success=False, content=None, error=f"服务器 {server_name} 不存在") + 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}" + ) - 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: + 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: 模板参数 @@ -1260,34 +1311,42 @@ class MCPClientManager: # 如果指定了服务器 if server_name: if server_name not in self._clients: - return MCPCallResult(success=False, content=None, error=f"服务器 {server_name} 不存在") + 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}") - + + 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 @@ -1299,52 +1358,52 @@ class MCPClientManager: 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() @@ -1376,73 +1435,71 @@ class MCPClientManager: # 达到最大重连次数,降低检测频率 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, + "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() @@ -1451,7 +1508,7 @@ class MCPClientManager: 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 { diff --git a/plugins/MaiBot_MCPBridgePlugin/plugin.py b/plugins/MaiBot_MCPBridgePlugin/plugin.py index 4ad38d0f..06055c6b 100644 --- a/plugins/MaiBot_MCPBridgePlugin/plugin.py +++ b/plugins/MaiBot_MCPBridgePlugin/plugin.py @@ -1,7 +1,30 @@ """ -MCP 桥接插件 v1.7.0 +MCP 桥接插件 v2.0.0 将 MCP (Model Context Protocol) 服务器的工具桥接到 MaiBot +v2.0.0 配置与架构精简(功能保持不变): +- MCP 服务器配置统一为 Claude Desktop 的 mcpServers JSON(WebUI / config.toml 同一入口) +- 兼容迁移:检测到旧版 servers.list 时自动迁移为 mcpServers(仅迁移,避免多入口混淆) +- 移除 WebUI 导入导出/快速添加服务器的旧实现(避免 tomlkit 依赖与格式混乱) + +v1.9.0 双轨制架构: +- 软流程 (ReAct): LLM 自主决策,动态多轮调用 MCP 工具,灵活应对复杂场景 +- 硬流程 (Workflow): 用户预定义的工作流,固定执行顺序,可靠可控 +- 工具链重命名为 Workflow,更清晰地表达其"预定义流程"的本质 +- 命令更新:/mcp workflow 替代 /mcp chain + +v1.8.1 工具链易用性优化: +- 快速添加工具链:WebUI 表单式配置,无需手写 JSON +- 工具链模板:提供常用工具链配置模板参考 +- 使用指南:内置变量语法和命令说明 +- 状态显示优化:详细展示工具链步骤和参数信息 + +v1.8.0 工具链支持: +- 工具链:将多个工具按顺序执行,后续工具可使用前序工具的输出 +- 自定义工具链:在 WebUI 配置工具链,自动注册为组合工具供 LLM 调用 +- 变量替换:支持 ${input.参数}、${step.输出键}、${prev} 变量 +- 工具链命令:/mcp chain 查看、测试、管理工具链 + v1.7.0 稳定性与易用性优化: - 断路器模式:故障服务器快速失败,避免拖慢整体响应 - 状态实时刷新:WebUI 每 10 秒自动更新连接状态 @@ -9,7 +32,7 @@ v1.7.0 稳定性与易用性优化: v1.6.0 配置导入导出: - 新增 /mcp import 命令,支持从 Claude Desktop 格式导入配置 -- 新增 /mcp export 命令,导出为 Claude Desktop / Kiro / MaiBot 格式 +- 新增 /mcp export 命令,导出为 Claude Desktop (mcpServers) 格式 - 支持 stdio、sse、http、streamable_http 全部传输类型 - 自动跳过同名服务器,防止重复导入 @@ -27,10 +50,8 @@ v1.5.2 性能优化: - 稳定服务器逐渐增加间隔,减少不必要的网络请求 - 断开的服务器使用较短间隔快速重连 -v1.5.1 易用性优化: -- 新增「快速添加服务器」表单式配置,无需手写 JSON -- 支持填写名称、类型、URL、命令、参数、鉴权头 -- 保存后自动合并到服务器列表 +v1.5.1 易用性优化(v2.0.0 起已移除): +- 「快速添加服务器」表单式配置(已统一为 Claude mcpServers JSON,避免多入口混淆) v1.5.0 性能优化: - 服务器并行连接:多个服务器同时连接,大幅减少启动时间 @@ -73,6 +94,7 @@ from src.plugin_system import ( ConfigField, ToolParamType, ) +from src.plugin_system.base.config_types import section_meta from src.plugin_system.base.component_types import ToolInfo, ComponentType, EventType from src.plugin_system.base.base_events_handler import BaseEventHandler @@ -84,7 +106,17 @@ from .mcp_client import ( TransportType, mcp_manager, ) -from .config_converter import ConfigConverter +from .core.claude_config import ( + ClaudeConfigError, + legacy_servers_list_to_claude_config, + parse_claude_mcp_config, +) +from .tool_chain import ( + ToolChainDefinition, + ToolChainStep, + ChainExecutionResult, + tool_chain_manager, +) logger = get_logger("mcp_bridge_plugin") @@ -93,11 +125,9 @@ logger = get_logger("mcp_bridge_plugin") # v1.4.0: 调用链路追踪 # ============================================================================ - @dataclass class ToolCallRecord: """工具调用记录""" - call_id: str timestamp: float tool_name: str @@ -117,46 +147,46 @@ class ToolCallRecord: 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: @@ -166,7 +196,7 @@ class ToolCallTracer: 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) @@ -180,11 +210,9 @@ tool_call_tracer = ToolCallTracer() # v1.4.0: 工具调用缓存 # ============================================================================ - @dataclass class CacheEntry: """缓存条目""" - tool_name: str args_hash: str result: str @@ -195,7 +223,7 @@ class CacheEntry: class ToolCallCache: """工具调用缓存(LRU)""" - + def __init__(self, max_entries: int = 200, ttl: int = 300): self._cache: OrderedDict[str, CacheEntry] = OrderedDict() self._max_entries = max_entries @@ -203,54 +231,54 @@ class ToolCallCache: 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, @@ -258,7 +286,7 @@ class ToolCallCache: created_at=now, expires_at=now + self._ttl, ) - + # 如果已存在,更新 if key in self._cache: self._cache[key] = entry @@ -267,25 +295,25 @@ class ToolCallCache: # 检查容量 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: """必要时淘汰条目""" # 先清理过期的 @@ -293,11 +321,11 @@ class ToolCallCache: 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"] @@ -321,17 +349,16 @@ 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, @@ -343,61 +370,61 @@ class PermissionChecker: """配置权限检查器""" 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: @@ -406,41 +433,41 @@ class PermissionChecker: # 如果是 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)] @@ -454,7 +481,6 @@ permission_checker = PermissionChecker() # 工具类型转换 # ============================================================================ - def convert_json_type_to_tool_param_type(json_type: str) -> ToolParamType: """将 JSON Schema 类型转换为 MaiBot 的 ToolParamType""" type_mapping = { @@ -468,36 +494,41 @@ def convert_json_type_to_tool_param_type(json_type: str) -> ToolParamType: 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]]]]: +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: + # 为无参数的工具添加占位参数,避免某些模型报错 + parameters.append(("_placeholder", ToolParamType.STRING, "占位参数,无需填写", False, None)) return parameters - + properties = input_schema.get("properties", {}) required = input_schema.get("required", []) - + + # 如果没有任何参数,添加占位参数 + if not properties: + parameters.append(("_placeholder", ToolParamType.STRING, "占位参数,无需填写", False, None)) + return parameters + 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 @@ -505,29 +536,28 @@ def parse_mcp_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(): @@ -541,21 +571,24 @@ class MCPToolProxy(BaseTool): 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} 在当前场景下不可用"} - + 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 @@ -566,13 +599,13 @@ class MCPToolProxy(BaseTool): 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: @@ -581,7 +614,7 @@ class MCPToolProxy(BaseTool): success = False error = result.error logger.warning(f"MCP 工具 {self.name} 调用失败: {result.error}") - + # v1.3.0: 后处理 post_processed = False processed_result = content @@ -591,9 +624,9 @@ class MCPToolProxy(BaseTool): 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, @@ -613,16 +646,16 @@ class MCPToolProxy(BaseTool): 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 @@ -632,53 +665,53 @@ class MCPToolProxy(BaseTool): 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} @@ -686,13 +719,13 @@ class MCPToolProxy(BaseTool): {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: @@ -702,47 +735,37 @@ class MCPToolProxy(BaseTool): 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 [] - + servers = _plugin_instance._load_mcp_servers_config() 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]] + 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], @@ -752,56 +775,59 @@ class MCPToolProxy(BaseTool): ) 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 + 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,), @@ -813,27 +839,31 @@ def create_mcp_tool_class( "_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 + 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, @@ -842,9 +872,9 @@ class MCPToolRegistry: 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: @@ -852,11 +882,11 @@ class MCPToolRegistry: 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() @@ -874,10 +904,9 @@ _plugin_instance: Optional["MCPBridgePlugin"] = None # 内置工具 # ============================================================================ - class MCPReadResourceTool(BaseTool): """v1.2.0: MCP 资源读取工具""" - + name = "mcp_read_resource" description = "读取 MCP 服务器提供的资源内容(如文件、数据库记录等)。使用前请先用 mcp_status 查看可用资源。" parameters = [ @@ -885,28 +914,28 @@ class MCPReadResourceTool(BaseTool): ("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 = [ @@ -915,83 +944,201 @@ class MCPGetPromptTool(BaseTool): ("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) +# ============================================================================ +# v1.8.0: 工具链代理工具 +# ============================================================================ + +class ToolChainProxyBase(BaseTool): + """工具链代理基类""" + + name: str = "" + description: str = "" + parameters: List[Tuple[str, ToolParamType, str, bool, Optional[List[str]]]] = [] + available_for_llm: bool = True + + _chain_name: str = "" + + async def execute(self, function_args: Dict[str, Any]) -> Dict[str, Any]: + """执行工具链""" + # 移除内部标记 + args = {k: v for k, v in function_args.items() if k != "llm_called"} + + logger.debug(f"执行工具链 {self._chain_name},参数: {args}") + + result = await tool_chain_manager.execute_chain(self._chain_name, args) + + if result.success: + # 构建输出 + output_parts = [] + output_parts.append(result.final_output) + + # 可选:添加执行摘要 + # output_parts.append(f"\n\n---\n执行摘要:\n{result.to_summary()}") + + return {"name": self.name, "content": "\n".join(output_parts)} + else: + error_msg = f"⚠️ 工具链执行失败: {result.error}" + if result.step_results: + error_msg += f"\n\n执行详情:\n{result.to_summary()}" + return {"name": self.name, "content": error_msg} + + async def direct_execute(self, **function_args) -> Dict[str, Any]: + return await self.execute(function_args) + + +def create_chain_tool_class(chain: ToolChainDefinition) -> Type[ToolChainProxyBase]: + """根据工具链定义动态创建工具类""" + # 构建参数列表 + parameters = [] + for param_name, param_desc in chain.input_params.items(): + parameters.append((param_name, ToolParamType.STRING, param_desc, True, None)) + + # 生成类名和工具名 + class_name = f"ToolChain_{chain.name}".replace("-", "_").replace(".", "_") + tool_name = f"chain_{chain.name}".replace("-", "_").replace(".", "_") + + # 构建描述 + description = chain.description + if chain.steps: + step_names = [s.tool_name.split("_")[-1] for s in chain.steps[:3]] + description += f" (执行流程: {' → '.join(step_names)}{'...' if len(chain.steps) > 3 else ''})" + + tool_class = type( + class_name, + (ToolChainProxyBase,), + { + "name": tool_name, + "description": description, + "parameters": parameters, + "available_for_llm": True, + "_chain_name": chain.name, + } + ) + + return tool_class + + +class ToolChainRegistry: + """工具链注册表""" + + def __init__(self): + self._tool_classes: Dict[str, Type[ToolChainProxyBase]] = {} + self._tool_infos: Dict[str, ToolInfo] = {} + + def register_chain(self, chain: ToolChainDefinition) -> Tuple[ToolInfo, Type[ToolChainProxyBase]]: + """注册工具链为组合工具""" + tool_class = create_chain_tool_class(chain) + + self._tool_classes[chain.name] = 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[chain.name] = info + + return info, tool_class + + def unregister_chain(self, chain_name: str) -> bool: + """注销工具链""" + if chain_name in self._tool_classes: + del self._tool_classes[chain_name] + del self._tool_infos[chain_name] + 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() + + +# 全局工具链注册表 +tool_chain_registry = ToolChainRegistry() + + class MCPStatusTool(BaseTool): """MCP 状态查询工具""" - + name = "mcp_status" - description = ( - "查询 MCP 桥接插件的状态,包括服务器连接状态、可用工具列表、资源列表、提示模板列表、调用统计、追踪记录等信息" - ) + description = "查询 MCP 桥接插件的状态,包括服务器连接状态、可用工具列表、工具链列表、资源列表、提示模板列表、调用统计、追踪记录等信息" parameters = [ - ( - "query_type", - ToolParamType.STRING, - "查询类型", - False, - ["status", "tools", "resources", "prompts", "stats", "trace", "cache", "all"], - ), + ("query_type", ToolParamType.STRING, "查询类型", False, ["status", "tools", "chains", "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 ("chains", "all"): + result_parts.append(self._format_chains()) + 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 "未知的查询类型"} - + + 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 桥接插件状态"] @@ -1000,24 +1147,24 @@ class MCPStatusTool(BaseTool): 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(): + 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 " (已禁用)" + 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: + 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: @@ -1025,35 +1172,35 @@ class MCPStatusTool(BaseTool): 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"] + + 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 + 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(): @@ -1062,19 +1209,19 @@ class MCPStatusTool(BaseTool): 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(): @@ -1083,20 +1230,20 @@ class MCPStatusTool(BaseTool): 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 "❌" @@ -1105,9 +1252,9 @@ class MCPStatusTool(BaseTool): 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() @@ -1118,7 +1265,29 @@ class MCPStatusTool(BaseTool): lines.append(f" 命中: {stats['hits']}, 未命中: {stats['misses']}") lines.append(f" 命中率: {stats['hit_rate']}") return "\n".join(lines) - + + def _format_chains(self) -> str: + """v1.8.0: 格式化工具链列表""" + chains = tool_chain_manager.get_all_chains() + if not chains: + return "🔗 当前没有配置工具链" + + lines = ["🔗 工具链列表"] + for name, chain in chains.items(): + status = "✅" if chain.enabled else "❌" + lines.append(f"\n{status} {name}") + lines.append(f" 描述: {chain.description[:50]}...") + lines.append(f" 步骤: {len(chain.steps)} 个") + for i, step in enumerate(chain.steps[:3]): + lines.append(f" {i+1}. {step.tool_name}") + if len(chain.steps) > 3: + lines.append(f" ... 还有 {len(chain.steps) - 3} 个步骤") + if chain.input_params: + params = ", ".join(chain.input_params.keys()) + lines.append(f" 参数: {params}") + + return "\n".join(lines) + async def direct_execute(self, **function_args) -> Dict[str, Any]: return await self.execute(function_args) @@ -1127,13 +1296,12 @@ class MCPStatusTool(BaseTool): # 命令处理 # ============================================================================ - 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.+))?$" + command_pattern = r"^[//]mcp(?:\s+(?Pstatus|tools|stats|reconnect|trace|cache|perm|export|search|chain))?(?:\s+(?P.+))?$" async def execute(self) -> Tuple[bool, Optional[str], bool]: """执行命令""" @@ -1142,26 +1310,30 @@ class MCPStatusCommand(BaseCommand): 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) + + # v1.8.0: 工具链命令 + if subcommand == "chain": + return await self._handle_chain(arg) result = self._format_output(subcommand, arg) await self.send_text(result) @@ -1171,7 +1343,7 @@ class MCPStatusCommand(BaseCommand): """查找相似的服务器名称""" name_lower = name.lower() all_servers = list(mcp_manager._clients.keys()) - + # 简单的相似度匹配:包含关系或前缀匹配 similar = [] for srv in all_servers: @@ -1180,7 +1352,7 @@ class MCPStatusCommand(BaseCommand): 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]: @@ -1214,7 +1386,7 @@ class MCPStatusCommand(BaseCommand): 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(): @@ -1227,11 +1399,11 @@ class MCPStatusCommand(BaseCommand): 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)): @@ -1245,17 +1417,17 @@ class MCPStatusCommand(BaseCommand): 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 '否'}") @@ -1264,22 +1436,22 @@ class MCPStatusCommand(BaseCommand): 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) @@ -1308,52 +1480,42 @@ class MCPStatusCommand(BaseCommand): 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 "[]" + if not isinstance(servers_section, dict): + servers_section = {} - try: - servers = json.loads(servers_list_str) if servers_list_str.strip() else [] - except json.JSONDecodeError: - await self.send_text("❌ 当前服务器配置格式错误,无法导出") - return (True, None, True) + claude_json = str(servers_section.get("claude_config_json", "") or "") + if not claude_json.strip(): + legacy_list = str(servers_section.get("list", "") or "") + claude_json = legacy_servers_list_to_claude_config(legacy_list) or "" - if not servers: + if not claude_json.strip(): 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)}") + pretty = json.dumps(json.loads(claude_json), ensure_ascii=False, indent=2) + except Exception: + pretty = claude_json + lines = ["📤 导出为 Claude Desktop 格式(mcpServers):"] + if format_type and format_type.strip() and format_type.strip().lower() != "claude": + lines.append("(v2.0 已精简为仅 Claude 格式,忽略其他格式参数)") + lines.append("") + lines.append(pretty) + await self.send_text("\n".join(lines)) + return (True, None, True) async def _handle_search(self, query: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: @@ -1410,11 +1572,11 @@ class MCPStatusCommand(BaseCommand): 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}") @@ -1425,6 +1587,171 @@ class MCPStatusCommand(BaseCommand): await self.send_text("\n".join(lines)) return (True, None, True) + async def _handle_chain(self, arg: Optional[str] = None) -> Tuple[bool, Optional[str], bool]: + """v1.8.0: 处理工具链命令""" + if not arg or not arg.strip(): + # 显示工具链列表和帮助 + chains = tool_chain_manager.get_all_chains() + + lines = ["🔗 工具链管理"] + lines.append("") + + if chains: + lines.append(f"已配置 {len(chains)} 个工具链:") + for name, chain in chains.items(): + status = "✅" if chain.enabled else "❌" + steps_count = len(chain.steps) + lines.append(f" {status} {name} ({steps_count} 步)") + else: + lines.append("当前没有配置工具链") + + lines.append("") + lines.append("命令:") + lines.append(" /mcp chain list 查看所有工具链") + lines.append(" /mcp chain <名称> 查看工具链详情") + lines.append(" /mcp chain test <名称> <参数JSON> 测试执行") + lines.append(" /mcp chain reload 重新加载配置") + lines.append("") + lines.append("💡 在 WebUI「工具链」配置区编辑工具链") + + await self.send_text("\n".join(lines)) + return (True, None, True) + + parts = arg.strip().split(maxsplit=2) + sub_action = parts[0].lower() + + if sub_action == "list": + # 列出所有工具链 + chains = tool_chain_manager.get_all_chains() + if not chains: + await self.send_text("🔗 当前没有配置工具链") + return (True, None, True) + + lines = [f"🔗 工具链列表 ({len(chains)} 个)"] + for name, chain in chains.items(): + status = "✅" if chain.enabled else "❌" + lines.append(f"\n{status} {name}") + lines.append(f" {chain.description[:60]}...") + lines.append(f" 步骤: {' → '.join([s.tool_name.split('_')[-1] for s in chain.steps[:4]])}") + if chain.input_params: + lines.append(f" 参数: {', '.join(chain.input_params.keys())}") + + await self.send_text("\n".join(lines)) + return (True, None, True) + + elif sub_action == "reload": + # 重新加载工具链配置 + global _plugin_instance + if _plugin_instance: + _plugin_instance._load_tool_chains() + chains = tool_chain_manager.get_all_chains() + from src.plugin_system.core.component_registry import component_registry + registered = 0 + for name, chain in tool_chain_manager.get_enabled_chains().items(): + tool_name = f"chain_{name}".replace("-", "_").replace(".", "_") + if component_registry.get_component_info(tool_name, ComponentType.TOOL): + registered += 1 + lines = [f"✅ 已重新加载工具链配置"] + lines.append(f"📋 配置数: {len(chains)} 个") + lines.append(f"🔧 已注册: {registered} 个(可被 LLM 调用)") + if chains: + lines.append("") + lines.append("工具链列表:") + for name, chain in chains.items(): + status = "✅" if chain.enabled else "❌" + lines.append(f" {status} chain_{name}") + await self.send_text("\n".join(lines)) + else: + await self.send_text("❌ 插件未初始化") + return (True, None, True) + + elif sub_action == "test" and len(parts) >= 2: + # 测试执行工具链 + chain_name = parts[1] + args_json = parts[2] if len(parts) > 2 else "{}" + + chain = tool_chain_manager.get_chain(chain_name) + if not chain: + await self.send_text(f"❌ 工具链 '{chain_name}' 不存在") + return (True, None, True) + + try: + input_args = json.loads(args_json) + except json.JSONDecodeError: + await self.send_text("❌ 参数 JSON 格式错误") + return (True, None, True) + + await self.send_text(f"🔄 正在执行工具链 {chain_name}...") + + result = await tool_chain_manager.execute_chain(chain_name, input_args) + + lines = [] + if result.success: + lines.append(f"✅ 工具链执行成功 ({result.total_duration_ms:.0f}ms)") + lines.append("") + lines.append("执行详情:") + lines.append(result.to_summary()) + lines.append("") + lines.append("最终输出:") + output_preview = result.final_output[:500] + if len(result.final_output) > 500: + output_preview += "..." + lines.append(output_preview) + else: + lines.append(f"❌ 工具链执行失败") + lines.append(f"错误: {result.error}") + if result.step_results: + lines.append("") + lines.append("执行详情:") + lines.append(result.to_summary()) + + await self.send_text("\n".join(lines)) + return (True, None, True) + + else: + # 查看特定工具链详情 + chain_name = sub_action + chain = tool_chain_manager.get_chain(chain_name) + + if not chain: + # 尝试模糊匹配 + all_chains = tool_chain_manager.get_all_chains() + similar = [n for n in all_chains.keys() if chain_name.lower() in n.lower()] + msg = f"❌ 工具链 '{chain_name}' 不存在" + if similar: + msg += f"\n💡 你是不是想找: {', '.join(similar[:3])}" + await self.send_text(msg) + return (True, None, True) + + lines = [f"🔗 工具链: {chain.name}"] + lines.append(f"状态: {'✅ 启用' if chain.enabled else '❌ 禁用'}") + lines.append(f"描述: {chain.description}") + lines.append("") + + if chain.input_params: + lines.append("📥 输入参数:") + for param, desc in chain.input_params.items(): + lines.append(f" • {param}: {desc}") + lines.append("") + + lines.append(f"📋 执行步骤 ({len(chain.steps)} 个):") + for i, step in enumerate(chain.steps): + optional_tag = " (可选)" if step.optional else "" + lines.append(f" {i+1}. {step.tool_name}{optional_tag}") + if step.description: + lines.append(f" {step.description}") + if step.output_key: + lines.append(f" 输出键: {step.output_key}") + if step.args_template: + args_preview = json.dumps(step.args_template, ensure_ascii=False)[:60] + lines.append(f" 参数: {args_preview}...") + + lines.append("") + lines.append(f"💡 测试: /mcp chain test {chain.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() @@ -1450,9 +1777,9 @@ class MCPStatusCommand(BaseCommand): cb = info.get("circuit_breaker", {}) cb_state = cb.get("state", "closed") if cb_state == "open": - lines.append(" ⚡ 断路器熔断中") + lines.append(f" ⚡ 断路器熔断中") elif cb_state == "half_open": - lines.append(" ⚡ 断路器试探中") + lines.append(f" ⚡ 断路器试探中") if info["consecutive_failures"] > 0: lines.append(f" ⚠️ 连续失败 {info['consecutive_failures']} 次") @@ -1468,7 +1795,7 @@ class MCPStatusCommand(BaseCommand): # 如果指定了服务器名,显示全部工具;否则折叠显示 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: @@ -1508,9 +1835,14 @@ class MCPStatusCommand(BaseCommand): lines.append(" /mcp reconnect 重连断开的服务器") lines.append(" /mcp reconnect <名称> 重连指定服务器") lines.append("") - lines.append("配置导入导出:") - lines.append(" /mcp import 导入配置") - lines.append(" /mcp export [格式] 导出配置") + lines.append("服务器配置(Claude):") + lines.append(" /mcp import 合并 Claude mcpServers 配置") + lines.append(" /mcp export 导出当前 mcpServers 配置") + lines.append("") + lines.append("工具链:") + lines.append(" /mcp chain 查看工具链列表") + lines.append(" /mcp chain <名称> 查看工具链详情") + lines.append(" /mcp chain test <名称> <参数> 测试执行") lines.append("") lines.append("其他:") lines.append(" /mcp trace 查看调用追踪") @@ -1531,13 +1863,13 @@ class MCPImportCommand(BaseCommand): 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 配置导入 @@ -1546,8 +1878,7 @@ class MCPImportCommand(BaseCommand): 支持的格式: • Claude Desktop 格式 (mcpServers 对象) -• Kiro MCP 格式 -• MaiBot 格式 (数组) +• 兼容旧版:MaiBot servers 列表数组(将自动迁移为 mcpServers) 示例: /mcp import {"mcpServers":{"time":{"command":"uvx","args":["mcp-server-time"]}}} @@ -1556,77 +1887,93 @@ class MCPImportCommand(BaseCommand): 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 "[]" + raw_text = content.strip() + # 解析输入:支持 Claude mcpServers 或旧版 servers 列表数组 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)) + data = json.loads(raw_text) + except json.JSONDecodeError as e: + await self.send_text(f"❌ JSON 解析失败: {e}") 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)) + if isinstance(data, list): + migrated = legacy_servers_list_to_claude_config(raw_text) + if not migrated: + await self.send_text("❌ 旧版 servers 列表解析失败,无法迁移") + return (True, None, True) + data = json.loads(migrated) + + if not isinstance(data, dict): + await self.send_text("❌ 配置必须是 JSON 对象(包含 mcpServers)") return (True, None, True) - # 合并到现有列表 - new_servers = existing_servers + result.servers - new_list_str = json.dumps(new_servers, ensure_ascii=False, indent=2) + incoming_mapping = data.get("mcpServers", data) + if not isinstance(incoming_mapping, dict): + await self.send_text("❌ mcpServers 必须是 JSON 对象") + return (True, None, True) + + # 校验输入配置 + try: + parse_claude_mcp_config(json.dumps({"mcpServers": incoming_mapping}, ensure_ascii=False)) + except ClaudeConfigError as e: + await self.send_text(f"❌ 配置校验失败: {e}") + return (True, None, True) + + servers_section = _plugin_instance.config.get("servers", {}) + if not isinstance(servers_section, dict): + servers_section = {} + + existing_json = str(servers_section.get("claude_config_json", "") or "") + if not existing_json.strip(): + legacy_list = str(servers_section.get("list", "") or "") + existing_json = legacy_servers_list_to_claude_config(legacy_list) or "" + + existing_mapping: Dict[str, Any] = {} + if existing_json.strip(): + try: + parsed = json.loads(existing_json) + mapping = parsed.get("mcpServers", parsed) + if isinstance(mapping, dict): + existing_mapping = mapping + except Exception: + existing_mapping = {} + + added: List[str] = [] + skipped: List[str] = [] + + for name, conf in incoming_mapping.items(): + if name in existing_mapping: + skipped.append(str(name)) + continue + existing_mapping[str(name)] = conf + added.append(str(name)) - # 更新配置 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) + _plugin_instance.config["servers"]["claude_config_json"] = json.dumps( + {"mcpServers": existing_mapping}, ensure_ascii=False, indent=2 + ) - # 构建成功响应 - lines.append(f"✅ 成功导入 {len(result.servers)} 个服务器:") - for srv in result.servers: - transport = srv.get("transport", "stdio") - lines.append(f" • {srv.get('name')} ({transport})") + # 持久化到配置文件(使用插件基类的写入逻辑) + try: + config_path = Path(_plugin_instance.plugin_dir) / _plugin_instance.config_file_name + _plugin_instance._save_config_to_file(_plugin_instance.config, str(config_path)) + except Exception as e: + logger.warning(f"保存配置文件失败: {e}") - 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} 个") + lines = [] + if added: + lines.append(f"✅ 成功导入 {len(added)} 个服务器:") + for n in added[:20]: + lines.append(f" • {n}") + if len(added) > 20: + lines.append(f" ... 还有 {len(added) - 20} 个") + else: + lines.append("⚠️ 没有新服务器可导入") - 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}") + if skipped: + lines.append(f"\n⏭️ 跳过 {len(skipped)} 个已存在的服务器") lines.append("\n💡 发送 /mcp reconnect 使配置生效") @@ -1638,57 +1985,52 @@ class MCPImportCommand(BaseCommand): # 事件处理器 # ============================================================================ - 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() - + if _plugin_instance is not None: + await _plugin_instance._stop_status_refresher() + await mcp_manager.shutdown() mcp_tool_registry.clear() - + logger.info("MCP 桥接插件已关闭所有连接") return (True, True, None, None, None) @@ -1697,35 +2039,34 @@ class MCPStopHandler(BaseEventHandler): # 主插件类 # ============================================================================ - @register_plugin class MCPBridgePlugin(BasePlugin): - """MCP 桥接插件 v1.4.0 - 将 MCP 服务器的工具桥接到 MaiBot""" - + """MCP 桥接插件 v2.0.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": "🔐 权限控制", + "guide": section_meta("📖 快速入门", order=1), + "plugin": section_meta("🔘 插件开关", order=2), + "servers": section_meta("🔌 MCP Servers(Claude)", order=3), + "tool_chains": section_meta("🔗 Workflow(硬流程/工具链)", order=4), + "react": section_meta("🔄 ReAct(软流程)", collapsed=True, order=5), + "status": section_meta("📊 运行状态", order=10), + "tools": section_meta("🔧 工具管理", collapsed=True, order=20), + "permissions": section_meta("🔐 权限控制", collapsed=True, order=21), + "settings": section_meta("⚙️ 高级设置", collapsed=True, order=30), } - + config_schema: dict = { # 新手引导区(只读) "guide": { "quick_start": ConfigField( type=str, - default="1. 从下方链接获取 MCP 服务器 2. 在「快速添加」填写信息 3. 保存后发送 /mcp reconnect", + default="1. 获取 MCP 服务器 2. 在「MCP Servers(Claude)」粘贴 mcpServers 配置 3. 保存后发送 /mcp reconnect 4. (可选)在「Workflow/ ReAct」配置流程", description="三步开始使用", label="🚀 快速入门", disabled=True, @@ -1737,13 +2078,13 @@ class MCPBridgePlugin(BasePlugin): description="复制链接到浏览器打开,获取免费 MCP 服务器", label="🌐 获取 MCP 服务器", disabled=True, - hint="魔搭 ModelScope 国内免费推荐,复制服务器 URL 到「快速添加」即可", + hint="魔搭 ModelScope 国内免费推荐,将 mcpServers 配置粘贴到「MCP Servers(Claude)」即可", 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="复制到服务器列表可直接使用(免费时间服务器)", + default='{"mcpServers":{"time":{"url":"https://mcp.api-inference.modelscope.cn/server/mcp-server-time"}}}', + description="复制到 MCP Servers(Claude)可直接使用(免费时间服务器)", label="📝 配置示例", disabled=True, order=3, @@ -1753,54 +2094,10 @@ class MCPBridgePlugin(BasePlugin): "enabled": ConfigField( type=bool, default=False, - description="是否启用插件", + 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, @@ -1926,7 +2223,7 @@ class MCPBridgePlugin(BasePlugin): min=5.0, max=60.0, step=5.0, - hint="值越小刷新越频繁,但会增加少量磁盘写入", + hint="值越小刷新越频繁,但会增加少量 CPU 消耗", order=14, ), "enable_resources": ConfigField( @@ -2061,7 +2358,7 @@ class MCPBridgePlugin(BasePlugin): input_type="textarea", disabled=True, rows=12, - hint="从此处复制工具名到下方禁用列表", + hint="从此处复制工具名到下方禁用列表或工具链配置", order=1, ), "disabled_tools": ConfigField( @@ -2075,6 +2372,242 @@ class MCPBridgePlugin(BasePlugin): order=2, ), }, + # v1.8.0 工具链配置 + "tool_chains": { + "chains_enabled": ConfigField( + type=bool, + default=True, + description="🔗 启用工具链功能", + label="🔗 启用工具链", + hint="工具链可将多个工具按顺序执行,后续工具可使用前序工具的输出", + order=1, + ), + # 工具链使用指南 + "chains_guide": ConfigField( + type=str, + default="""工具链将多个 MCP 工具串联执行,后续步骤可使用前序步骤的输出 + +📌 变量语法: + ${input.参数名} - 用户输入的参数 + ${step.输出键} - 某步骤的输出(需设置 output_key) + ${prev} - 上一步的输出 + ${prev.字段} - 上一步输出(JSON)的某字段 + ${step.输出键.0.字段} / ${step.输出键[0].字段} - 访问数组下标 + ${step.输出键['return'][0]['location']} - 支持 bracket 写法 + +📌 测试命令: + /mcp chain list - 查看所有工具链 + /mcp chain 链名 {"参数":"值"} - 测试执行""", + description="工具链使用说明", + label="📖 使用指南", + input_type="textarea", + disabled=True, + rows=10, + order=2, + ), + # 快速添加工具链(表单式) + "quick_chain_name": ConfigField( + type=str, + default="", + description="工具链名称(英文,如 search_and_summarize)", + label="➕ 快速添加 - 名称", + placeholder="my_tool_chain", + hint="必填,将作为 LLM 可调用的工具名", + order=10, + ), + "quick_chain_desc": ConfigField( + type=str, + default="", + description="工具链描述(供 LLM 理解何时使用)", + label="➕ 快速添加 - 描述", + placeholder="先搜索内容,再获取详情并总结", + hint="必填,清晰描述工具链的用途", + order=11, + ), + "quick_chain_params": ConfigField( + type=str, + default="", + description="输入参数(每行一个,格式: 参数名=描述)", + label="➕ 快速添加 - 输入参数", + input_type="textarea", + rows=3, + placeholder="query=搜索关键词\nmax_results=最大结果数", + hint="定义用户需要提供的参数", + order=12, + ), + "quick_chain_steps": ConfigField( + type=str, + default="", + description="执行步骤(每行一个,格式: 工具名|参数JSON|输出键)", + label="➕ 快速添加 - 执行步骤", + input_type="textarea", + rows=5, + placeholder='mcp_server_search|{"keyword":"${input.query}"}|search_result\nmcp_server_detail|{"id":"${prev}"}|\n# 访问数组示例:\n# mcp_geo|{"q":"${input.query}"}|geo\n# mcp_next|{"location":"${step.geo.return.0.location}"}|', + hint="格式: 工具名|参数模板|输出键(输出键可选,用于后续步骤引用 ${step.xxx})", + order=13, + ), + "quick_chain_add": ConfigField( + type=str, + default="", + description="填写上方信息后,在此输入 ADD 并保存即可添加", + label="➕ 确认添加", + placeholder="输入 ADD 并保存", + hint="添加后会自动合并到下方工具链列表", + order=14, + ), + # 工具链模板 + "chains_templates": ConfigField( + type=str, + default="""📋 常用工具链模板(复制到下方列表使用): + +1️⃣ 搜索+详情模板: +{ + "name": "search_and_detail", + "description": "搜索内容并获取详情", + "input_params": {"query": "搜索关键词"}, + "steps": [ + {"tool_name": "搜索工具名", "args_template": {"keyword": "${input.query}"}, "output_key": "results"}, + {"tool_name": "详情工具名", "args_template": {"id": "${prev}"}} + ] +} + +2️⃣ 获取+处理模板: +{ + "name": "fetch_and_process", + "description": "获取数据并处理", + "input_params": {"url": "目标URL"}, + "steps": [ + {"tool_name": "获取工具名", "args_template": {"url": "${input.url}"}, "output_key": "data"}, + {"tool_name": "处理工具名", "args_template": {"content": "${step.data}"}} + ] +} + +3️⃣ 多步骤可选模板: +{ + "name": "multi_step_chain", + "description": "多步骤处理,部分可选", + "input_params": {"input": "输入内容"}, + "steps": [ + {"tool_name": "步骤1工具", "args_template": {"data": "${input.input}"}, "output_key": "step1"}, + {"tool_name": "步骤2工具", "args_template": {"data": "${prev}"}, "output_key": "step2", "optional": true}, + {"tool_name": "步骤3工具", "args_template": {"data": "${step.step1}"}} + ] +}""", + description="工具链配置模板参考", + label="📝 配置模板", + input_type="textarea", + disabled=True, + rows=15, + order=20, + ), + "chains_list": ConfigField( + type=str, + default="[]", + description="工具链配置(JSON 数组格式)", + label="📋 工具链列表", + input_type="textarea", + rows=20, + placeholder='''[ + { + "name": "search_and_detail", + "description": "先搜索再获取详情", + "input_params": {"query": "搜索关键词"}, + "steps": [ + {"tool_name": "mcp_server_search", "args_template": {"keyword": "${input.query}"}, "output_key": "search_result"}, + {"tool_name": "mcp_server_get_detail", "args_template": {"id": "${step.search_result}"}} + ] + } +]''', + hint="每个工具链包含 name、description、input_params、steps", + order=30, + ), + "chains_status": ConfigField( + type=str, + default="(启动后自动生成)", + description="当前已注册的工具链状态(只读)", + label="📊 工具链状态", + input_type="textarea", + disabled=True, + rows=8, + order=40, + ), + }, + # v1.9.0 ReAct 软流程配置 + "react": { + "react_enabled": ConfigField( + type=bool, + default=False, + description="🔄 将 MCP 工具注册到记忆检索 ReAct 系统", + label="🔄 启用 ReAct 集成", + hint="启用后,MaiBot 的 ReAct Agent 可在记忆检索时调用 MCP 工具", + order=1, + ), + "react_guide": ConfigField( + type=str, + default="""ReAct 软流程说明: + +📌 什么是 ReAct? +ReAct (Reasoning + Acting) 是 LLM 自主决策的多轮工具调用模式。 +与 Workflow 硬流程不同,ReAct 由 LLM 动态决定调用哪些工具。 + +📌 工作原理: +1. 用户提问 → LLM 分析需要什么信息 +2. LLM 选择调用工具 → 获取结果 +3. LLM 观察结果 → 决定是否需要更多信息 +4. 重复 2-3 直到信息足够 → 生成最终回答 + +📌 与 Workflow 的区别: +- ReAct (软流程): LLM 自主决策,灵活但不可预测 +- Workflow (硬流程): 用户预定义,固定流程,可靠可控 + +📌 使用场景: +- 复杂问题需要多步推理 +- 不确定需要调用哪些工具 +- 需要根据中间结果动态调整""", + description="ReAct 软流程使用说明", + label="📖 使用指南", + input_type="textarea", + disabled=True, + rows=15, + order=2, + ), + "filter_mode": ConfigField( + type=str, + default="whitelist", + description="过滤模式", + label="📋 过滤模式", + choices=["whitelist", "blacklist"], + hint="whitelist: 只注册列出的工具;blacklist: 排除列出的工具", + order=3, + ), + "tool_filter": ConfigField( + type=str, + default="", + description="工具过滤列表(每行一个,支持通配符 * 和精确匹配)", + label="🔍 工具过滤列表", + input_type="textarea", + rows=6, + placeholder="""# 精确匹配示例: +mcp_bing_web_search_bing_search +mcp_mcmod_search_mod + +# 通配符示例: +mcp_*_search_* +mcp_bing_*""", + hint="白名单模式: 只注册列出的工具;黑名单模式: 排除列出的工具。支持 # 注释", + order=4, + ), + "react_status": ConfigField( + type=str, + default="(启动后自动生成)", + description="当前已注册到 ReAct 的工具状态(只读)", + label="📊 ReAct 工具状态", + input_type="textarea", + disabled=True, + rows=6, + order=10, + ), + }, # v1.4.0 权限控制 "permissions": { "perm_enabled": ConfigField( @@ -2122,92 +2655,48 @@ class MCPBridgePlugin(BasePlugin): label="📜 高级权限规则(可选)", input_type="textarea", rows=10, - placeholder="""[ + 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, - ), - }, + # v2.0: 服务器配置统一为 Claude Desktop mcpServers 规范(JSON) "servers": { - "list": ConfigField( + "claude_config_json": ConfigField( type=str, - default="[]", - description="MCP 服务器列表(JSON 格式,高级用户可直接编辑)", - label="🔌 服务器列表(高级)", + default='{"mcpServers":{}}', + description="Claude Desktop 规范的 MCP 配置(JSON)", + label="🔌 MCP Servers(Claude 规范)", input_type="textarea", - rows=15, - hint="⚠️ JSON 数组格式。新手建议使用上方「快速添加」", + rows=18, + hint="仅支持 Claude Desktop 的 mcpServers JSON。每个服务器需包含 command(stdio) 或 url(remote)。", order=1, ), + "claude_config_guide": ConfigField( + type=str, + default="""示例: +{ + "mcpServers": { + "fetch": { "command": "uvx", "args": ["mcp-server-fetch"] }, + "time": { "url": "https://mcp.api-inference.modelscope.cn/server/mcp-server-time" } + } +} + +可选字段: +- enabled: true/false +- headers: {"Authorization":"Bearer ..."} +- env: {"KEY":"VALUE"} +- transport/type: "streamable_http" | "http" | "sse"(remote 可选,默认 streamable_http) +""", + description="配置说明(只读)", + label="📖 配置说明", + input_type="textarea", + disabled=True, + rows=12, + order=2, + ), }, "status": { "connection_status": ConfigField( @@ -2223,43 +2712,43 @@ class MCPBridgePlugin(BasePlugin): ), }, } - + @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: @@ -2267,26 +2756,24 @@ class MCPBridgePlugin(BasePlugin): value = match1.group(2) suffix = match1.group(3) # 将转义的换行符还原为实际换行符 - unescaped = ( - value.replace("\\n", "\n").replace("\\t", "\t").replace('\\"', '"').replace("\\\\", "\\") - ) + 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) @@ -2299,7 +2786,7 @@ class MCPBridgePlugin(BasePlugin): else: multiline_parts.append(next_line) j += 1 - + if found_end and len(multiline_parts) > 1: # 合并为三引号字符串 full_value = "\n".join(multiline_parts) @@ -2308,35 +2795,39 @@ class MCPBridgePlugin(BasePlugin): 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 + self._status_refresh_running = False + self._status_refresh_task: Optional[asyncio.Task] = None + self._last_persisted_display_hash: str = "" + self._last_servers_config_error: str = "" _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( @@ -2345,7 +2836,7 @@ class MCPBridgePlugin(BasePlugin): 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), @@ -2353,7 +2844,7 @@ class MCPBridgePlugin(BasePlugin): 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( @@ -2363,478 +2854,506 @@ class MCPBridgePlugin(BasePlugin): 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) + + # v2.0: 服务器配置统一由 servers.claude_config_json 提供(不再通过 WebUI 导入/快速添加写入旧 servers.list) + + # v1.8.0: 初始化工具链管理器 + tool_chain_manager.set_executor(mcp_manager) + self._load_tool_chains() - # 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 "[]" - + def _persist_runtime_displays(self) -> None: + """将 WebUI 只读展示字段写回配置文件,使 WebUI 能正确显示运行状态。""" try: - existing_servers = json.loads(servers_list_str) if servers_list_str.strip() else [] - except json.JSONDecodeError: - existing_servers = [] + config_path = Path(self.plugin_dir) / self.config_file_name - 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("检测到 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}") + payload = { + "status.connection_status": str(self.config.get("status", {}).get("connection_status", "") or ""), + "tools.tool_list": str(self.config.get("tools", {}).get("tool_list", "") or ""), + "tool_chains.chains_status": str(self.config.get("tool_chains", {}).get("chains_status", "") or ""), + "react.react_status": str(self.config.get("react", {}).get("react_status", "") or ""), + } + digest = hashlib.sha256(json.dumps(payload, ensure_ascii=False).encode("utf-8")).hexdigest() + if digest == self._last_persisted_display_hash: 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: + self._save_config_to_file(self.config, str(config_path)) + self._last_persisted_display_hash = digest + except Exception as e: + logger.debug(f"写回运行状态到配置文件失败: {e}") + + def _process_quick_add_chain(self) -> None: + """v1.8.0: 处理快速添加工具链表单""" + chains_config = self.config.get("tool_chains", {}) + + # 检查是否触发添加 + add_trigger = chains_config.get("quick_chain_add", "").strip().upper() + if add_trigger != "ADD": + return + + # 获取表单数据 + chain_name = chains_config.get("quick_chain_name", "").strip() + chain_desc = chains_config.get("quick_chain_desc", "").strip() + params_str = chains_config.get("quick_chain_params", "").strip() + steps_str = chains_config.get("quick_chain_steps", "").strip() + + # 验证必填字段 + if not chain_name: + logger.warning("快速添加工具链: 名称不能为空") + self._clear_quick_chain_fields() + return + + if not chain_desc: + logger.warning("快速添加工具链: 描述不能为空") + self._clear_quick_chain_fields() + return + + if not steps_str: + logger.warning("快速添加工具链: 步骤不能为空") + self._clear_quick_chain_fields() + return + + # 解析输入参数 + input_params = {} + if params_str: + for line in params_str.split("\n"): + line = line.strip() + if not line or "=" not in line: + continue + parts = line.split("=", 1) + param_name = parts[0].strip() + param_desc = parts[1].strip() if len(parts) > 1 else param_name + input_params[param_name] = param_desc + + # 解析步骤 + steps = [] + for line in steps_str.split("\n"): + line = line.strip() + if not line: + continue + + parts = line.split("|") + if len(parts) < 2: + logger.warning(f"快速添加工具链: 步骤格式错误: {line}") + continue + + tool_name = parts[0].strip() + args_str = parts[1].strip() if len(parts) > 1 else "{}" + output_key = parts[2].strip() if len(parts) > 2 else "" + + # 解析参数 JSON try: - headers = json.loads(server_headers_str) - if isinstance(headers, dict): - new_server["headers"] = headers + args_template = json.loads(args_str) if args_str else {} 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 "[]" - + logger.warning(f"快速添加工具链: 参数 JSON 格式错误: {args_str}") + args_template = {} + + steps.append({ + "tool_name": tool_name, + "args_template": args_template, + "output_key": output_key, + }) + + if not steps: + logger.warning("快速添加工具链: 没有有效的步骤") + self._clear_quick_chain_fields() + return + + # 构建新工具链 + new_chain = { + "name": chain_name, + "description": chain_desc, + "input_params": input_params, + "steps": steps, + "enabled": True, + } + + # 获取现有工具链列表 + chains_json = chains_config.get("chains_list", "[]") try: - servers_list = json.loads(servers_list_str) if servers_list_str.strip() else [] + chains_list = json.loads(chains_json) if chains_json.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})") - + chains_list = [] + + # 检查是否已存在同名工具链 + for existing in chains_list: + if existing.get("name") == chain_name: + logger.info(f"快速添加: 工具链 {chain_name} 已存在,将更新") + chains_list.remove(existing) + break + + # 添加新工具链 + chains_list.append(new_chain) + new_chains_json = json.dumps(chains_list, ensure_ascii=False, indent=2) + # 更新配置 - 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.config["tool_chains"]["chains_list"] = new_chains_json + + # 清空表单字段 + self._clear_quick_chain_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 - + self._save_chains_list(new_chains_json) + + logger.info(f"快速添加: 已添加工具链 {chain_name} ({len(steps)} 个步骤)") + + def _clear_quick_chain_fields(self) -> None: + """清空快速添加工具链表单字段""" + if "tool_chains" not in self.config: + self.config["tool_chains"] = {} + self.config["tool_chains"]["quick_chain_name"] = "" + self.config["tool_chains"]["quick_chain_desc"] = "" + self.config["tool_chains"]["quick_chain_params"] = "" + self.config["tool_chains"]["quick_chain_steps"] = "" + self.config["tool_chains"]["quick_chain_add"] = "" + + def _save_chains_list(self, chains_json: str) -> None: + """保存工具链列表到配置文件""" 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("服务器列表已保存到配置文件") + config_path = Path(self.plugin_dir) / self.config_file_name + self._save_config_to_file(self.config, str(config_path)) + logger.info("工具链列表已保存到配置文件") except Exception as e: - logger.warning(f"保存服务器列表失败: {e}") + logger.warning(f"保存工具链列表失败: {e}") + + def _load_tool_chains(self) -> None: + """v1.8.0: 加载工具链配置""" + # 先处理快速添加 + self._process_quick_add_chain() + + chains_config = self.config.get("tool_chains", {}) + if not isinstance(chains_config, dict): + chains_config = {} + # 兼容旧版本:部分版本可能使用 tool_chain 或其他字段名 + if not chains_config: + legacy_section = self.config.get("tool_chain") + if isinstance(legacy_section, dict): + chains_config = legacy_section + self.config["tool_chains"] = legacy_section + + # 兼容旧版本:chains_list 字段名变化 + chains_json = str(chains_config.get("chains_list", "") or "") + if not chains_json.strip(): + for legacy_key in ("list", "chains", "workflow_list", "workflows", "toolchains"): + legacy_val = chains_config.get(legacy_key) + if legacy_val is None: + continue + + if isinstance(legacy_val, str) and legacy_val.strip(): + chains_json = legacy_val + break + + if isinstance(legacy_val, list): + chains_json = json.dumps(legacy_val, ensure_ascii=False, indent=2) + break + + if isinstance(legacy_val, dict): + chains_json = json.dumps([legacy_val], ensure_ascii=False, indent=2) + break + + if chains_json.strip(): + if "tool_chains" not in self.config or not isinstance(self.config.get("tool_chains"), dict): + self.config["tool_chains"] = {} + self.config["tool_chains"]["chains_list"] = chains_json + logger.info("检测到旧版 Workflow 配置字段,已自动迁移为 tool_chains.chains_list(请在 WebUI 保存一次以固化)") + + chains_config = self.config.get("tool_chains", {}) + if not isinstance(chains_config, dict): + chains_config = {} + + if not chains_config.get("chains_enabled", True): + logger.info("工具链功能已禁用") + return + + chains_json = str(chains_config.get("chains_list", "[]") or "") + if not chains_json or not chains_json.strip(): + return + + # 清空现有工具链 + tool_chain_manager.clear() + tool_chain_registry.clear() + + # 加载新配置 + loaded, errors = tool_chain_manager.load_from_json(chains_json) + + if errors: + for err in errors: + logger.warning(f"工具链配置错误: {err}") + + if loaded > 0: + logger.info(f"已加载 {loaded} 个工具链") + # 注册工具链到组件系统 + self._register_tool_chains() + self._update_chains_status_display() + + def _register_tool_chains(self) -> None: + """v1.8.1: 将工具链注册到 MaiBot 组件系统,使 LLM 可调用""" + from src.plugin_system.core.component_registry import component_registry + + chain_count = 0 + for chain_name, chain in tool_chain_manager.get_enabled_chains().items(): + try: + expected_tool_name = f"chain_{chain.name}".replace("-", "_").replace(".", "_") + if component_registry.get_component_info(expected_tool_name, ComponentType.TOOL): + chain_count += 1 + logger.debug(f"🔗 工具链已存在,跳过重复注册: {expected_tool_name}") + continue + + info, tool_class = tool_chain_registry.register_chain(chain) + info.plugin_name = self.plugin_name + + if component_registry.register_component(info, tool_class): + chain_count += 1 + logger.info(f"🔗 注册工具链: {tool_class.name}") + else: + logger.warning(f"⚠️ 工具链注册被跳过(可能已存在): {tool_class.name}") + except Exception as e: + logger.error(f"注册工具链 {chain_name} 失败: {e}") + + if chain_count > 0: + logger.info(f"已注册 {chain_count} 个工具链到组件系统") + + def _register_tools_to_react(self) -> int: + """v1.9.0: 将 MCP 工具注册到记忆检索 ReAct 系统(软流程) + + 这样 MaiBot 的 ReAct Agent 在检索记忆时可以调用 MCP 工具, + 实现 LLM 自主决策的多轮工具调用。 + + Returns: + int: 成功注册的工具数量 + """ + try: + from src.memory_system.retrieval_tools import register_memory_retrieval_tool + except ImportError: + logger.warning("无法导入记忆检索工具注册模块,跳过 ReAct 工具注册") + return 0 + + react_config = self.config.get("react", {}) + filter_mode = react_config.get("filter_mode", "whitelist") + tool_filter = react_config.get("tool_filter", "").strip() + + # 解析过滤列表(支持 # 注释) + filter_patterns = [] + for line in tool_filter.split("\n"): + line = line.strip() + if line and not line.startswith("#"): + filter_patterns.append(line) + + registered_count = 0 + disabled_tools = self._get_disabled_tools() + registered_tools = [] # 记录已注册的工具名 + + for tool_key, (tool_info, _) in mcp_manager.all_tools.items(): + tool_name = tool_key.replace("-", "_").replace(".", "_") + + # 跳过禁用的工具 + if tool_name in disabled_tools: + continue + + # 应用过滤器 + if filter_patterns: + matched = any( + fnmatch.fnmatch(tool_name, p) or tool_name == p + for p in filter_patterns + ) + + if filter_mode == "whitelist": + # 白名单模式:只注册匹配的 + if not matched: + continue + else: + # 黑名单模式:排除匹配的 + if matched: + continue + + try: + # 转换参数格式 + parameters = self._convert_mcp_params_to_react_format(tool_info.input_schema) + + # 创建异步执行函数(使用闭包捕获 tool_key) + def make_execute_func(tk: str): + async def execute_func(**kwargs) -> str: + result = await mcp_manager.call_tool(tk, kwargs) + if result.success: + return result.content or "(无返回内容)" + else: + return f"工具调用失败: {result.error}" + return execute_func + + execute_func = make_execute_func(tool_key) + + # 注册到 ReAct 系统 + register_memory_retrieval_tool( + name=f"mcp_{tool_name}", + description=f"{tool_info.description} [MCP: {tool_info.server_name}]", + parameters=parameters, + execute_func=execute_func, + ) + + registered_count += 1 + registered_tools.append(f"mcp_{tool_name}") + logger.debug(f"🔄 注册 ReAct 工具: mcp_{tool_name}") + + except Exception as e: + logger.warning(f"注册 ReAct 工具 {tool_name} 失败: {e}") + + if registered_count > 0: + mode_str = "白名单" if filter_mode == "whitelist" else "黑名单" + logger.info(f"已注册 {registered_count} 个 MCP 工具到 ReAct 系统 (过滤模式: {mode_str})") + + # 更新状态显示 + self._update_react_status_display(registered_tools, filter_mode, filter_patterns) + + return registered_count + + def _update_react_status_display(self, registered_tools: List[str], filter_mode: str, filter_patterns: List[str]) -> None: + """更新 ReAct 工具状态显示""" + if not registered_tools: + status_text = "(未注册任何工具)" + else: + mode_str = "白名单" if filter_mode == "whitelist" else "黑名单" + lines = [f"📊 已注册 {len(registered_tools)} 个工具 (模式: {mode_str})"] + if filter_patterns: + lines.append(f"过滤规则: {len(filter_patterns)} 条") + lines.append("") + for tool in registered_tools[:20]: + lines.append(f" • {tool}") + if len(registered_tools) > 20: + lines.append(f" ... 还有 {len(registered_tools) - 20} 个") + status_text = "\n".join(lines) + + # 更新内存配置 + if "react" not in self.config: + self.config["react"] = {} + self.config["react"]["react_status"] = status_text + + def _convert_mcp_params_to_react_format(self, input_schema: Dict) -> List[Dict[str, Any]]: + """将 MCP 工具参数转换为 ReAct 工具参数格式""" + 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(): + param_type = param_info.get("type", "string") + description = param_info.get("description", f"参数 {param_name}") + is_required = param_name in required + + parameters.append({ + "name": param_name, + "type": param_type, + "description": description, + "required": is_required, + }) + + return parameters + + def _update_chains_status_display(self) -> None: + """v1.8.0: 更新工具链状态显示""" + chains = tool_chain_manager.get_all_chains() + + if not chains: + status_text = "(无工具链配置)" + else: + lines = [f"📊 已配置 {len(chains)} 个工具链:\n"] + for name, chain in chains.items(): + status = "✅" if chain.enabled else "❌" + # 显示工具链基本信息 + lines.append(f"{status} chain_{name}") + lines.append(f" 描述: {chain.description[:40]}{'...' if len(chain.description) > 40 else ''}") + + # 显示输入参数 + if chain.input_params: + params = ", ".join(chain.input_params.keys()) + lines.append(f" 参数: {params}") + + # 显示步骤 + lines.append(f" 步骤: {len(chain.steps)} 个") + for i, step in enumerate(chain.steps): + opt = " (可选)" if step.optional else "" + out = f" → {step.output_key}" if step.output_key else "" + lines.append(f" {i+1}. {step.tool_name}{out}{opt}") + lines.append("") + + status_text = "\n".join(lines) + + # 更新内存配置 + if "tool_chains" not in self.config: + self.config["tool_chains"] = {} + self.config["tool_chains"]["chains_status"] = status_text + 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 - + servers_config = self._load_mcp_servers_config() + if not servers_config: logger.warning("未配置任何 MCP 服务器") self._initialized = True + self._update_status_display() + self._update_tool_list_display() + self._update_chains_status_display() + self._start_status_refresher() + self._persist_runtime_displays() return - + auto_connect = settings.get("auto_connect", True) if not auto_connect: logger.info("auto_connect 已禁用,跳过自动连接") self._initialized = True + self._update_status_display() + self._update_tool_list_display() + self._update_chains_status_display() + self._start_status_refresher() + self._persist_runtime_displays() 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 + self._update_status_display() + self._update_tool_list_display() + self._update_chains_status_display() + self._start_status_refresher() + self._persist_runtime_displays() return - + logger.info(f"准备并行连接 {len(enabled_configs)} 个 MCP 服务器") - + # v1.5.0: 并行连接所有服务器 async def connect_single_server(config: MCPServerConfig) -> Tuple[MCPServerConfig, bool]: """连接单个服务器""" @@ -2860,12 +3379,15 @@ class MCPBridgePlugin(BasePlugin): 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) + 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 @@ -2879,72 +3401,165 @@ class MCPBridgePlugin(BasePlugin): 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, 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}") - + + chains_config = self.config.get("tool_chains", {}) + chains_enabled = bool(chains_config.get("chains_enabled", True)) if isinstance(chains_config, dict) else True + chain_count = len(tool_chain_manager.get_enabled_chains()) if chains_enabled else 0 + + # v1.9.0: 注册 MCP 工具到记忆检索 ReAct 系统(软流程) + react_count = 0 + react_config = self.config.get("react", {}) + if react_config.get("react_enabled", False): + react_count = self._register_tools_to_react() + self._initialized = True - logger.info(f"MCP 桥接插件初始化完成,已注册 {registered_count} 个工具") - + logger.info(f"MCP 桥接插件初始化完成,已注册 {registered_count} 个工具,{chain_count} 个工具链,{react_count} 个 ReAct 工具") + # 更新状态显示 self._update_status_display() self._update_tool_list_display() + self._update_chains_status_display() + self._start_status_refresher() + self._persist_runtime_displays() + + def _start_status_refresher(self) -> None: + """启动 WebUI 状态刷新任务(不写入磁盘)""" + task = getattr(self, "_status_refresh_task", None) + if task and not task.done(): + return - def _parse_servers_json(self, servers_list: str) -> List[Dict]: - """解析服务器列表 JSON 字符串""" - if not servers_list.strip(): + self._status_refresh_running = True + self._status_refresh_task = asyncio.create_task(self._status_refresh_loop()) + + async def _stop_status_refresher(self) -> None: + """停止 WebUI 状态刷新任务""" + self._status_refresh_running = False + task = getattr(self, "_status_refresh_task", None) + if task: + task.cancel() + try: + await task + except asyncio.CancelledError: + pass + self._status_refresh_task = None + + async def _status_refresh_loop(self) -> None: + """定期刷新 WebUI 展示字段(状态/工具列表/工具链状态)""" + while getattr(self, "_status_refresh_running", False): + try: + settings = self.config.get("settings", {}) + enabled = bool(settings.get("status_refresh_enabled", True)) + interval = float(settings.get("status_refresh_interval", 10.0) or 10.0) + interval = max(5.0, min(interval, 60.0)) + + if enabled and self._initialized: + self._update_status_display() + self._update_tool_list_display() + self._update_chains_status_display() + self._persist_runtime_displays() + + await asyncio.sleep(interval if enabled else 5.0) + except asyncio.CancelledError: + break + except Exception as e: + logger.debug(f"状态刷新任务异常: {e}") + await asyncio.sleep(5.0) + + def _load_mcp_servers_config(self) -> List[Dict[str, Any]]: + """v2.0: 从 Claude mcpServers JSON 加载服务器配置。 + + - 唯一主入口:config.servers.claude_config_json + - 兼容:若旧版 servers.list 存在且 claude_config_json 为空,会自动迁移并写回内存配置 + """ + servers_section = self.config.get("servers", {}) + if not isinstance(servers_section, dict): + servers_section = {} + + claude_json = str(servers_section.get("claude_config_json", "") or "") + + if not claude_json.strip(): + legacy_list = str(servers_section.get("list", "") or "") + migrated = legacy_servers_list_to_claude_config(legacy_list) + if migrated: + claude_json = migrated + if "servers" not in self.config: + self.config["servers"] = {} + self.config["servers"]["claude_config_json"] = migrated + logger.info("检测到旧版 servers.list,已自动迁移为 Claude mcpServers(请在 WebUI 保存一次以固化)") + + if not claude_json.strip(): + self._last_servers_config_error = "未配置任何 MCP 服务器(请在 WebUI 的「MCP Servers(Claude)」粘贴 mcpServers JSON)" 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 格式错误") + servers = parse_claude_mcp_config(claude_json) + except ClaudeConfigError as e: + self._last_servers_config_error = str(e) + logger.error(f"Claude mcpServers 配置解析失败: {e}") + return [] + except Exception as e: + self._last_servers_config_error = str(e) + logger.error(f"Claude mcpServers 配置解析异常: {e}") return [] + self._last_servers_config_error = "" + + # 保留未知字段(如 post_process)供旧功能使用 + raw_mapping: Dict[str, Any] = {} + try: + parsed = json.loads(claude_json) + mapping = parsed.get("mcpServers", parsed) + if isinstance(mapping, dict): + raw_mapping = mapping + except Exception: + raw_mapping = {} + + configs: List[Dict[str, Any]] = [] + for srv in servers: + raw = raw_mapping.get(srv.name, {}) + cfg: Dict[str, Any] = raw.copy() if isinstance(raw, dict) else {} + cfg.update( + { + "name": srv.name, + "enabled": srv.enabled, + "transport": srv.transport, + "command": srv.command, + "args": srv.args, + "env": srv.env, + "url": srv.url, + "headers": srv.headers, + } + ) + configs.append(cfg) + + return configs + def _parse_server_config(self, conf: Dict) -> MCPServerConfig: """解析服务器配置字典""" transport_str = conf.get("transport", "stdio").lower() - + transport_map = { "stdio": TransportType.STDIO, "sse": TransportType.SSE, @@ -2952,7 +3567,7 @@ class MCPBridgePlugin(BasePlugin): "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), @@ -2963,69 +3578,50 @@ class MCPBridgePlugin(BasePlugin): 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 = [] + cfg_err = str(getattr(self, "_last_servers_config_error", "") or "").strip() + if cfg_err: + lines.append(f"⚠️ 配置: {cfg_err}") + lines.append("") + lines.append(f"服务器: {status['connected_servers']}/{status['total_servers']} 已连接") lines.append(f"工具数: {status['total_tools']}") if settings.get("enable_resources", False): @@ -3034,13 +3630,13 @@ class MCPBridgePlugin(BasePlugin): 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") @@ -3048,54 +3644,35 @@ class MCPBridgePlugin(BasePlugin): 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, @@ -3105,9 +3682,9 @@ class MCPBridgePlugin(BasePlugin): 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, @@ -3117,7 +3694,7 @@ class MCPBridgePlugin(BasePlugin): component_type=ComponentType.TOOL, ) components.append((read_resource_info, MCPReadResourceTool)) - + if settings.get("enable_prompts", False): get_prompt_info = ToolInfo( name=MCPGetPromptTool.name, @@ -3127,9 +3704,9 @@ class MCPBridgePlugin(BasePlugin): component_type=ComponentType.TOOL, ) components.append((get_prompt_info, MCPGetPromptTool)) - + return components - + def get_status(self) -> Dict[str, Any]: """获取插件状态""" return { @@ -3139,7 +3716,7 @@ class MCPBridgePlugin(BasePlugin): "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/test_mcp_client.py b/plugins/MaiBot_MCPBridgePlugin/test_mcp_client.py deleted file mode 100644 index fc968ae3..00000000 --- a/plugins/MaiBot_MCPBridgePlugin/test_mcp_client.py +++ /dev/null @@ -1,278 +0,0 @@ -#!/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("✅ 连接成功!") - - # 检查工具 - 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("\n📊 统计信息:") - print(f" 全局调用: {stats['global']['total_tool_calls']}") - print(f" 成功: {stats['global']['successful_calls']}") - print(f" 失败: {stats['global']['failed_calls']}") - - else: - print("❌ 连接失败") - - # 清理 - 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/plugins/MaiBot_MCPBridgePlugin/tool_chain.py b/plugins/MaiBot_MCPBridgePlugin/tool_chain.py new file mode 100644 index 00000000..2be515f1 --- /dev/null +++ b/plugins/MaiBot_MCPBridgePlugin/tool_chain.py @@ -0,0 +1,582 @@ +""" +MCP Workflow 模块 v1.9.0 +支持用户自定义工作流(硬流程),将多个 MCP 工具按顺序执行 + +双轨制架构: +- 软流程 (ReAct): LLM 自主决策,动态多轮调用工具,灵活但不可预测 +- 硬流程 (Workflow): 用户预定义的工作流,固定流程,可靠可控 + +功能: +- Workflow 定义和管理 +- 顺序执行多个工具(硬流程) +- 支持变量替换(使用前序工具的输出) +- 自动注册为组合工具供 LLM 调用 +- 与 ReAct 软流程互补,用户可选择合适的执行方式 +""" + +import asyncio +import json +import re +import time +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional, Tuple, Type + +try: + from src.common.logger import get_logger + logger = get_logger("mcp_tool_chain") +except ImportError: + import logging + logger = logging.getLogger("mcp_tool_chain") + + +@dataclass +class ToolChainStep: + """工具链步骤""" + tool_name: str # 要调用的工具名(如 mcp_server_tool) + args_template: Dict[str, Any] = field(default_factory=dict) # 参数模板,支持变量替换 + output_key: str = "" # 输出存储的键名,供后续步骤引用 + description: str = "" # 步骤描述 + optional: bool = False # 是否可选(失败时继续执行) + + def to_dict(self) -> Dict[str, Any]: + return { + "tool_name": self.tool_name, + "args_template": self.args_template, + "output_key": self.output_key, + "description": self.description, + "optional": self.optional, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ToolChainStep": + return cls( + tool_name=data.get("tool_name", ""), + args_template=data.get("args_template", {}), + output_key=data.get("output_key", ""), + description=data.get("description", ""), + optional=data.get("optional", False), + ) + + +@dataclass +class ToolChainDefinition: + """工具链定义""" + name: str # 工具链名称(将作为组合工具的名称) + description: str # 工具链描述(供 LLM 理解) + steps: List[ToolChainStep] = field(default_factory=list) # 执行步骤 + input_params: Dict[str, str] = field(default_factory=dict) # 输入参数定义 {参数名: 描述} + enabled: bool = True # 是否启用 + + def to_dict(self) -> Dict[str, Any]: + return { + "name": self.name, + "description": self.description, + "steps": [step.to_dict() for step in self.steps], + "input_params": self.input_params, + "enabled": self.enabled, + } + + @classmethod + def from_dict(cls, data: Dict[str, Any]) -> "ToolChainDefinition": + steps = [ToolChainStep.from_dict(s) for s in data.get("steps", [])] + return cls( + name=data.get("name", ""), + description=data.get("description", ""), + steps=steps, + input_params=data.get("input_params", {}), + enabled=data.get("enabled", True), + ) + + +@dataclass +class ChainExecutionResult: + """工具链执行结果""" + success: bool + final_output: str # 最终输出(最后一个步骤的结果) + step_results: List[Dict[str, Any]] = field(default_factory=list) # 每个步骤的结果 + error: str = "" + total_duration_ms: float = 0.0 + + def to_summary(self) -> str: + """生成执行摘要""" + lines = [] + for i, step in enumerate(self.step_results): + status = "✅" if step.get("success") else "❌" + tool = step.get("tool_name", "unknown") + duration = step.get("duration_ms", 0) + lines.append(f"{status} 步骤{i+1}: {tool} ({duration:.0f}ms)") + if not step.get("success") and step.get("error"): + lines.append(f" 错误: {step['error'][:50]}") + return "\n".join(lines) + + +class ToolChainExecutor: + """工具链执行器""" + + # 变量替换模式: ${step.output_key} 或 ${input.param_name} 或 ${prev} + VAR_PATTERN = re.compile(r'\$\{([^}]+)\}') + + def __init__(self, mcp_manager): + self._mcp_manager = mcp_manager + + def _resolve_tool_key(self, tool_name: str) -> Optional[str]: + """解析工具名,返回有效的 tool_key + + 支持: + - 直接使用 tool_key(如 mcp_server_tool) + - 使用注册后的工具名(会自动转换 - 和 . 为 _) + """ + all_tools = self._mcp_manager.all_tools + + # 直接匹配 + if tool_name in all_tools: + return tool_name + + # 尝试转换后匹配(用户可能使用了注册后的名称) + normalized = tool_name.replace("-", "_").replace(".", "_") + if normalized in all_tools: + return normalized + + # 尝试查找包含该名称的工具 + for key in all_tools.keys(): + if key.endswith(f"_{tool_name}") or key.endswith(f"_{normalized}"): + return key + + return None + + async def execute( + self, + chain: ToolChainDefinition, + input_args: Dict[str, Any], + ) -> ChainExecutionResult: + """执行工具链 + + Args: + chain: 工具链定义 + input_args: 用户输入的参数 + + Returns: + ChainExecutionResult: 执行结果 + """ + start_time = time.time() + step_results = [] + context = { + "input": input_args or {}, # 用户输入,确保不为 None + "step": {}, # 各步骤输出,按 output_key 存储 + "prev": "", # 上一步的输出 + } + + final_output = "" + + # 验证必需的输入参数 + missing_params = [] + for param_name in chain.input_params.keys(): + if param_name not in context["input"]: + missing_params.append(param_name) + + if missing_params: + return ChainExecutionResult( + success=False, + final_output="", + error=f"缺少必需参数: {', '.join(missing_params)}", + total_duration_ms=(time.time() - start_time) * 1000, + ) + + for i, step in enumerate(chain.steps): + step_start = time.time() + step_result = { + "step_index": i, + "tool_name": step.tool_name, + "success": False, + "output": "", + "error": "", + "duration_ms": 0, + } + + try: + # 替换参数中的变量 + resolved_args = self._resolve_args(step.args_template, context) + step_result["resolved_args"] = resolved_args + + # 解析工具名 + tool_key = self._resolve_tool_key(step.tool_name) + if not tool_key: + step_result["error"] = f"工具 {step.tool_name} 不存在" + logger.warning(f"工具链步骤 {i+1}: 工具 {step.tool_name} 不存在") + + if not step.optional: + step_results.append(step_result) + return ChainExecutionResult( + success=False, + final_output="", + step_results=step_results, + error=f"步骤 {i+1}: 工具 {step.tool_name} 不存在", + total_duration_ms=(time.time() - start_time) * 1000, + ) + step_results.append(step_result) + continue + + logger.debug(f"工具链步骤 {i+1}: 调用 {tool_key},参数: {resolved_args}") + + # 调用工具 + result = await self._mcp_manager.call_tool(tool_key, resolved_args) + + step_duration = (time.time() - step_start) * 1000 + step_result["duration_ms"] = step_duration + + if result.success: + step_result["success"] = True + # 确保 content 不为 None + content = result.content if result.content is not None else "" + step_result["output"] = content + + # 更新上下文 + context["prev"] = content + if step.output_key: + context["step"][step.output_key] = content + + final_output = content + content_preview = content[:100] if content else "(空)" + logger.debug(f"工具链步骤 {i+1} 成功: {content_preview}...") + else: + step_result["error"] = result.error or "未知错误" + logger.warning(f"工具链步骤 {i+1} 失败: {result.error}") + + if not step.optional: + step_results.append(step_result) + return ChainExecutionResult( + success=False, + final_output="", + step_results=step_results, + error=f"步骤 {i+1} ({step.tool_name}) 失败: {result.error}", + total_duration_ms=(time.time() - start_time) * 1000, + ) + + except Exception as e: + step_duration = (time.time() - step_start) * 1000 + step_result["duration_ms"] = step_duration + step_result["error"] = str(e) + logger.error(f"工具链步骤 {i+1} 异常: {e}") + + if not step.optional: + step_results.append(step_result) + return ChainExecutionResult( + success=False, + final_output="", + step_results=step_results, + error=f"步骤 {i+1} ({step.tool_name}) 异常: {e}", + total_duration_ms=(time.time() - start_time) * 1000, + ) + + step_results.append(step_result) + + total_duration = (time.time() - start_time) * 1000 + + return ChainExecutionResult( + success=True, + final_output=final_output, + step_results=step_results, + total_duration_ms=total_duration, + ) + + def _resolve_args(self, args_template: Dict[str, Any], context: Dict[str, Any]) -> Dict[str, Any]: + """解析参数模板,替换变量 + + 支持的变量格式: + - ${input.param_name}: 用户输入的参数 + - ${step.output_key}: 某个步骤的输出 + - ${prev}: 上一步的输出 + - ${prev.field}: 上一步输出(JSON)的某个字段 + """ + resolved = {} + + for key, value in args_template.items(): + if isinstance(value, str): + resolved[key] = self._substitute_vars(value, context) + elif isinstance(value, dict): + resolved[key] = self._resolve_args(value, context) + elif isinstance(value, list): + resolved[key] = [ + self._substitute_vars(v, context) if isinstance(v, str) else v + for v in value + ] + else: + resolved[key] = value + + return resolved + + def _substitute_vars(self, template: str, context: Dict[str, Any]) -> str: + """替换字符串中的变量""" + def replacer(match): + var_path = match.group(1) + return self._get_var_value(var_path, context) + + return self.VAR_PATTERN.sub(replacer, template) + + def _get_var_value(self, var_path: str, context: Dict[str, Any]) -> str: + """获取变量值 + + Args: + var_path: 变量路径,如 "input.query", "step.search_result", "prev", "prev.id" + context: 上下文 + """ + parts = self._parse_var_path(var_path) + + if not parts: + return "" + + # 获取根对象 + root = parts[0] + if root not in context: + logger.warning(f"变量 {var_path} 的根 '{root}' 不存在") + return "" + + value = context[root] + + # 遍历路径 + for part in parts[1:]: + if isinstance(value, str): + parsed = self._try_parse_json(value) + if parsed is not None: + value = parsed + + if isinstance(value, dict): + value = value.get(part, "") + elif isinstance(value, list): + if part.isdigit(): + idx = int(part) + value = value[idx] if 0 <= idx < len(value) else "" + else: + value = "" + else: + value = "" + + # 确保返回字符串 + if isinstance(value, (dict, list)): + return json.dumps(value, ensure_ascii=False) + if value is None: + return "" + if value == "": + return "" + return str(value) + + def _try_parse_json(self, value: str) -> Optional[Any]: + """尝试将字符串解析为 JSON 对象,失败则返回 None。""" + if not value: + return None + try: + return json.loads(value) + except json.JSONDecodeError: + return None + + def _parse_var_path(self, var_path: str) -> List[str]: + """解析变量路径,支持点号与下标写法。 + + 支持: + - step.geo.return.0.location + - step.geo.return[0].location + - step.geo['return'][0]['location'] + """ + if not var_path: + return [] + + tokens: List[str] = [] + buf: List[str] = [] + in_bracket = False + in_quote = False + quote_char = "" + + def flush_buf() -> None: + if buf: + token = "".join(buf).strip() + if token: + tokens.append(token) + buf.clear() + + i = 0 + while i < len(var_path): + ch = var_path[i] + + if not in_bracket and ch == ".": + flush_buf() + i += 1 + continue + + if not in_bracket and ch == "[": + flush_buf() + in_bracket = True + in_quote = False + quote_char = "" + i += 1 + continue + + if in_bracket and not in_quote and ch == "]": + flush_buf() + in_bracket = False + i += 1 + continue + + if in_bracket and ch in ("'", '"'): + if not in_quote: + in_quote = True + quote_char = ch + i += 1 + continue + if quote_char == ch: + in_quote = False + quote_char = "" + i += 1 + continue + + if in_bracket and not in_quote: + if ch.isspace(): + i += 1 + continue + if ch == ",": + i += 1 + continue + + buf.append(ch) + i += 1 + + flush_buf() + + if in_bracket or in_quote: + return [p for p in var_path.split(".") if p] + + return tokens + + +class ToolChainManager: + """工具链管理器""" + + _instance: Optional["ToolChainManager"] = 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._chains: Dict[str, ToolChainDefinition] = {} + self._executor: Optional[ToolChainExecutor] = None + + def set_executor(self, mcp_manager) -> None: + """设置执行器""" + self._executor = ToolChainExecutor(mcp_manager) + + def add_chain(self, chain: ToolChainDefinition) -> bool: + """添加工具链""" + if not chain.name: + logger.error("工具链名称不能为空") + return False + + if chain.name in self._chains: + logger.warning(f"工具链 {chain.name} 已存在,将被覆盖") + + self._chains[chain.name] = chain + logger.info(f"已添加工具链: {chain.name} ({len(chain.steps)} 个步骤)") + return True + + def remove_chain(self, name: str) -> bool: + """移除工具链""" + if name in self._chains: + del self._chains[name] + logger.info(f"已移除工具链: {name}") + return True + return False + + def get_chain(self, name: str) -> Optional[ToolChainDefinition]: + """获取工具链""" + return self._chains.get(name) + + def get_all_chains(self) -> Dict[str, ToolChainDefinition]: + """获取所有工具链""" + return self._chains.copy() + + def get_enabled_chains(self) -> Dict[str, ToolChainDefinition]: + """获取所有启用的工具链""" + return {name: chain for name, chain in self._chains.items() if chain.enabled} + + async def execute_chain( + self, + chain_name: str, + input_args: Dict[str, Any], + ) -> ChainExecutionResult: + """执行工具链""" + chain = self._chains.get(chain_name) + if not chain: + return ChainExecutionResult( + success=False, + final_output="", + error=f"工具链 {chain_name} 不存在", + ) + + if not chain.enabled: + return ChainExecutionResult( + success=False, + final_output="", + error=f"工具链 {chain_name} 已禁用", + ) + + if not self._executor: + return ChainExecutionResult( + success=False, + final_output="", + error="工具链执行器未初始化", + ) + + return await self._executor.execute(chain, input_args) + + def load_from_json(self, json_str: str) -> Tuple[int, List[str]]: + """从 JSON 字符串加载工具链配置 + + Returns: + (成功加载数量, 错误列表) + """ + errors = [] + loaded = 0 + + try: + data = json.loads(json_str) if json_str.strip() else [] + except json.JSONDecodeError as e: + return 0, [f"JSON 解析失败: {e}"] + + if not isinstance(data, list): + data = [data] + + for i, item in enumerate(data): + try: + chain = ToolChainDefinition.from_dict(item) + if not chain.name: + errors.append(f"第 {i+1} 个工具链缺少名称") + continue + if not chain.steps: + errors.append(f"工具链 {chain.name} 没有步骤") + continue + + self.add_chain(chain) + loaded += 1 + except Exception as e: + errors.append(f"第 {i+1} 个工具链解析失败: {e}") + + return loaded, errors + + def export_to_json(self, pretty: bool = True) -> str: + """导出所有工具链为 JSON""" + chains_data = [chain.to_dict() for chain in self._chains.values()] + if pretty: + return json.dumps(chains_data, ensure_ascii=False, indent=2) + return json.dumps(chains_data, ensure_ascii=False) + + def clear(self) -> None: + """清空所有工具链""" + self._chains.clear() + + +# 全局工具链管理器实例 +tool_chain_manager = ToolChainManager()