From ed5315eef6bb66f92eb322c183f950090d3fc514 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Sun, 26 Apr 2026 23:49:04 +0800 Subject: [PATCH] feat: add MCP management and host-only runtime policy --- .env.example | 10 + config/__init__.py | 6 +- config/mcp.py | 23 + core/main_terminal.py | 22 + core/main_terminal_parts/tools_definition.py | 100 +++ core/main_terminal_parts/tools_execution.py | 100 ++- core/main_terminal_parts/tools_policy.py | 9 + core/tool_config.py | 4 + docs/mcp-tool-config-guide.md | 199 +++++ modules/admin_policy_manager.py | 24 + modules/mcp_client_manager.py | 824 ++++++++++++++++++ modules/mcp_server_registry.py | 315 +++++++ prompts/main_system.txt | 7 + prompts/main_system_qwenvl.txt | 7 + scripts/mcp_calculator_server.py | 156 ++++ scripts/mcp_demo_server.py | 140 +++ server/admin.py | 76 +- server/state.py | 6 + static/icons/mcp-logo.svg | 12 + static/src/admin/PolicyApp.vue | 391 ++++++++- .../personalization/PersonalizationDrawer.vue | 72 +- static/src/utils/chatDisplay.ts | 3 + static/src/utils/icons.ts | 3 + test/test_mcp_integration.py | 257 ++++++ 24 files changed, 2756 insertions(+), 10 deletions(-) create mode 100644 config/mcp.py create mode 100644 docs/mcp-tool-config-guide.md create mode 100644 modules/mcp_client_manager.py create mode 100644 modules/mcp_server_registry.py create mode 100644 scripts/mcp_calculator_server.py create mode 100644 scripts/mcp_demo_server.py create mode 100644 static/icons/mcp-logo.svg create mode 100644 test/test_mcp_integration.py diff --git a/.env.example b/.env.example index 15b3d38..8feec66 100644 --- a/.env.example +++ b/.env.example @@ -47,3 +47,13 @@ TERMINAL_SANDBOX_REQUIRE=0 # === 资源控制 ================================================================ PROJECT_MAX_STORAGE_MB=2048 MAX_ACTIVE_USER_CONTAINERS=8 + +# === MCP 工具扩展(可选) ===================================================== +# 1=启用,0=禁用 +MCP_TOOLS_ENABLED=1 +# MCP 服务配置文件路径(默认 data/mcp_servers.json) +MCP_SERVERS_FILE=./data/mcp_servers.json +# MCP 协议版本(默认 2025-06-18) +MCP_PROTOCOL_VERSION=2025-06-18 +# MCP 默认超时(秒) +MCP_DEFAULT_TIMEOUT_SECONDS=25 diff --git a/config/__init__.py b/config/__init__.py index 9a006f5..cbd9467 100644 --- a/config/__init__.py +++ b/config/__init__.py @@ -48,6 +48,7 @@ from . import auth as _auth from . import uploads as _uploads from . import sub_agent as _sub_agent from . import custom_tools as _custom_tools +from . import mcp as _mcp from .api import * from .paths import * @@ -63,9 +64,10 @@ from .auth import * from .uploads import * from .sub_agent import * from .custom_tools import * +from .mcp import * __all__ = [] -for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools): +for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp): __all__ += getattr(module, "__all__", []) -del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools +del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp diff --git a/config/mcp.py b/config/mcp.py new file mode 100644 index 0000000..e56580d --- /dev/null +++ b/config/mcp.py @@ -0,0 +1,23 @@ +"""MCP(Model Context Protocol)工具扩展配置。""" + +import os +from .paths import DATA_DIR + +# 是否启用 MCP 工具桥接 +MCP_TOOLS_ENABLED = os.environ.get("MCP_TOOLS_ENABLED", "1") not in {"0", "false", "False"} + +# MCP 服务器配置文件路径 +MCP_SERVERS_FILE = os.environ.get("MCP_SERVERS_FILE", f"{DATA_DIR}/mcp_servers.json") + +# 客户端声明的协议版本(可按需覆盖) +MCP_PROTOCOL_VERSION = os.environ.get("MCP_PROTOCOL_VERSION", "2025-06-18") + +# 工具发现/调用默认超时(秒) +MCP_DEFAULT_TIMEOUT_SECONDS = int(os.environ.get("MCP_DEFAULT_TIMEOUT_SECONDS", "25")) + +__all__ = [ + "MCP_TOOLS_ENABLED", + "MCP_SERVERS_FILE", + "MCP_PROTOCOL_VERSION", + "MCP_DEFAULT_TIMEOUT_SECONDS", +] diff --git a/core/main_terminal.py b/core/main_terminal.py index 69a8054..3da3f95 100644 --- a/core/main_terminal.py +++ b/core/main_terminal.py @@ -14,6 +14,7 @@ try: TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, + MCP_TOOLS_ENABLED, ) except ImportError: import sys @@ -30,6 +31,7 @@ except ImportError: TERMINAL_SANDBOX_MEMORY, PROJECT_MAX_STORAGE_MB, CUSTOM_TOOLS_ENABLED, + MCP_TOOLS_ENABLED, ) from modules.file_manager import FileManager @@ -44,6 +46,8 @@ from modules.ocr_client import OCRClient from modules.easter_egg_manager import EasterEggManager from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category +from modules.mcp_client_manager import MCPClientManager try: from config.limits import THINKING_FAST_INTERVAL @@ -154,6 +158,22 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi default_enabled=True, silent_when_disabled=False, ) + self.mcp_tools_enabled = bool(MCP_TOOLS_ENABLED) + self.mcp_server_registry = MCPServerRegistry() + self.mcp_client_manager = MCPClientManager( + self.mcp_server_registry, + container_session=container_session, + ) + self.mcp_tool_alias_map: Dict[str, object] = {} + if self.mcp_tools_enabled: + default_mcp_cat = build_default_mcp_category() + if "mcp" not in self.tool_categories_map: + self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( + label=default_mcp_cat["label"], + tools=default_mcp_cat["tools"], + default_enabled=True, + silent_when_disabled=False, + ) self.admin_forced_category_states: Dict[str, Optional[bool]] = {} self.admin_disabled_models: List[str] = [] self.admin_policy_ui_blocks: Dict[str, bool] = {} @@ -261,6 +281,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi self.terminal_ops.set_container_session(session) if getattr(self, "file_manager", None): self.file_manager.set_container_session(session) + if getattr(self, "mcp_client_manager", None): + self.mcp_client_manager.set_container_session(session) if getattr(self, "sub_agent_manager", None): try: self.sub_agent_manager.set_container_session(session) diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 6106a24..0f0c4ce 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -63,6 +63,7 @@ from modules.skills_manager import ( ) from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import build_default_mcp_category try: from config.limits import THINKING_FAST_INTERVAL @@ -87,6 +88,14 @@ logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True class MainTerminalToolsDefinitionMixin: + @staticmethod + def _mcp_disabled_message() -> str: + return "当前为docker模式,MCP仅支持宿主机模式" + + def _is_mcp_disabled_in_docker_mode(self) -> bool: + session = getattr(self, "container_session", None) + return bool(session and getattr(session, "mode", None) == "docker") + def _inject_intent(self, properties: Dict[str, Any]) -> Dict[str, Any]: """在工具参数中注入 intent(简短意图说明),仅当开关启用时。 @@ -184,6 +193,68 @@ class MainTerminalToolsDefinitionMixin: return tools + def _build_mcp_tools(self) -> List[Dict]: + if not getattr(self, "mcp_tools_enabled", False): + if hasattr(self, "tool_categories_map") and "mcp" in self.tool_categories_map: + self.tool_categories_map["mcp"].tools = [] + self.mcp_tool_alias_map = {} + return [] + if self._is_mcp_disabled_in_docker_mode(): + self.mcp_tool_alias_map = {} + mcp_category_tools = ["list_mcp_servers"] + if "mcp" in self.tool_categories_map: + self.tool_categories_map["mcp"].tools = mcp_category_tools + elif getattr(self, "mcp_tools_enabled", False): + default_mcp_cat = build_default_mcp_category() + self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( + label=default_mcp_cat["label"], + tools=mcp_category_tools, + default_enabled=True, + silent_when_disabled=False, + ) + return [] + # 类别层面的禁用优先生效(即使策略里尚未填充具体工具列表) + mcp_cat = getattr(self, "tool_categories_map", {}).get("mcp") + if mcp_cat is not None: + mcp_enabled = self.tool_category_states.get("mcp", getattr(mcp_cat, "default_enabled", True)) + forced = getattr(self, "admin_forced_category_states", {}).get("mcp") + if isinstance(forced, bool): + mcp_enabled = forced + if not mcp_enabled: + self.mcp_tool_alias_map = {} + self.tool_categories_map["mcp"].tools = [] + return [] + manager = getattr(self, "mcp_client_manager", None) + if manager is None: + self.mcp_tool_alias_map = {} + return [] + try: + tool_defs, alias_map = manager.build_openai_tools( + ensure_discovery=True, + discovery_timeout_seconds=10, + ) + except Exception as exc: + logger.warning("构建 MCP 工具列表失败: %s", exc) + tool_defs, alias_map = [], {} + + # 保存映射,供执行层按 alias 分发到具体 server/tool + self.mcp_tool_alias_map = alias_map or {} + mcp_category_tools = ["list_mcp_servers", *list((alias_map or {}).keys())] + + # 同步 mcp 分类列表,避免策略页显示旧数据 + if "mcp" in self.tool_categories_map: + self.tool_categories_map["mcp"].tools = mcp_category_tools + elif getattr(self, "mcp_tools_enabled", False): + default_mcp_cat = build_default_mcp_category() + self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( + label=default_mcp_cat["label"], + tools=mcp_category_tools, + default_enabled=True, + silent_when_disabled=False, + ) + + return tool_defs + def define_tools(self) -> List[Dict]: """定义可用工具(添加确认工具)""" now = datetime.now() @@ -592,6 +663,31 @@ class MainTerminalToolsDefinitionMixin: } } }, + { + "type": "function", + "function": { + "name": "list_mcp_servers", + "description": "列出当前已配置的 MCP 服务与工具映射(mcp__... 别名)。可选刷新远程 tools/list 缓存后再返回。", + "parameters": { + "type": "object", + "properties": self._inject_intent({ + "refresh": { + "type": "boolean", + "description": "是否先同步 MCP 服务工具缓存(默认 false)" + }, + "server_id": { + "type": "string", + "description": "仅查看指定 MCP 服务(可选)" + }, + "include_disabled": { + "type": "boolean", + "description": "是否包含已禁用服务(默认 false)" + } + }), + "required": [] + } + } + }, { "type": "function", "function": { @@ -879,6 +975,10 @@ class MainTerminalToolsDefinitionMixin: custom_tools = self._build_custom_tools() if custom_tools: tools.extend(custom_tools) + # 附加 MCP 工具(由管理员统一配置) + mcp_tools = self._build_mcp_tools() + if mcp_tools: + tools.extend(mcp_tools) if self.disabled_tools: tools = [ tool for tool in tools diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 15762f4..b410f80 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -97,6 +97,14 @@ logger = setup_logger(__name__) DISABLE_LENGTH_CHECK = True class MainTerminalToolsExecutionMixin: + @staticmethod + def _mcp_disabled_message() -> str: + return "当前为docker模式,MCP仅支持宿主机模式" + + def _is_mcp_disabled_in_docker_mode(self) -> bool: + session = getattr(self, "container_session", None) + return bool(session and getattr(session, "mode", None) == "docker") + _TERMINAL_SERIES_TOOLS = { "terminal_session", "terminal_input", @@ -597,13 +605,103 @@ class MainTerminalToolsExecutionMixin: pass custom_tool = self.custom_tool_registry.get_tool(tool_name) + # MCP 工具 alias 识别(mcp____) + is_mcp_tool_alias = bool( + isinstance(tool_name, str) and tool_name.startswith("mcp__") + ) + if (tool_name == "list_mcp_servers" or is_mcp_tool_alias) and self._is_mcp_disabled_in_docker_mode(): + blocked = self._mcp_disabled_message() + return json.dumps( + { + "success": False, + "error": blocked, + "message": blocked, + }, + ensure_ascii=False, + ) + # Skill 强约束:未阅读对应 SKILL.md 时,阻止 terminal/sub-agent 系列工具执行 strict_error = self._check_skill_strict_prerequisite(tool_name, arguments) if strict_error: return json.dumps(strict_error, ensure_ascii=False) try: - if custom_tool: + if tool_name == "list_mcp_servers": + manager = getattr(self, "mcp_client_manager", None) + registry = getattr(self, "mcp_server_registry", None) + if manager is None or registry is None: + result = {"success": False, "error": "MCP 模块不可用"} + else: + refresh = bool(arguments.get("refresh", False)) + include_disabled = bool(arguments.get("include_disabled", False)) + server_id = str(arguments.get("server_id") or "").strip() or None + sync_result = None + if refresh: + sync_result = await asyncio.to_thread( + manager.sync_servers, + server_id=server_id, + ) + await asyncio.to_thread(registry.reload) + servers = registry.list_servers(include_disabled=include_disabled) + if server_id: + servers = [item for item in servers if item.get("id") == server_id] + + # 读取当前 alias 映射(不强制再次发现) + _, alias_map = manager.build_openai_tools(ensure_discovery=False) + aliases_by_server = {} + for alias, binding in (alias_map or {}).items(): + sid = getattr(binding, "server_id", "") + if not sid: + continue + aliases_by_server.setdefault(sid, []).append(alias) + + items = [] + for item in servers: + sid = str(item.get("id") or "") + cache_tools = item.get("tools_cache") or [] + cache_names = [ + str((tool or {}).get("name") or "").strip() + for tool in cache_tools + if isinstance(tool, dict) + ] + cache_names = [name for name in cache_names if name] + aliases = sorted(aliases_by_server.get(sid, [])) + items.append( + { + "id": sid, + "name": item.get("name") or sid, + "enabled": bool(item.get("enabled", True)), + "transport": item.get("transport"), + "timeout_seconds": item.get("timeout_seconds"), + "tools_cache_count": len(cache_names), + "tools_cache_names": cache_names, + "tool_aliases": aliases, + "tools_cache_updated_at": item.get("tools_cache_updated_at"), + "last_error": item.get("last_error") or "", + } + ) + + result = { + "success": True, + "count": len(items), + "servers": items, + "sync_result": sync_result, + } + elif is_mcp_tool_alias: + if not getattr(self, "mcp_tools_enabled", False): + result = {"success": False, "error": "MCP 功能未启用"} + return json.dumps(result, ensure_ascii=False) + manager = getattr(self, "mcp_client_manager", None) + if manager is None: + result = {"success": False, "error": "MCP 管理器不可用"} + else: + result = await asyncio.to_thread( + manager.call_tool_by_alias, + tool_name, + arguments, + alias_map=getattr(self, "mcp_tool_alias_map", None), + ) + elif custom_tool: result = await self.custom_tool_executor.run(tool_name, arguments) elif tool_name == "read_file": result = self._handle_read_tool(arguments) diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index d8d0b04..4528c24 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -63,6 +63,7 @@ from modules.skills_manager import ( ) from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category from modules.custom_tool_executor import CustomToolExecutor +from modules.mcp_server_registry import build_default_mcp_category try: from config.limits import THINKING_FAST_INTERVAL @@ -224,6 +225,14 @@ class MainTerminalToolsPolicyMixin: default_enabled=True, silent_when_disabled=False, ) + if getattr(self, "mcp_tools_enabled", False) and "mcp" not in self.tool_categories_map: + default_mcp_cat = build_default_mcp_category() + self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))( + label=default_mcp_cat["label"], + tools=[], + default_enabled=True, + silent_when_disabled=False, + ) # 重新构建启用状态映射,保留已有值 new_states: Dict[str, bool] = {} for key, cat in self.tool_categories_map.items(): diff --git a/core/tool_config.py b/core/tool_config.py index bc12583..d52d29b 100644 --- a/core/tool_config.py +++ b/core/tool_config.py @@ -27,6 +27,10 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = { label="网络检索", tools=["web_search", "extract_webpage", "save_webpage"], ), + "mcp": ToolCategory( + label="MCP 工具", + tools=["list_mcp_servers"], + ), "file_edit": ToolCategory( label="文件编辑", tools=[ diff --git a/docs/mcp-tool-config-guide.md b/docs/mcp-tool-config-guide.md new file mode 100644 index 0000000..3d0d2a7 --- /dev/null +++ b/docs/mcp-tool-config-guide.md @@ -0,0 +1,199 @@ +# 本项目新增 MCP 工具配置指南 + +> 最后更新:2026-04-26 +> 适用目录:`/Users/jojo/Desktop/agents/正在修复中/agents` + +本文面向后续维护者,说明如何在当前项目里新增并接入一个 MCP 工具(MCP Server)。 + +--- + +## 1. 先了解本项目里的 MCP 架构 + +当前实现是「**本项目作为 MCP 客户端桥接层**」: + +1. 在后台配置一个或多个 MCP Server(stdio / streamable_http) +2. 系统通过 `tools/list` 拉取远端工具 +3. 本地自动生成工具别名:`mcp____` +4. 模型调用该别名时,桥接层转发到对应服务的 `tools/call` + +内置了一个原生工具: + +- `list_mcp_servers`:查看服务、缓存工具和别名映射(可选刷新) + +--- + +## 2. 配置入口(推荐) + +### 2.1 管理后台入口(推荐) + +- 打开:`/admin/policy` +- 页面中的「**MCP 服务配置(统一工具扩展)**」区域可做增删改查、同步工具 + +### 2.2 个人空间入口(宿主机管理员) + +- 个人空间里有单独的「**MCP 配置**」入口 +- 仅在 **宿主机模式 + 管理员** 时显示 +- 点击后会跳转到 `/admin/policy` + +> 管理 API 受管理员权限和二级密码校验保护,建议优先使用页面配置,不建议直接手改请求。 + +--- + +## 3. 第一步:先写一个可运行的 MCP Server + +仓库已有可直接参考的测试服务: + +- `/Users/jojo/Desktop/agents/正在修复中/agents/scripts/mcp_calculator_server.py` + +这个示例使用 stdio,支持: + +- `initialize` +- `tools/list` +- `tools/call` + +如果你写新服务,至少要保证上述 3 个方法可用。 + +--- + +## 4. 第二步:在后台新增服务配置 + +在 `/admin/policy` 的 MCP 区域点击“新增 MCP 服务”,填写核心字段: + +- `id`:必须以字母开头,仅允许字母/数字/`_`/`-` +- `transport`:`stdio` 或 `streamable_http` +- `enabled`:是否启用 +- `timeout(s)`:调用超时 + +### 4.1 stdio 示例 + +- `transport`: `stdio` +- `command`: `python3`(或绝对路径) +- `args`: 每行一个参数(常见是服务脚本绝对路径) +- `cwd`: 可选 +- `env`: 可选 JSON + +示例: + +```json +{ + "id": "calc_stdio", + "name": "Calculator MCP", + "enabled": true, + "transport": "stdio", + "command": "python3", + "args": ["/abs/path/scripts/mcp_calculator_server.py"], + "timeout_seconds": 12 +} +``` + +### 4.2 streamable_http 示例 + +```json +{ + "id": "my_http_mcp", + "name": "My HTTP MCP", + "enabled": true, + "transport": "streamable_http", + "url": "http://127.0.0.1:8000/mcp", + "headers": { + "Authorization": "Bearer " + }, + "timeout_seconds": 25 +} +``` + +### 4.3 可选过滤 + +- `include_tools`:仅放行这些工具名 +- `exclude_tools`:屏蔽这些工具名 + +--- + +## 5. 第三步:同步工具缓存 + +保存后点击: + +- “同步工具”(单服务) +- 或“同步全部工具” + +同步成功后会写入缓存字段(页面可见): + +- `tools_cache_count` +- `tools_cache_updated_at` +- `last_error` + +--- + +## 6. 第四步:在对话里验证是否接入成功 + +让模型先调用 `list_mcp_servers`(可 `refresh=true`),确认: + +1. 目标服务在列表中 +2. `tools_cache_names` 有你新工具 +3. `tool_aliases` 已生成(通常是 `mcp__...`) + +再让模型调用对应别名工具即可。 + +--- + +## 7. 环境变量(可选) + +见 `.env.example`: + +- `MCP_TOOLS_ENABLED=1` +- `MCP_SERVERS_FILE=./data/mcp_servers.json` +- `MCP_PROTOCOL_VERSION=2025-06-18` +- `MCP_DEFAULT_TIMEOUT_SECONDS=25` + +默认配置文件是运行态文件: + +- `/Users/jojo/Desktop/agents/正在修复中/agents/data/mcp_servers.json` + +--- + +## 8. Docker 模式下的 stdio MCP(重要) + +当前策略已收敛为:**MCP 仅在宿主机模式支持**。 + +当会话运行在 docker 模式时: + +1. 调用 `list_mcp_servers` 会直接返回:`当前为docker模式,MCP仅支持宿主机模式`。 +2. 调用任意 `mcp__...` 工具别名也会返回同样提示。 +3. 不再在 docker 模式执行 MCP 服务同步与调用。 + +如果需要使用 MCP,请先切换到宿主机模式后再进行配置/调用。 + +--- + +## 9. 常见问题排查 + +1. **服务保存失败** + - 检查 `id` 格式、transport 必填项(stdio 要 `command`,HTTP 要 `url`) + +2. **同步失败 / 无工具** + - 看 `last_error` + - 检查服务是否能独立启动 + - 检查 `include_tools/exclude_tools` + +3. **工具在页面有,但模型看不到** + - 确认服务 `enabled=true` + - 确认管理策略没禁用 MCP 分类 + - 重新“同步工具”后再试 + +4. **调用时报超时** + - 提高 `timeout_seconds` + - 优化 MCP Server 响应速度 + +--- + +## 10. 关键代码位置(便于二次开发) + +- MCP 配置:`/Users/jojo/Desktop/agents/正在修复中/agents/config/mcp.py` +- 服务注册表:`/Users/jojo/Desktop/agents/正在修复中/agents/modules/mcp_server_registry.py` +- MCP 客户端桥接:`/Users/jojo/Desktop/agents/正在修复中/agents/modules/mcp_client_manager.py` +- 管理 API:`/Users/jojo/Desktop/agents/正在修复中/agents/server/admin.py` +- 工具定义/执行: + - `/Users/jojo/Desktop/agents/正在修复中/agents/core/main_terminal_parts/tools_definition.py` + - `/Users/jojo/Desktop/agents/正在修复中/agents/core/main_terminal_parts/tools_execution.py` +- 管理端页面:`/Users/jojo/Desktop/agents/正在修复中/agents/static/src/admin/PolicyApp.vue` +- 测试示例服务:`/Users/jojo/Desktop/agents/正在修复中/agents/scripts/mcp_calculator_server.py` diff --git a/modules/admin_policy_manager.py b/modules/admin_policy_manager.py index 9e957c4..f1a0000 100644 --- a/modules/admin_policy_manager.py +++ b/modules/admin_policy_manager.py @@ -16,6 +16,7 @@ from typing import Dict, Any, Tuple from config.paths import ADMIN_POLICY_FILE from config.model_profiles import get_registered_model_keys from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category +from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category def _allowed_models() -> set[str]: return set(get_registered_model_keys(visible_only=False)) @@ -145,6 +146,7 @@ def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, D """从 override 字典生成 {id: {label, tools, default_enabled}}""" from core.tool_config import TOOL_CATEGORIES # 延迟导入避免循环 registry = CustomToolRegistry() + mcp_registry = MCPServerRegistry() base: Dict[str, Dict[str, Any]] = { key: { @@ -167,6 +169,17 @@ def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, D "silent_when_disabled": False, } + # 注入 MCP 工具分类(动态) + mcp_cat = build_default_mcp_category() + mcp_cat_id = mcp_cat.get("id", "mcp") + mcp_tools = ["list_mcp_servers", *mcp_registry.build_cached_alias_list()] + base[mcp_cat_id] = { + "label": mcp_cat.get("label", "MCP 工具"), + "tools": mcp_tools, + "default_enabled": True, + "silent_when_disabled": False, + } + remove_ids = set(overrides.get("remove_categories") or []) for rid in remove_ids: base.pop(rid, None) @@ -227,6 +240,7 @@ def describe_defaults() -> Dict[str, Any]: """返回默认(未覆盖)工具分类,用于前端渲染。""" from core.tool_config import TOOL_CATEGORIES registry = CustomToolRegistry() + mcp_registry = MCPServerRegistry() categories = { key: { @@ -248,6 +262,16 @@ def describe_defaults() -> Dict[str, Any]: "silent_when_disabled": False, } + # MCP 工具分类 + mcp_cat = build_default_mcp_category() + mcp_cat_id = mcp_cat.get("id", "mcp") + categories[mcp_cat_id] = { + "label": mcp_cat.get("label", "MCP 工具"), + "tools": ["list_mcp_servers", *mcp_registry.build_cached_alias_list()], + "default_enabled": True, + "silent_when_disabled": False, + } + return { "categories": categories, "models": sorted(list(_allowed_models())), diff --git a/modules/mcp_client_manager.py b/modules/mcp_client_manager.py new file mode 100644 index 0000000..d6d7a13 --- /dev/null +++ b/modules/mcp_client_manager.py @@ -0,0 +1,824 @@ +"""MCP 客户端管理:工具发现、调用与 OpenAI 工具映射。""" + +from __future__ import annotations + +import hashlib +import json +import os +import select +import shutil +import subprocess +import time +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Dict, Iterable, List, Optional, Tuple, TYPE_CHECKING + +import httpx + +from config import MCP_PROTOCOL_VERSION, MCP_DEFAULT_TIMEOUT_SECONDS +from modules.mcp_server_registry import MCPServerRegistry + +if TYPE_CHECKING: + from modules.user_container_manager import ContainerHandle + + +class MCPClientError(Exception): + """MCP 客户端异常。""" + + +@dataclass +class MCPToolBinding: + alias: str + server_id: str + server_name: str + remote_name: str + transport: str + timeout_seconds: int + + +class _StdioMCPClient: + """最小可用 stdio MCP 客户端(JSON-RPC over newline-delimited JSON)。""" + + CONTAINER_PREFIXES = ( + "/workspace", + "/usr/", + "/bin/", + "/sbin/", + "/lib/", + "/lib64/", + "/opt/", + "/etc/", + "/tmp/", + "/var/", + "/home/", + "/root/", + ) + PATH_OPTION_KEYS = { + "--path", + "--dir", + "--directory", + "--root", + "--cwd", + "--workspace", + } + + def __init__( + self, + server: Dict[str, Any], + timeout_seconds: int, + protocol_version: str, + container_session: Optional["ContainerHandle"] = None, + ): + self.server = server + self.timeout_seconds = max(3, int(timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS)) + self.protocol_version = protocol_version + self.container_session = container_session + self.process: Optional[subprocess.Popen] = None + self._seq = 0 + + def _next_id(self) -> int: + self._seq += 1 + return self._seq + + @staticmethod + def _is_windows_abs_path(value: str) -> bool: + return len(value) > 2 and value[1] == ":" and value[0].isalpha() + + @staticmethod + def _normalize_mount_path(value: str) -> str: + mount = str(value or "/workspace").strip() + mount = mount.rstrip("/") + return mount or "/workspace" + + def _normalize_workspace_path(self, value: Any) -> str: + raw = str(value or "").strip() + if not raw: + return "" + try: + return str(Path(raw).expanduser().resolve()) + except Exception: + return raw + + def _map_workspace_path(self, raw: str, workspace_host: str, mount_path: str) -> str: + if not raw or not workspace_host: + return raw + if raw.startswith(mount_path): + return raw + if self._is_windows_abs_path(raw): + return raw + if not raw.startswith("/"): + return raw + try: + normalized = str(Path(raw).expanduser().resolve()) + except Exception: + normalized = raw + if normalized == workspace_host: + return mount_path + prefix = workspace_host + os.sep + if normalized.startswith(prefix): + rel = normalized[len(prefix):].replace("\\", "/") + return f"{mount_path}/{rel}" if rel else mount_path + return raw + + def _rewrite_path_like_value_for_container( + self, + value: str, + workspace_host: str, + mount_path: str, + ) -> str: + raw = str(value or "").strip() + if not raw: + return raw + mapped = self._map_workspace_path(raw, workspace_host, mount_path) + return mapped if mapped != raw else raw + + @staticmethod + def _command_basename(value: str) -> str: + text = str(value or "").strip().replace("\\", "/").rstrip("/") + if not text: + return "" + if "/" in text: + return text.split("/")[-1] + return text + + def _rewrite_command_for_container(self, command: str, workspace_host: str, mount_path: str) -> str: + raw = str(command or "").strip() + if not raw: + return raw + mapped = self._map_workspace_path(raw, workspace_host, mount_path) + if mapped != raw: + return mapped + # command 若是宿主机绝对路径(如 /opt/homebrew/bin/npx), + # 在容器内通常应退化为命令名(npx/python3),避免无意义路径校验阻断。 + if raw.startswith("/") or self._is_windows_abs_path(raw) or "\\" in raw: + candidate = self._command_basename(raw) + return candidate or raw + return raw + + def _rewrite_arg_for_container( + self, + arg: str, + *, + index: int, + workspace_host: str, + mount_path: str, + ) -> str: + raw = str(arg or "") + stripped = raw.strip() + if not stripped: + return raw + if "=" in stripped: + left, right = stripped.split("=", 1) + if left in self.PATH_OPTION_KEYS and right: + rewritten = self._rewrite_path_like_value_for_container( + right, + workspace_host=workspace_host, + mount_path=mount_path, + ) + return f"{left}={rewritten}" + return self._rewrite_path_like_value_for_container( + stripped, + workspace_host=workspace_host, + mount_path=mount_path, + ) + + def _prepare_launch(self) -> Tuple[List[str], Optional[str], Dict[str, str]]: + command_raw = str(self.server.get("command") or "").strip() + args_raw = [str(item) for item in (self.server.get("args") or [])] + env_raw = dict(self.server.get("env") or {}) + cwd_raw = str(self.server.get("cwd") or "").strip() or None + if not command_raw: + raise MCPClientError("stdio 服务缺少 command") + + session = self.container_session + use_container = bool(session and getattr(session, "mode", None) == "docker") + if not use_container: + env = dict(os.environ) + env.update(env_raw) + return [command_raw, *args_raw], cwd_raw, env + + container_name = str(getattr(session, "container_name", "") or "").strip() + if not container_name: + raise MCPClientError("docker 模式下 stdio MCP 缺少 container_name") + docker_bin = str(getattr(session, "sandbox_bin", "") or "docker").strip() or "docker" + docker_path = shutil.which(docker_bin) or docker_bin + mount_path = self._normalize_mount_path(getattr(session, "mount_path", "/workspace")) + workspace_host = self._normalize_workspace_path(getattr(session, "workspace_path", "")) + + command = self._rewrite_command_for_container( + command_raw, + workspace_host=workspace_host, + mount_path=mount_path, + ) + args: List[str] = [] + for idx, item in enumerate(args_raw): + args.append( + self._rewrite_arg_for_container( + item, + index=idx, + workspace_host=workspace_host, + mount_path=mount_path, + ) + ) + cwd = ( + self._rewrite_path_like_value_for_container( + cwd_raw, + workspace_host=workspace_host, + mount_path=mount_path, + ) + if cwd_raw + else None + ) + + cmd: List[str] = [docker_path, "exec", "-i"] + if cwd: + cmd += ["-w", cwd] + + exec_env = { + "PYTHONIOENCODING": "utf-8", + "TERM": "xterm-256color", + } + for key, value in env_raw.items(): + if value is not None: + exec_env[str(key)] = str(value) + for key, value in exec_env.items(): + cmd += ["-e", f"{key}={value}"] + cmd += [container_name, command, *args] + + return cmd, None, dict(os.environ) + + def start(self) -> None: + launch_cmd, cwd, env = self._prepare_launch() + try: + self.process = subprocess.Popen( + launch_cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + bufsize=1, + cwd=cwd or None, + env=env, + ) + except Exception as exc: + raise MCPClientError(f"启动 stdio MCP 服务失败: {exc}") from exc + + def _send(self, payload: Dict[str, Any]) -> None: + if not self.process or not self.process.stdin: + raise MCPClientError("stdio 进程未启动") + raw = json.dumps(payload, ensure_ascii=False) + try: + self.process.stdin.write(raw + "\n") + self.process.stdin.flush() + except Exception as exc: + raise MCPClientError(f"写入 stdio 消息失败: {exc}") from exc + + def _read_message(self, timeout_seconds: Optional[float] = None) -> Dict[str, Any]: + if not self.process or not self.process.stdout: + raise MCPClientError("stdio 进程未启动") + timeout = self.timeout_seconds if timeout_seconds is None else max(0.1, float(timeout_seconds)) + deadline = time.monotonic() + timeout + stdout = self.process.stdout + last_text_line = "" + while True: + if self.process.poll() is not None: + stderr_text = "" + stdout_text = "" + try: + if self.process.stderr: + stderr_text = self.process.stderr.read() or "" + except Exception: + pass + try: + if self.process.stdout: + stdout_text = self.process.stdout.read() or "" + except Exception: + pass + detail = (stderr_text or stdout_text or last_text_line or "")[:400] + raise MCPClientError(f"stdio 进程已退出(code={self.process.returncode}){detail}") + + remain = deadline - time.monotonic() + if remain <= 0: + raise MCPClientError("读取 stdio MCP 响应超时") + + readable, _, _ = select.select([stdout], [], [], min(0.3, remain)) + if not readable: + continue + line = stdout.readline() + if not line: + continue + line = line.strip() + if not line: + continue + try: + return json.loads(line) + except Exception: + last_text_line = line + continue + + def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + req_id = self._next_id() + payload = {"jsonrpc": "2.0", "id": req_id, "method": method, "params": params or {}} + self._send(payload) + while True: + msg = self._read_message() + if msg.get("id") == req_id: + if "error" in msg: + error = msg.get("error") or {} + raise MCPClientError(f"{method} 调用失败: {error}") + return msg.get("result") or {} + # 服务器主动向客户端发起请求时,返回 method not found,避免阻塞 + if "id" in msg and "method" in msg: + self._send( + { + "jsonrpc": "2.0", + "id": msg.get("id"), + "error": {"code": -32601, "message": "Client method not implemented"}, + } + ) + + def notify(self, method: str, params: Optional[Dict[str, Any]] = None) -> None: + self._send({"jsonrpc": "2.0", "method": method, "params": params or {}}) + + def initialize(self) -> Dict[str, Any]: + result = self.request( + "initialize", + { + "protocolVersion": self.protocol_version, + "capabilities": {}, + "clientInfo": {"name": "agents-mcp-bridge", "version": "1.0.0"}, + }, + ) + self.notify("notifications/initialized") + return result + + def close(self) -> None: + if not self.process: + return + try: + if self.process.stdin: + self.process.stdin.close() + except Exception: + pass + try: + if self.process.poll() is None: + self.process.terminate() + self.process.wait(timeout=1.5) + except Exception: + try: + self.process.kill() + except Exception: + pass + finally: + try: + if self.process.stdout: + self.process.stdout.close() + except Exception: + pass + try: + if self.process.stderr: + self.process.stderr.close() + except Exception: + pass + self.process = None + + +class _StreamableHTTPMCPClient: + """最小可用 streamable HTTP MCP 客户端。""" + + def __init__(self, server: Dict[str, Any], timeout_seconds: int, protocol_version: str): + self.server = server + self.url = str(server.get("url") or "").strip() + self.timeout_seconds = max(3, int(timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS)) + self.protocol_version = protocol_version + self._session_id: Optional[str] = None + self._seq = 0 + self._client = httpx.Client(timeout=self.timeout_seconds) + + def _next_id(self) -> int: + self._seq += 1 + return self._seq + + @staticmethod + def _header_pick(headers: httpx.Headers, key: str) -> Optional[str]: + for hk, hv in headers.items(): + if hk.lower() == key.lower(): + return hv + return None + + def _build_headers(self) -> Dict[str, str]: + headers = { + "Accept": "application/json, text/event-stream", + "Content-Type": "application/json", + "MCP-Protocol-Version": self.protocol_version, + } + if self._session_id: + headers["Mcp-Session-Id"] = self._session_id + headers.update(self.server.get("headers") or {}) + return headers + + @staticmethod + def _parse_sse_json_events(raw_text: str) -> List[Dict[str, Any]]: + events: List[Dict[str, Any]] = [] + blocks = raw_text.split("\n\n") + for block in blocks: + data_lines: List[str] = [] + for line in block.splitlines(): + if line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + if not data_lines: + continue + payload = "\n".join(data_lines).strip() + if not payload or payload == "[DONE]": + continue + try: + parsed = json.loads(payload) + except Exception: + continue + if isinstance(parsed, dict): + events.append(parsed) + return events + + def _request(self, method: str, params: Optional[Dict[str, Any]] = None, notification: bool = False) -> Dict[str, Any]: + if not self.url: + raise MCPClientError("streamable_http 服务缺少 url") + req_id = None if notification else self._next_id() + payload: Dict[str, Any] = {"jsonrpc": "2.0", "method": method, "params": params or {}} + if req_id is not None: + payload["id"] = req_id + try: + resp = self._client.post(self.url, headers=self._build_headers(), json=payload) + except Exception as exc: + raise MCPClientError(f"HTTP MCP 请求失败: {exc}") from exc + + session_id = self._header_pick(resp.headers, "Mcp-Session-Id") + if session_id: + self._session_id = session_id + + if resp.status_code >= 400: + raise MCPClientError(f"HTTP MCP 状态异常: {resp.status_code} {resp.text[:400]}") + + if notification: + return {} + + content_type = (resp.headers.get("content-type") or "").lower() + message: Optional[Dict[str, Any]] = None + if "application/json" in content_type: + parsed = resp.json() + if isinstance(parsed, dict): + message = parsed + elif "text/event-stream" in content_type: + for event in self._parse_sse_json_events(resp.text): + if event.get("id") == req_id: + message = event + break + else: + try: + parsed = resp.json() + if isinstance(parsed, dict): + message = parsed + except Exception: + pass + + if not message: + raise MCPClientError(f"HTTP MCP 未返回可解析响应(method={method})") + if "error" in message: + raise MCPClientError(f"{method} 调用失败: {message.get('error')}") + return message.get("result") or {} + + def initialize(self) -> Dict[str, Any]: + result = self._request( + "initialize", + { + "protocolVersion": self.protocol_version, + "capabilities": {}, + "clientInfo": {"name": "agents-mcp-bridge", "version": "1.0.0"}, + }, + ) + self._request("notifications/initialized", {}, notification=True) + return result + + def request(self, method: str, params: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + return self._request(method, params, notification=False) + + def close(self) -> None: + try: + if self._session_id and self.url: + self._client.delete(self.url, headers=self._build_headers()) + except Exception: + pass + try: + self._client.close() + except Exception: + pass + + +class MCPClientManager: + """MCP 客户端主协调器。""" + + def __init__( + self, + registry: MCPServerRegistry, + protocol_version: str = MCP_PROTOCOL_VERSION, + container_session: Optional["ContainerHandle"] = None, + ): + self.registry = registry + self.protocol_version = str(protocol_version or MCP_PROTOCOL_VERSION) + self.container_session = container_session + self._latest_alias_map: Dict[str, MCPToolBinding] = {} + + def set_container_session(self, session: Optional["ContainerHandle"]) -> None: + self.container_session = session + + @staticmethod + def _sanitize_input_schema(schema: Any) -> Dict[str, Any]: + if not isinstance(schema, dict): + return {"type": "object", "properties": {}} + output = dict(schema) + if output.get("type") != "object": + output["type"] = "object" + if not isinstance(output.get("properties"), dict): + output["properties"] = {} + if "required" in output and not isinstance(output.get("required"), list): + output.pop("required", None) + return output + + @staticmethod + def _compact_tool_entry(tool: Dict[str, Any]) -> Dict[str, Any]: + return { + "name": str(tool.get("name") or "").strip(), + "description": str(tool.get("description") or ""), + "title": str(tool.get("title") or ""), + "inputSchema": tool.get("inputSchema") if isinstance(tool.get("inputSchema"), dict) else {"type": "object", "properties": {}}, + } + + @staticmethod + def _build_call_message(result: Dict[str, Any]) -> str: + content = result.get("content") + if isinstance(content, list): + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + text = str(item.get("text") or "").strip() + if text: + return text[:500] + structured = result.get("structuredContent") + if structured is not None: + try: + return json.dumps(structured, ensure_ascii=False)[:500] + except Exception: + return str(structured)[:500] + return "MCP 工具调用完成" + + @staticmethod + def _content_preview(content_items: Any) -> str: + if not isinstance(content_items, list): + return "" + previews: List[str] = [] + for item in content_items: + if not isinstance(item, dict): + continue + ctype = item.get("type") + if ctype == "text": + text = str(item.get("text") or "").strip() + if text: + previews.append(text[:300]) + elif ctype: + previews.append(f"[{ctype}]") + if len(previews) >= 4: + break + return "\n".join(previews).strip() + + def _make_client(self, server: Dict[str, Any], timeout_seconds: int): + transport = server.get("transport") + if transport == "stdio": + return _StdioMCPClient( + server, + timeout_seconds, + self.protocol_version, + container_session=self.container_session, + ) + if transport == "streamable_http": + return _StreamableHTTPMCPClient(server, timeout_seconds, self.protocol_version) + raise MCPClientError(f"不支持的 MCP transport: {transport}") + + def _paged_list_tools(self, client, max_pages: int = 20) -> List[Dict[str, Any]]: + cursor: Optional[str] = None + tools: List[Dict[str, Any]] = [] + for _ in range(max_pages): + params = {"cursor": cursor} if cursor else {} + result = client.request("tools/list", params) + page_tools = result.get("tools") if isinstance(result, dict) else [] + if isinstance(page_tools, list): + for item in page_tools: + if isinstance(item, dict): + compact = self._compact_tool_entry(item) + if compact.get("name"): + tools.append(compact) + cursor = (result or {}).get("nextCursor") if isinstance(result, dict) else None + if not cursor: + break + return tools + + def discover_tools_for_server(self, server_id: str, *, timeout_seconds: Optional[int] = None) -> Dict[str, Any]: + server = self.registry.get_server(server_id) + if not server: + return {"success": False, "error": f"未找到 MCP 服务: {server_id}"} + timeout = int(timeout_seconds or server.get("timeout_seconds") or MCP_DEFAULT_TIMEOUT_SECONDS) + started = time.time() + client = None + try: + client = self._make_client(server, timeout) + client.start() if hasattr(client, "start") else None + init_result = client.initialize() + tools = self._paged_list_tools(client) + self.registry.update_tools_cache(server_id, tools=tools, error="", touched=True) + elapsed_ms = int((time.time() - started) * 1000) + return { + "success": True, + "server_id": server_id, + "protocol_version": init_result.get("protocolVersion") if isinstance(init_result, dict) else None, + "tools_count": len(tools), + "tools": tools, + "elapsed_ms": elapsed_ms, + } + except Exception as exc: + self.registry.update_tools_cache(server_id, error=str(exc), touched=True) + return {"success": False, "server_id": server_id, "error": str(exc)} + finally: + try: + if client: + client.close() + except Exception: + pass + + def sync_servers(self, server_id: Optional[str] = None) -> Dict[str, Any]: + # 先从磁盘重载一次,避免「文件已改但进程内缓存未刷新」导致同步遗漏, + # 以及后续 update_tools_cache 把旧缓存反写覆盖新配置。 + self.registry.reload() + if server_id: + return self.discover_tools_for_server(server_id) + results = [] + for server in self.registry.list_enabled_servers(): + sid = server.get("id") + if not sid: + continue + results.append(self.discover_tools_for_server(sid)) + ok_count = sum(1 for item in results if item.get("success")) + overall_success = (not results) or (ok_count == len(results)) + return { + "success": overall_success, + "servers_total": len(results), + "servers_success": ok_count, + "servers_failed": len(results) - ok_count, + "results": results, + } + + def _iter_visible_tools(self, server: Dict[str, Any]) -> Iterable[Dict[str, Any]]: + include_tools = set(server.get("include_tools") or []) + exclude_tools = set(server.get("exclude_tools") or []) + for item in server.get("tools_cache") or []: + if not isinstance(item, dict): + continue + name = str(item.get("name") or "").strip() + if not name: + continue + if include_tools and name not in include_tools: + continue + if name in exclude_tools: + continue + yield item + + def build_openai_tools( + self, + *, + ensure_discovery: bool = True, + discovery_timeout_seconds: int = 12, + ) -> Tuple[List[Dict[str, Any]], Dict[str, MCPToolBinding]]: + tools: List[Dict[str, Any]] = [] + alias_map: Dict[str, MCPToolBinding] = {} + + servers = self.registry.list_enabled_servers() + for server in servers: + sid = str(server.get("id") or "").strip() + if not sid: + continue + if ensure_discovery and not (server.get("tools_cache") or []): + self.discover_tools_for_server(sid, timeout_seconds=discovery_timeout_seconds) + server = self.registry.get_server(sid) or server + + for item in self._iter_visible_tools(server): + remote_name = str(item.get("name") or "").strip() + alias_base = self.registry.make_tool_alias(sid, remote_name) + alias = alias_base + if alias in alias_map: + suffix = hashlib.sha1(f"{sid}:{remote_name}".encode("utf-8")).hexdigest()[:6] + max_len = max(8, 64 - len(suffix) - 2) + alias = f"{alias_base[:max_len]}__{suffix}" + + description = str(item.get("description") or "").strip() + if description: + description = f"[MCP:{sid}] {description}" + else: + description = f"[MCP:{sid}] 通过 MCP 服务调用远程工具 {remote_name}" + + schema = self._sanitize_input_schema(item.get("inputSchema")) + tools.append( + { + "type": "function", + "function": { + "name": alias, + "description": description, + "parameters": schema, + }, + } + ) + alias_map[alias] = MCPToolBinding( + alias=alias, + server_id=sid, + server_name=str(server.get("name") or sid), + remote_name=remote_name, + transport=str(server.get("transport") or ""), + timeout_seconds=int(server.get("timeout_seconds") or MCP_DEFAULT_TIMEOUT_SECONDS), + ) + + self._latest_alias_map = alias_map + return tools, alias_map + + def _resolve_binding( + self, + tool_alias: str, + alias_map: Optional[Dict[str, MCPToolBinding]] = None, + ) -> Optional[MCPToolBinding]: + if alias_map and tool_alias in alias_map: + return alias_map[tool_alias] + if tool_alias in self._latest_alias_map: + return self._latest_alias_map[tool_alias] + + # 容错:从缓存动态重建一遍 + _, rebuilt = self.build_openai_tools(ensure_discovery=False) + return rebuilt.get(tool_alias) + + def call_tool_by_alias( + self, + tool_alias: str, + arguments: Dict[str, Any], + *, + alias_map: Optional[Dict[str, MCPToolBinding]] = None, + ) -> Dict[str, Any]: + binding = self._resolve_binding(tool_alias, alias_map) + if not binding: + return {"success": False, "error": f"未找到 MCP 工具映射: {tool_alias}"} + server = self.registry.get_server(binding.server_id) + if not server: + return {"success": False, "error": f"未找到 MCP 服务: {binding.server_id}"} + if not server.get("enabled", True): + return {"success": False, "error": f"MCP 服务已禁用: {binding.server_id}"} + + timeout = int(server.get("timeout_seconds") or binding.timeout_seconds or MCP_DEFAULT_TIMEOUT_SECONDS) + client = None + started = time.time() + try: + client = self._make_client(server, timeout) + client.start() if hasattr(client, "start") else None + client.initialize() + call_result = client.request( + "tools/call", + {"name": binding.remote_name, "arguments": arguments or {}}, + ) + is_error = bool((call_result or {}).get("isError")) + content_items = (call_result or {}).get("content") or [] + preview = self._content_preview(content_items) + elapsed_ms = int((time.time() - started) * 1000) + message = self._build_call_message(call_result) + + return { + "success": not is_error, + "server_id": binding.server_id, + "server_name": binding.server_name, + "tool_alias": binding.alias, + "tool_name": binding.remote_name, + "transport": binding.transport, + "elapsed_ms": elapsed_ms, + "is_error": is_error, + "message": message, + "preview": preview, + "content": content_items, + "structured_content": (call_result or {}).get("structuredContent"), + "raw_result": call_result, + } + except Exception as exc: + return { + "success": False, + "server_id": binding.server_id, + "server_name": binding.server_name, + "tool_alias": binding.alias, + "tool_name": binding.remote_name, + "transport": binding.transport, + "error": str(exc), + } + finally: + try: + if client: + client.close() + except Exception: + pass diff --git a/modules/mcp_server_registry.py b/modules/mcp_server_registry.py new file mode 100644 index 0000000..fa9e646 --- /dev/null +++ b/modules/mcp_server_registry.py @@ -0,0 +1,315 @@ +"""MCP 服务器配置与缓存管理。""" + +from __future__ import annotations + +import json +import re +import time +from pathlib import Path +from typing import Any, Dict, List, Optional, Tuple + +from config import ( + MCP_SERVERS_FILE, + MCP_TOOLS_ENABLED, + MCP_DEFAULT_TIMEOUT_SECONDS, +) + + +class MCPServerRegistry: + """文件式 MCP 服务器注册表。""" + + ID_PATTERN = re.compile(r"^[A-Za-z][A-Za-z0-9_-]*$") + TRANSPORTS = {"stdio", "streamable_http"} + + def __init__(self, config_path: str = MCP_SERVERS_FILE, enabled: bool = MCP_TOOLS_ENABLED): + self.config_path = Path(config_path).expanduser().resolve() + self.enabled = bool(enabled) + self.config_path.parent.mkdir(parents=True, exist_ok=True) + self._cache: List[Dict[str, Any]] = [] + if self.enabled: + self.reload() + + # ------------------------------------------------------------------ + # 基础方法 + # ------------------------------------------------------------------ + @classmethod + def _is_valid_id(cls, server_id: str) -> bool: + return bool(server_id and cls.ID_PATTERN.match(server_id)) + + @staticmethod + def _normalize_transport(raw: Any) -> str: + value = str(raw or "").strip().lower().replace("-", "_") + if value in {"http", "https", "streamablehttp"}: + return "streamable_http" + return value + + @staticmethod + def _normalize_str_list(value: Any) -> List[str]: + if not isinstance(value, list): + return [] + output: List[str] = [] + for item in value: + text = str(item).strip() if item is not None else "" + if text: + output.append(text) + return output + + @staticmethod + def _normalize_str_dict(value: Any) -> Dict[str, str]: + if not isinstance(value, dict): + return {} + output: Dict[str, str] = {} + for k, v in value.items(): + key = str(k).strip() if k is not None else "" + if not key: + continue + output[key] = str(v) if v is not None else "" + return output + + @staticmethod + def _normalize_bool(value: Any, default: bool = True) -> bool: + if isinstance(value, bool): + return value + if value is None: + return default + text = str(value).strip().lower() + if text in {"1", "true", "yes", "on"}: + return True + if text in {"0", "false", "no", "off"}: + return False + return default + + @staticmethod + def _normalize_timeout(value: Any) -> int: + try: + parsed = int(float(value)) + except Exception: + parsed = MCP_DEFAULT_TIMEOUT_SECONDS + if parsed <= 0: + parsed = MCP_DEFAULT_TIMEOUT_SECONDS + return min(parsed, 300) + + @staticmethod + def _safe_slug(value: Any, fallback: str = "x") -> str: + raw = re.sub(r"[^A-Za-z0-9_-]+", "_", str(value or "")).strip("_") + if not raw: + raw = fallback + if not re.match(r"^[A-Za-z]", raw): + raw = f"x_{raw}" + return raw + + @classmethod + def make_tool_alias(cls, server_id: str, remote_tool_name: str, *, max_len: int = 64) -> str: + sid = cls._safe_slug(server_id, "server") + tid = cls._safe_slug(remote_tool_name, "tool") + prefix = f"mcp__{sid}__" + remain = max(8, max_len - len(prefix)) + return f"{prefix}{tid[:remain]}" + + @staticmethod + def build_default_mcp_category() -> Dict[str, Any]: + return { + "id": "mcp", + "label": "MCP 工具", + "tools": ["list_mcp_servers"], + "default_enabled": True, + "silent_when_disabled": False, + } + + def _ensure_file(self) -> None: + if self.config_path.exists(): + return + payload = {"servers": []} + self.config_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def _read_file(self) -> Dict[str, Any]: + self._ensure_file() + try: + data = json.loads(self.config_path.read_text(encoding="utf-8")) + if isinstance(data, dict): + return data + except Exception: + pass + return {"servers": []} + + def _write_file(self, payload: Dict[str, Any]) -> None: + self.config_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + def _normalize_server(self, item: Dict[str, Any]) -> Dict[str, Any]: + data = dict(item or {}) + server_id = str(data.get("id") or "").strip() + transport = self._normalize_transport(data.get("transport")) + timeout_seconds = self._normalize_timeout(data.get("timeout_seconds")) + include_tools = self._normalize_str_list(data.get("include_tools")) + exclude_tools = self._normalize_str_list(data.get("exclude_tools")) + + normalized = { + "id": server_id, + "name": str(data.get("name") or server_id), + "description": str(data.get("description") or ""), + "enabled": self._normalize_bool(data.get("enabled"), True), + "transport": transport, + "command": str(data.get("command") or "").strip(), + "args": self._normalize_str_list(data.get("args")), + "cwd": str(data.get("cwd") or "").strip(), + "env": self._normalize_str_dict(data.get("env")), + "url": str(data.get("url") or "").strip(), + "headers": self._normalize_str_dict(data.get("headers")), + "timeout_seconds": timeout_seconds, + "include_tools": include_tools, + "exclude_tools": exclude_tools, + "tools_cache": data.get("tools_cache") if isinstance(data.get("tools_cache"), list) else [], + "tools_cache_updated_at": str(data.get("tools_cache_updated_at") or ""), + "last_error": str(data.get("last_error") or ""), + } + + return normalized + + def _validate_server(self, item: Dict[str, Any]) -> Tuple[bool, str]: + server_id = item.get("id") + if not self._is_valid_id(server_id): + return False, "服务 ID 不合法:需以字母开头,可包含字母、数字、下划线、短横线" + transport = item.get("transport") + if transport not in self.TRANSPORTS: + return False, f"不支持的 transport: {transport}(支持: {sorted(self.TRANSPORTS)})" + if transport == "stdio" and not item.get("command"): + return False, "stdio 传输必须提供 command" + if transport == "streamable_http" and not item.get("url"): + return False, "streamable_http 传输必须提供 url" + return True, "" + + # ------------------------------------------------------------------ + # 对外接口 + # ------------------------------------------------------------------ + def reload(self) -> List[Dict[str, Any]]: + data = self._read_file() + servers = data.get("servers") + output: List[Dict[str, Any]] = [] + if isinstance(servers, list): + for item in servers: + if not isinstance(item, dict): + continue + normalized = self._normalize_server(item) + if not normalized.get("id"): + continue + output.append(normalized) + self._cache = output + return list(self._cache) + + def list_servers(self, include_disabled: bool = True) -> List[Dict[str, Any]]: + if include_disabled: + return [dict(item) for item in self._cache] + return [dict(item) for item in self._cache if item.get("enabled")] + + def list_enabled_servers(self) -> List[Dict[str, Any]]: + return self.list_servers(include_disabled=False) + + def get_server(self, server_id: str) -> Optional[Dict[str, Any]]: + target = str(server_id or "").strip() + for item in self._cache: + if item.get("id") == target: + return dict(item) + return None + + def upsert_server(self, payload: Dict[str, Any]) -> Dict[str, Any]: + normalized = self._normalize_server(payload or {}) + ok, error = self._validate_server(normalized) + if not ok: + raise ValueError(error) + + current = {item["id"]: item for item in self._cache if item.get("id")} + existing = current.get(normalized["id"]) or {} + if existing: + # 保留历史缓存字段,除非显式覆盖 + if not normalized.get("tools_cache"): + normalized["tools_cache"] = existing.get("tools_cache") or [] + if not normalized.get("tools_cache_updated_at"): + normalized["tools_cache_updated_at"] = existing.get("tools_cache_updated_at") or "" + if not normalized.get("last_error"): + normalized["last_error"] = existing.get("last_error") or "" + current[normalized["id"]] = normalized + updated = sorted(current.values(), key=lambda x: str(x.get("id"))) + self._cache = updated + self._write_file({"servers": updated}) + return dict(normalized) + + def delete_server(self, server_id: str) -> bool: + target = str(server_id or "").strip() + if not target: + return False + before = len(self._cache) + after = [item for item in self._cache if item.get("id") != target] + if len(after) == before: + return False + self._cache = after + self._write_file({"servers": after}) + return True + + def update_tools_cache( + self, + server_id: str, + *, + tools: Optional[List[Dict[str, Any]]] = None, + error: Optional[str] = None, + touched: bool = True, + ) -> Optional[Dict[str, Any]]: + target = str(server_id or "").strip() + if not target: + return None + changed = False + updated: List[Dict[str, Any]] = [] + matched: Optional[Dict[str, Any]] = None + for item in self._cache: + if item.get("id") != target: + updated.append(item) + continue + next_item = dict(item) + if tools is not None: + next_item["tools_cache"] = tools + changed = True + if error is not None: + next_item["last_error"] = str(error) + changed = True + if touched: + next_item["tools_cache_updated_at"] = time.strftime("%Y-%m-%d %H:%M:%S") + changed = True + updated.append(next_item) + matched = next_item + if changed: + self._cache = updated + self._write_file({"servers": updated}) + return dict(matched) if matched else None + + def build_cached_alias_list(self, server_id: Optional[str] = None) -> List[str]: + aliases: List[str] = [] + seen = set() + targets = self._cache + if server_id: + targets = [item for item in self._cache if item.get("id") == server_id] + for server in targets: + if not server.get("enabled", True): + continue + sid = server.get("id") + if not sid: + continue + include_tools = set(server.get("include_tools") or []) + exclude_tools = set(server.get("exclude_tools") or []) + tools = server.get("tools_cache") or [] + for tool in tools: + name = str((tool or {}).get("name") or "").strip() + if not name: + continue + if include_tools and name not in include_tools: + continue + if name in exclude_tools: + continue + alias = self.make_tool_alias(sid, name) + if alias in seen: + continue + seen.add(alias) + aliases.append(alias) + return aliases + + +def build_default_mcp_category() -> Dict[str, Any]: + return MCPServerRegistry.build_default_mcp_category() diff --git a/prompts/main_system.txt b/prompts/main_system.txt index 0248a3c..5fb4a44 100644 --- a/prompts/main_system.txt +++ b/prompts/main_system.txt @@ -18,6 +18,7 @@ | **代码开发** | 编写、调试、重构代码;代码审查;技术方案设计 | | **数据处理** | 处理表格、分析数据、生成图表和报告 | | **信息检索** | 网络搜索(`web_search`)、网页内容提取(`extract_webpage`、`save_webpage`)、信息整合 | +| **MCP扩展** | 通过 MCP 服务扩展外部工具;可用 `list_mcp_servers` 查看已配置服务与工具别名 | | **文件管理** | 批量处理文件、目录操作、自动化任务 | | **终端操作** | 执行命令(`run_command`)、运行Python代码(`run_python`)、持久终端会话(`terminal_session` 系列) | | **视觉理解** | 分析图片内容、识别文字/物体/表格(`vlm_analyze`/`view_image`) | @@ -116,6 +117,12 @@ - `extract_webpage`:提取网页正文 - `save_webpage`:保存网页为文本 +### 3.6 MCP 工具扩展 + +- 当你不确定当前有哪些 MCP 工具可用时,先调用 `list_mcp_servers` +- 需要刷新远程工具缓存时,可使用 `list_mcp_servers` 并传 `refresh=true` +- 调用具体 MCP 工具时,使用工具列表中的 `mcp__...` 别名 + --- ## 4. 禁止做的事情 diff --git a/prompts/main_system_qwenvl.txt b/prompts/main_system_qwenvl.txt index 3e55079..d46e047 100644 --- a/prompts/main_system_qwenvl.txt +++ b/prompts/main_system_qwenvl.txt @@ -18,6 +18,7 @@ | **代码开发** | 编写、调试、重构代码;代码审查;技术方案设计 | | **数据处理** | 处理表格、分析数据、生成图表和报告 | | **信息检索** | 网络搜索(`web_search`)、网页内容提取(`extract_webpage`、`save_webpage`)、信息整合 | +| **MCP扩展** | 通过 MCP 服务扩展外部工具;可用 `list_mcp_servers` 查看已配置服务与工具别名 | | **文件管理** | 批量处理文件、目录操作、自动化任务 | | **终端操作** | 执行命令(`run_command`)、运行Python代码(`run_python`)、持久终端会话(`terminal_session` 系列) | | **视觉理解** | 自带多模态能力,可直接分析图片内容、识别文字/物体/表格/场景 | @@ -134,6 +135,12 @@ - `extract_webpage`:提取网页正文 - `save_webpage`:保存网页为文本 +### 3.6 MCP 工具扩展 + +- 当你不确定当前有哪些 MCP 工具可用时,先调用 `list_mcp_servers` +- 需要刷新远程工具缓存时,可使用 `list_mcp_servers` 并传 `refresh=true` +- 调用具体 MCP 工具时,使用工具列表中的 `mcp__...` 别名 + --- ## 4. 禁止做的事情 diff --git a/scripts/mcp_calculator_server.py b/scripts/mcp_calculator_server.py new file mode 100644 index 0000000..beb7795 --- /dev/null +++ b/scripts/mcp_calculator_server.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +"""测试用 MCP 计算器服务(stdio)。 + +提供工具: +- calculator: 基础四则运算 + 幂运算 + 取模 +""" + +from __future__ import annotations + +import json +import math +import sys +from typing import Any, Dict, Tuple + +PROTOCOL_VERSION = "2025-06-18" + + +def _send(payload: Dict[str, Any]) -> None: + sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def _ok(msg_id: Any, result: Dict[str, Any]) -> None: + _send({"jsonrpc": "2.0", "id": msg_id, "result": result}) + + +def _err(msg_id: Any, code: int, message: str) -> None: + _send({"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}) + + +def _tools_list() -> Dict[str, Any]: + return { + "tools": [ + { + "name": "calculator", + "description": "简单计算器:支持 add/sub/mul/div/pow/mod", + "inputSchema": { + "type": "object", + "properties": { + "operation": { + "type": "string", + "enum": ["add", "sub", "mul", "div", "pow", "mod"], + "description": "运算类型", + }, + "a": {"type": "number", "description": "左操作数"}, + "b": {"type": "number", "description": "右操作数"}, + }, + "required": ["operation", "a", "b"], + }, + } + ] + } + + +def _parse_operands(arguments: Dict[str, Any]) -> Tuple[float, float]: + a = float((arguments or {}).get("a")) + b = float((arguments or {}).get("b")) + if math.isinf(a) or math.isinf(b) or math.isnan(a) or math.isnan(b): + raise ValueError("a/b 不能是 NaN 或 Infinity") + return a, b + + +def _calculate(operation: str, a: float, b: float) -> float: + if operation == "add": + return a + b + if operation == "sub": + return a - b + if operation == "mul": + return a * b + if operation == "div": + if b == 0: + raise ZeroDivisionError("除数不能为 0") + return a / b + if operation == "pow": + return a**b + if operation == "mod": + if b == 0: + raise ZeroDivisionError("取模除数不能为 0") + return a % b + raise ValueError(f"不支持的 operation: {operation}") + + +def _call_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + if name != "calculator": + return { + "isError": True, + "content": [{"type": "text", "text": f"未知工具: {name}"}], + "structuredContent": {"error": "tool_not_found", "name": name}, + } + try: + operation = str((arguments or {}).get("operation") or "").strip().lower() + a, b = _parse_operands(arguments or {}) + result = _calculate(operation, a, b) + except Exception as exc: + return { + "isError": True, + "content": [{"type": "text", "text": f"计算失败: {exc}"}], + "structuredContent": {"error": "invalid_arguments", "detail": str(exc)}, + } + return { + "isError": False, + "content": [{"type": "text", "text": f"{operation}({a}, {b}) = {result}"}], + "structuredContent": {"operation": operation, "a": a, "b": b, "result": result}, + } + + +def main() -> int: + while True: + line = sys.stdin.readline() + if not line: + break + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except Exception: + continue + + method = req.get("method") + msg_id = req.get("id") + params = req.get("params") or {} + + # 通知不需要响应 + if msg_id is None: + continue + + if method == "initialize": + _ok( + msg_id, + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "calculator-stdio-mcp", "version": "1.0.0"}, + }, + ) + continue + + if method == "tools/list": + _ok(msg_id, _tools_list()) + continue + + if method == "tools/call": + name = str(params.get("name") or "").strip() + arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} + _ok(msg_id, _call_tool(name, arguments)) + continue + + _err(msg_id, -32601, f"Method not found: {method}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/scripts/mcp_demo_server.py b/scripts/mcp_demo_server.py new file mode 100644 index 0000000..68f8182 --- /dev/null +++ b/scripts/mcp_demo_server.py @@ -0,0 +1,140 @@ +#!/usr/bin/env python3 +"""最小可运行 MCP stdio 示例服务。 + +功能: +- tools/list: 返回 echo_upper / add_numbers 两个工具 +- tools/call: 执行对应逻辑并返回 text + structuredContent +""" + +from __future__ import annotations + +import json +import sys +from typing import Any, Dict + +PROTOCOL_VERSION = "2025-06-18" + + +def _send(payload: Dict[str, Any]) -> None: + sys.stdout.write(json.dumps(payload, ensure_ascii=False) + "\n") + sys.stdout.flush() + + +def _ok(msg_id: Any, result: Dict[str, Any]) -> None: + _send({"jsonrpc": "2.0", "id": msg_id, "result": result}) + + +def _err(msg_id: Any, code: int, message: str) -> None: + _send({"jsonrpc": "2.0", "id": msg_id, "error": {"code": code, "message": message}}) + + +def _tools_list() -> Dict[str, Any]: + return { + "tools": [ + { + "name": "echo_upper", + "description": "将 text 转为大写后返回", + "inputSchema": { + "type": "object", + "properties": {"text": {"type": "string", "description": "输入文本"}}, + "required": ["text"], + }, + }, + { + "name": "add_numbers", + "description": "计算两个数字相加", + "inputSchema": { + "type": "object", + "properties": { + "a": {"type": "number", "description": "加数A"}, + "b": {"type": "number", "description": "加数B"}, + }, + "required": ["a", "b"], + }, + }, + ] + } + + +def _call_tool(name: str, arguments: Dict[str, Any]) -> Dict[str, Any]: + if name == "echo_upper": + text = str((arguments or {}).get("text") or "") + result = text.upper() + return { + "isError": False, + "content": [{"type": "text", "text": result}], + "structuredContent": {"result": result}, + } + if name == "add_numbers": + try: + a = float((arguments or {}).get("a")) + b = float((arguments or {}).get("b")) + except Exception: + return { + "isError": True, + "content": [{"type": "text", "text": "参数 a/b 必须是数字"}], + "structuredContent": {"error": "invalid_arguments"}, + } + value = a + b + return { + "isError": False, + "content": [{"type": "text", "text": f"{a} + {b} = {value}"}], + "structuredContent": {"a": a, "b": b, "sum": value}, + } + return { + "isError": True, + "content": [{"type": "text", "text": f"未知工具: {name}"}], + "structuredContent": {"error": "tool_not_found", "name": name}, + } + + +def main() -> int: + while True: + line = sys.stdin.readline() + if not line: + break + line = line.strip() + if not line: + continue + try: + req = json.loads(line) + except Exception: + continue + + method = req.get("method") + msg_id = req.get("id") + params = req.get("params") or {} + + # 通知无需响应 + if msg_id is None: + continue + + if method == "initialize": + _ok( + msg_id, + { + "protocolVersion": PROTOCOL_VERSION, + "capabilities": {"tools": {"listChanged": False}}, + "serverInfo": {"name": "demo-stdio-mcp", "version": "1.0.0"}, + }, + ) + continue + + if method == "tools/list": + _ok(msg_id, _tools_list()) + continue + + if method == "tools/call": + name = str(params.get("name") or "").strip() + arguments = params.get("arguments") if isinstance(params.get("arguments"), dict) else {} + _ok(msg_id, _call_tool(name, arguments)) + continue + + _err(msg_id, -32601, f"Method not found: {method}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) + diff --git a/server/admin.py b/server/admin.py index 2fb1300..9befe0b 100644 --- a/server/admin.py +++ b/server/admin.py @@ -8,10 +8,18 @@ from flask import Blueprint, jsonify, send_from_directory, request, current_app, from werkzeug.security import check_password_hash from .auth_helpers import login_required, admin_required, admin_api_required, api_login_required, get_current_user_record, resolve_admin_policy +from .context import with_terminal from .security import rate_limited from .utils_common import debug_log from . import state # 使用动态 state,确保与入口实例保持一致 -from .state import custom_tool_registry, user_manager, container_manager, PROJECT_MAX_STORAGE_MB +from .state import ( + custom_tool_registry, + mcp_server_registry, + mcp_client_manager, + user_manager, + container_manager, + PROJECT_MAX_STORAGE_MB, +) from .conversation import ( build_admin_dashboard_snapshot as _build_dashboard_rich, compute_workspace_storage, @@ -513,6 +521,72 @@ def admin_custom_tools_reload_api(): return jsonify({"success": False, "error": str(exc)}), 500 +@admin_bp.route('/api/admin/mcp-servers', methods=['GET', 'POST', 'DELETE']) +@api_login_required +@admin_api_required +def admin_mcp_servers_api(): + """MCP 服务配置管理(仅全局管理员)。""" + guard = _secondary_required() + if guard: + return guard + try: + if request.method == 'GET': + mcp_server_registry.reload() + return jsonify({"success": True, "data": mcp_server_registry.list_servers()}) + if request.method == 'POST': + payload = request.get_json() or {} + saved = mcp_server_registry.upsert_server(payload) + return jsonify({"success": True, "data": saved}) + + # DELETE + server_id = request.args.get("id") or (request.get_json() or {}).get("id") + if not server_id: + return jsonify({"success": False, "error": "缺少 id"}), 400 + removed = mcp_server_registry.delete_server(server_id) + if removed: + return jsonify({"success": True, "data": {"deleted": server_id}}) + return jsonify({"success": False, "error": "未找到该服务"}), 404 + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception as exc: + logging.exception("mcp-servers API error") + return jsonify({"success": False, "error": str(exc)}), 500 + + +@admin_bp.route('/api/admin/mcp-servers/reload', methods=['POST']) +@api_login_required +@admin_api_required +def admin_mcp_servers_reload_api(): + guard = _secondary_required() + if guard: + return guard + try: + servers = mcp_server_registry.reload() + return jsonify({"success": True, "data": {"count": len(servers)}}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + +@admin_bp.route('/api/admin/mcp-servers/sync', methods=['POST']) +@api_login_required +@admin_api_required +@with_terminal +def admin_mcp_servers_sync_api(terminal, workspace, username): + """同步 MCP 服务工具缓存(tools/list)。""" + guard = _secondary_required() + if guard: + return guard + payload = request.get_json() or {} + target_id = (payload.get("id") or "").strip() or None + manager = getattr(terminal, "mcp_client_manager", None) or mcp_client_manager + try: + result = manager.sync_servers(server_id=target_id) + return jsonify({"success": bool(result.get("success", True)), "data": result}) + except Exception as exc: + logging.exception("mcp-servers sync API error") + return jsonify({"success": False, "error": str(exc)}), 500 + + # ------------------ API 用户与监控 ------------------ # @admin_bp.route('/api/admin/api-dashboard', methods=['GET']) @api_login_required diff --git a/server/state.py b/server/state.py index e4a59a6..647374c 100644 --- a/server/state.py +++ b/server/state.py @@ -9,6 +9,8 @@ from typing import Dict, Any, Optional from config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB from core.web_terminal import WebTerminal from modules.custom_tool_registry import CustomToolRegistry +from modules.mcp_server_registry import MCPServerRegistry +from modules.mcp_client_manager import MCPClientManager from modules.usage_tracker import UsageTracker from modules.user_container_manager import UserContainerManager from modules.user_manager import UserManager @@ -19,6 +21,8 @@ from modules.tool_approval_manager import ToolApprovalManager user_manager = UserManager() api_user_manager = ApiUserManager() custom_tool_registry = CustomToolRegistry() +mcp_server_registry = MCPServerRegistry() +mcp_client_manager = MCPClientManager(mcp_server_registry) container_manager = UserContainerManager() user_terminals: Dict[str, WebTerminal] = {} terminal_rooms: Dict[str, set] = {} @@ -76,6 +80,8 @@ __all__ = [ "user_manager", "api_user_manager", "custom_tool_registry", + "mcp_server_registry", + "mcp_client_manager", "container_manager", "user_terminals", "terminal_rooms", diff --git a/static/icons/mcp-logo.svg b/static/icons/mcp-logo.svg new file mode 100644 index 0000000..03d9f85 --- /dev/null +++ b/static/icons/mcp-logo.svg @@ -0,0 +1,12 @@ + + + + + + + + + + + + diff --git a/static/src/admin/PolicyApp.vue b/static/src/admin/PolicyApp.vue index fa614c9..ee798ff 100644 --- a/static/src/admin/PolicyApp.vue +++ b/static/src/admin/PolicyApp.vue @@ -207,6 +207,108 @@ +
+
+

MCP 服务配置(统一工具扩展)

+
+ + + +
+
+
暂无 MCP 服务,点击“新增 MCP 服务”开始配置。
+
+
+
+ + + + + + + + + + + + + + +
+
+ 最近同步:{{ server.tools_cache_updated_at || '未同步' }} + 错误:{{ server.last_error }} + 缓存工具数:{{ server.tools_cache_count }} +
+
+ + + +
+
+
+
+

已删除分类

@@ -253,6 +355,26 @@ interface CategoryFormItem { forced: boolean | null; } +interface MCPServerFormItem { + id: string; + name: string; + description: string; + transport: 'stdio' | 'streamable_http'; + enabledText: 'true' | 'false'; + command: string; + argsText: string; + cwd: string; + url: string; + headersText: string; + envText: string; + timeoutText: string; + includeToolsText: string; + excludeToolsText: string; + tools_cache_updated_at: string; + last_error: string; + tools_cache_count: number; +} + const defaults = reactive({ categories: {} as Record, models: [] as string[], @@ -283,6 +405,9 @@ const form = reactive({ const lastUpdated = ref(null); const saving = ref(false); const policyCache = ref(null); +const mcpServers = ref([]); +const mcpLoading = ref(false); +const mcpSyncing = ref(false); const targetPlaceholder = computed(() => { if (form.target_type === 'role') return '如:admin / user'; @@ -534,6 +659,204 @@ function rebuildCategoryOverrides() { form.config.category_overrides = map; } +function splitCsv(value: string): string[] { + return (value || '') + .split(',') + .map((item) => item.trim()) + .filter(Boolean); +} + +function linesToList(value: string): string[] { + return (value || '') + .split('\n') + .map((item) => item.trim()) + .filter(Boolean); +} + +function parseJsonDict(label: string, raw: string): Record { + const text = (raw || '').trim(); + if (!text) return {}; + let parsed: any; + try { + parsed = JSON.parse(text); + } catch (error: any) { + throw new Error(`${label} 不是合法 JSON:${error?.message || error}`); + } + if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${label} 必须是 JSON 对象`); + } + const output: Record = {}; + Object.entries(parsed).forEach(([k, v]) => { + const key = String(k || '').trim(); + if (!key) return; + output[key] = String(v ?? ''); + }); + return output; +} + +function mapMcpServer(raw: any): MCPServerFormItem { + const transport = raw?.transport === 'streamable_http' ? 'streamable_http' : 'stdio'; + const args = Array.isArray(raw?.args) ? raw.args.filter((x: any) => typeof x === 'string') : []; + const toolsCache = Array.isArray(raw?.tools_cache) ? raw.tools_cache : []; + return { + id: String(raw?.id || ''), + name: String(raw?.name || raw?.id || ''), + description: String(raw?.description || ''), + transport, + enabledText: raw?.enabled === false ? 'false' : 'true', + command: String(raw?.command || ''), + argsText: args.join('\n'), + cwd: String(raw?.cwd || ''), + url: String(raw?.url || ''), + headersText: raw?.headers && typeof raw.headers === 'object' ? JSON.stringify(raw.headers, null, 2) : '', + envText: raw?.env && typeof raw.env === 'object' ? JSON.stringify(raw.env, null, 2) : '', + timeoutText: String(raw?.timeout_seconds ?? 25), + includeToolsText: Array.isArray(raw?.include_tools) ? raw.include_tools.join(',') : '', + excludeToolsText: Array.isArray(raw?.exclude_tools) ? raw.exclude_tools.join(',') : '', + tools_cache_updated_at: String(raw?.tools_cache_updated_at || ''), + last_error: String(raw?.last_error || ''), + tools_cache_count: toolsCache.length + }; +} + +function normalizeMcpPayload(server: MCPServerFormItem) { + const id = server.id.trim(); + if (!id) throw new Error('MCP 服务 ID 不能为空'); + return { + id, + name: server.name.trim() || id, + description: server.description.trim(), + transport: server.transport, + enabled: server.enabledText === 'true', + command: server.command.trim(), + args: linesToList(server.argsText), + cwd: server.cwd.trim(), + url: server.url.trim(), + headers: parseJsonDict('headers', server.headersText), + env: parseJsonDict('env', server.envText), + timeout_seconds: Number(server.timeoutText || 25), + include_tools: splitCsv(server.includeToolsText), + exclude_tools: splitCsv(server.excludeToolsText) + }; +} + +async function fetchMcpServers() { + if (!secondaryVerified.value) return; + mcpLoading.value = true; + try { + const resp = await fetch('/api/admin/mcp-servers', { credentials: 'same-origin' }); + const data = await resp.json(); + if (!resp.ok || !data.success) { + throw new Error(data.error || '加载 MCP 服务失败'); + } + mcpServers.value = (data.data || []).map((item: any) => mapMcpServer(item)); + } catch (error: any) { + banner.message = error?.message || '加载 MCP 服务失败'; + banner.type = 'error'; + } finally { + mcpLoading.value = false; + } +} + +function addMcpServer() { + mcpServers.value.unshift( + mapMcpServer({ + id: '', + name: '', + transport: 'stdio', + enabled: true, + timeout_seconds: 25 + }) + ); +} + +async function saveMcpServer(server: MCPServerFormItem) { + if (!secondaryVerified.value) return; + try { + const payload = normalizeMcpPayload(server); + const resp = await fetch('/api/admin/mcp-servers', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const data = await resp.json(); + if (!resp.ok || !data.success) { + throw new Error(data.error || '保存 MCP 服务失败'); + } + banner.message = `MCP 服务 ${payload.id} 已保存`; + banner.type = 'success'; + await syncMcpServer(payload.id, true); + } catch (error: any) { + banner.message = error?.message || '保存 MCP 服务失败'; + banner.type = 'error'; + } +} + +async function deleteMcpServer(id: string, idx: number) { + if (!secondaryVerified.value) return; + const target = String(id || '').trim(); + if (!target) { + mcpServers.value.splice(idx, 1); + return; + } + try { + const resp = await fetch(`/api/admin/mcp-servers?id=${encodeURIComponent(target)}`, { + method: 'DELETE', + credentials: 'same-origin' + }); + const data = await resp.json(); + if (!resp.ok || !data.success) { + throw new Error(data.error || '删除 MCP 服务失败'); + } + banner.message = `已删除 MCP 服务 ${target}`; + banner.type = 'success'; + await fetchMcpServers(); + await fetchDefaults(); + } catch (error: any) { + banner.message = error?.message || '删除 MCP 服务失败'; + banner.type = 'error'; + } +} + +async function syncMcpServer(id?: string, silent = false) { + if (!secondaryVerified.value) return; + const target = String(id || '').trim(); + if (typeof id !== 'undefined' && !target) { + banner.message = '请先保存服务后再同步工具'; + banner.type = 'error'; + return; + } + if (!silent) mcpSyncing.value = true; + try { + const resp = await fetch('/api/admin/mcp-servers/sync', { + method: 'POST', + credentials: 'same-origin', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(target ? { id: target } : {}) + }); + const data = await resp.json(); + if (!resp.ok || !data.success) { + throw new Error(data.error || '同步 MCP 工具失败'); + } + if (!silent) { + banner.message = target ? `已同步 ${target}` : '已同步全部 MCP 服务'; + banner.type = 'success'; + } + await fetchMcpServers(); + await fetchDefaults(); + } catch (error: any) { + banner.message = error?.message || '同步 MCP 工具失败'; + banner.type = 'error'; + } finally { + if (!silent) mcpSyncing.value = false; + } +} + +async function syncAllMcpServers() { + await syncMcpServer(); +} + async function fetchDefaults() { if (!secondaryVerified.value) return; const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' }); @@ -547,7 +870,6 @@ async function fetchDefaults() { policyCache.value = data.data; lastUpdated.value = data.data?.updated_at || null; applyScopeConfig(); - banner.message = ''; } function scopeConfig(): any { @@ -626,7 +948,7 @@ const handleVerifySecondary = async () => { await verifySecondary(secondaryPassword.value); if (secondaryVerified.value) { secondaryPassword.value = ''; - fetchDefaults().catch((err) => { + Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => { console.error(err); banner.message = err?.message || '加载策略失败'; banner.type = 'error'; @@ -638,7 +960,7 @@ onMounted(async () => { document.addEventListener('click', handleDocClick); await checkSecondary(); if (secondaryVerified.value) { - fetchDefaults().catch((err) => { + Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => { console.error(err); banner.message = err?.message || '加载策略失败'; banner.type = 'error'; @@ -648,7 +970,7 @@ onMounted(async () => { watch(secondaryVerified, (val) => { if (val) { - fetchDefaults().catch((err) => { + Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => { console.error(err); banner.message = err?.message || '加载策略失败'; banner.type = 'error'; @@ -789,6 +1111,67 @@ onBeforeUnmount(() => { box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4); } +.mcp-list { + display: flex; + flex-direction: column; + gap: 12px; +} + +.mcp-item { + border: 1px solid rgba(118, 103, 84, 0.2); + border-radius: 14px; + padding: 12px; + background: rgba(255, 255, 255, 0.92); +} + +.mcp-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 10px; +} + +.mcp-grid label { + display: flex; + flex-direction: column; + gap: 6px; + color: #5b4d3b; + font-size: 13px; +} + +.mcp-grid label.wide { + grid-column: 1 / -1; +} + +.mcp-grid textarea, +.mcp-grid input, +.mcp-grid select { + width: 100%; + box-sizing: border-box; + padding: 8px 10px; + border-radius: 10px; + border: 1px solid rgba(0, 0, 0, 0.12); + background: #fff; +} + +.mcp-meta { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 8px; + color: #8a7a62; + font-size: 12px; +} + +.mcp-actions { + margin-top: 8px; + display: flex; + gap: 8px; +} + +.error-text { + color: #b05b3c; +} + .panel-title { display: flex; align-items: center; diff --git a/static/src/components/personalization/PersonalizationDrawer.vue b/static/src/components/personalization/PersonalizationDrawer.vue index ddcdccd..89fa195 100644 --- a/static/src/components/personalization/PersonalizationDrawer.vue +++ b/static/src/components/personalization/PersonalizationDrawer.vue @@ -1378,6 +1378,40 @@
+
+
+
+

MCP 配置

+
+

MCP 服务器管理

+ 宿主机模式 · 管理员 +
+

+ 统一配置 MCP Server,支持新增、修改、同步与重载,集中管理工具扩展能力。 +

+
+
+ 统一配置 + 工具同步 + 即时生效 + 仅宿主机管理员 +
+
+ +

新窗口打开 /admin/policy 页面。

+
+
+
(resourceStore.usageQuota.role || '').toLowerCase() === 'admin'); +const sessionRole = ref(''); +const sessionHostMode = ref(false); + +const isAdmin = computed(() => { + const quotaRole = (resourceStore.usageQuota.role || '').toLowerCase(); + const loginRole = (sessionRole.value || '').toLowerCase(); + return quotaRole === 'admin' || loginRole === 'admin'; +}); +const isHostMode = computed(() => { + const mode = (resourceStore.containerStatus?.mode || '').toLowerCase(); + if (mode) { + return mode === 'host'; + } + return sessionHostMode.value; +}); +const showMcpConfigEntry = computed(() => isAdmin.value && isHostMode.value); const isAppShell = computed(() => { if (typeof window === 'undefined') return false; const params = new URLSearchParams(window.location.search); @@ -1533,6 +1583,9 @@ const isAppShell = computed(() => { const personalTabs = computed(() => { const tabs = baseTabs.filter((tab) => (tab.id === 'app-update' ? isAppShell.value : true)); + if (showMcpConfigEntry.value) { + tabs.push({ id: 'mcp-config' as const, label: 'MCP 配置', description: 'Server 管理' }); + } if (isAdmin.value) { tabs.push({ id: 'admin-monitor' as const, label: '管理员监控', description: '系统总览' }); } @@ -1805,8 +1858,18 @@ const downloadLatestApp = () => { window.location.href = url; }; -onMounted(() => { +onMounted(async () => { hydrateAppVersionFromBridge(); + try { + const resp = await fetch('/api/session-status', { credentials: 'same-origin' }); + if (!resp.ok) return; + const payload = await resp.json(); + const snapshot = payload?.session || {}; + sessionRole.value = String(snapshot.role || ''); + sessionHostMode.value = !!snapshot.host_mode; + } catch (_err) { + sessionRole.value = ''; + } }); watch( @@ -1999,6 +2062,11 @@ const openApiAdmin = () => { personalization.closeDrawer(); }; +const openMcpConfig = () => { + window.open('/admin/policy', '_blank', 'noopener'); + personalization.closeDrawer(); +}; + // ===== 主题切换 ===== const { setTheme, loadTheme } = useTheme(); const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [ diff --git a/static/src/utils/chatDisplay.ts b/static/src/utils/chatDisplay.ts index 62d3245..ade7ead 100644 --- a/static/src/utils/chatDisplay.ts +++ b/static/src/utils/chatDisplay.ts @@ -94,6 +94,9 @@ const RELATIVE_TIME_RANGE_MAP: Record = { export function getToolIcon(tool: any): string { const toolName = typeof tool === 'string' ? tool : tool?.name; + if (typeof toolName === 'string' && toolName.startsWith('mcp__')) { + return 'mcpLogo'; + } return TOOL_ICON_MAP[toolName as keyof typeof TOOL_ICON_MAP] || 'settings'; } diff --git a/static/src/utils/icons.ts b/static/src/utils/icons.ts index 33cb249..e37fdf9 100644 --- a/static/src/utils/icons.ts +++ b/static/src/utils/icons.ts @@ -21,6 +21,7 @@ export const ICONS = Object.freeze({ layers: '/static/icons/layers.svg', keyboard: '/static/icons/keyboard.svg', menu: '/static/icons/menu.svg', + mcpLogo: '/static/icons/mcp-logo.svg', monitor: '/static/icons/monitor.svg', octagon: '/static/icons/octagon.svg', pencil: '/static/icons/pencil.svg', @@ -71,12 +72,14 @@ export const TOOL_ICON_MAP = Object.freeze({ get_sub_agent_status: 'bot', web_search: 'search', trigger_easter_egg: 'sparkles', + list_mcp_servers: 'mcpLogo', view_image: 'camera', view_video: 'eye' }); export const TOOL_CATEGORY_ICON_MAP = Object.freeze({ network: 'globe', + mcp: 'mcpLogo', file_edit: 'pencil', personalization: 'userPen', read_focus: 'eye', diff --git a/test/test_mcp_integration.py b/test/test_mcp_integration.py new file mode 100644 index 0000000..afc97b8 --- /dev/null +++ b/test/test_mcp_integration.py @@ -0,0 +1,257 @@ +from __future__ import annotations + +import asyncio +import json +import sys +import tempfile +import unittest +from pathlib import Path +from types import SimpleNamespace + +from core.main_terminal import MainTerminal +from modules.mcp_client_manager import MCPClientManager, _StdioMCPClient +from modules.mcp_server_registry import MCPServerRegistry + + +class MCPIntegrationTest(unittest.TestCase): + def setUp(self): + self.temp_dir = tempfile.TemporaryDirectory() + root = Path(self.temp_dir.name) + self.project_dir = root / "project" + self.project_dir.mkdir(parents=True, exist_ok=True) + self.data_dir = root / "data" + self.data_dir.mkdir(parents=True, exist_ok=True) + self.registry_path = self.data_dir / "mcp_servers.json" + + self.registry = MCPServerRegistry(config_path=str(self.registry_path), enabled=True) + self.manager = MCPClientManager(self.registry) + + demo_script = Path(__file__).resolve().parents[1] / "scripts" / "mcp_demo_server.py" + self.assertTrue(demo_script.exists(), f"missing demo script: {demo_script}") + self.demo_script = demo_script + + self.registry.upsert_server( + { + "id": "demo_stdio", + "name": "Demo MCP", + "transport": "stdio", + "enabled": True, + "command": sys.executable, + "args": [str(self.demo_script)], + "timeout_seconds": 10, + } + ) + + def tearDown(self): + self.temp_dir.cleanup() + + def test_manager_discover_and_call(self): + sync_result = self.manager.sync_servers(server_id="demo_stdio") + self.assertTrue(sync_result.get("success"), sync_result) + self.assertGreaterEqual(sync_result.get("tools_count", 0), 2, sync_result) + + tools, alias_map = self.manager.build_openai_tools(ensure_discovery=False) + self.assertTrue(tools) + + echo_alias = None + for alias, binding in alias_map.items(): + if binding.remote_name == "echo_upper": + echo_alias = alias + break + self.assertTrue(echo_alias, f"missing echo_upper alias: {list(alias_map.keys())}") + + call_result = self.manager.call_tool_by_alias( + echo_alias, + {"text": "hello mcp"}, + alias_map=alias_map, + ) + self.assertTrue(call_result.get("success"), call_result) + payload = json.dumps(call_result, ensure_ascii=False) + self.assertIn("HELLO MCP", payload) + + def test_main_terminal_tool_execution(self): + sync_result = self.manager.sync_servers(server_id="demo_stdio") + self.assertTrue(sync_result.get("success"), sync_result) + + terminal = MainTerminal( + project_path=str(self.project_dir), + thinking_mode=False, + data_dir=str(self.data_dir), + ) + terminal.mcp_tools_enabled = True + terminal.mcp_server_registry = self.registry + terminal.mcp_client_manager = self.manager + + tool_defs = terminal.define_tools() + alias = None + for item in tool_defs: + name = ((item or {}).get("function") or {}).get("name") + if isinstance(name, str) and name.startswith("mcp__") and "echo_upper" in name: + alias = name + break + if not alias: + for item in tool_defs: + name = ((item or {}).get("function") or {}).get("name") + if isinstance(name, str) and name.startswith("mcp__"): + alias = name + break + self.assertTrue(alias, "no mcp alias in define_tools") + + raw = asyncio.run(terminal.handle_tool_call(alias, {"text": "abc"})) + data = json.loads(raw) + self.assertTrue(data.get("success"), data) + self.assertIn("ABC", json.dumps(data, ensure_ascii=False)) + + # 原生 MCP 列表工具 + list_raw = asyncio.run(terminal.handle_tool_call("list_mcp_servers", {"refresh": False})) + list_data = json.loads(list_raw) + self.assertTrue(list_data.get("success"), list_data) + self.assertGreaterEqual(int(list_data.get("count", 0)), 1, list_data) + + def test_main_terminal_mcp_blocked_in_docker_mode(self): + terminal = MainTerminal( + project_path=str(self.project_dir), + thinking_mode=False, + data_dir=str(self.data_dir), + ) + terminal.mcp_tools_enabled = True + terminal.mcp_server_registry = self.registry + terminal.mcp_client_manager = self.manager + terminal.container_session = SimpleNamespace(mode="docker") + + message = "当前为docker模式,MCP仅支持宿主机模式" + + list_raw = asyncio.run(terminal.handle_tool_call("list_mcp_servers", {"refresh": True})) + list_data = json.loads(list_raw) + self.assertFalse(list_data.get("success"), list_data) + self.assertEqual(list_data.get("error"), message, list_data) + + call_raw = asyncio.run(terminal.handle_tool_call("mcp__demo_stdio__echo_upper", {"text": "abc"})) + call_data = json.loads(call_raw) + self.assertFalse(call_data.get("success"), call_data) + self.assertEqual(call_data.get("error"), message, call_data) + + def test_calculator_mcp_server(self): + calculator_script = Path(__file__).resolve().parents[1] / "scripts" / "mcp_calculator_server.py" + self.assertTrue(calculator_script.exists(), f"missing calculator script: {calculator_script}") + self.registry.upsert_server( + { + "id": "calc_stdio", + "name": "Calculator MCP", + "transport": "stdio", + "enabled": True, + "command": sys.executable, + "args": [str(calculator_script)], + "timeout_seconds": 10, + } + ) + + sync_result = self.manager.sync_servers(server_id="calc_stdio") + self.assertTrue(sync_result.get("success"), sync_result) + self.assertEqual(sync_result.get("tools_count"), 1, sync_result) + + _, alias_map = self.manager.build_openai_tools(ensure_discovery=False) + calc_alias = None + for alias, binding in alias_map.items(): + if binding.server_id == "calc_stdio" and binding.remote_name == "calculator": + calc_alias = alias + break + self.assertTrue(calc_alias, f"missing calculator alias: {list(alias_map.keys())}") + + call_result = self.manager.call_tool_by_alias( + calc_alias, + {"operation": "mul", "a": 6, "b": 7}, + alias_map=alias_map, + ) + self.assertTrue(call_result.get("success"), call_result) + self.assertIn("42", json.dumps(call_result, ensure_ascii=False)) + + def test_sync_servers_reload_external_file_changes(self): + # 模拟“外部工具直接写 mcp_servers.json”,绕过 registry 内存缓存 + payload = { + "servers": [ + { + "id": "demo_stdio", + "name": "Demo MCP", + "transport": "stdio", + "enabled": True, + "command": sys.executable, + "args": [str(self.demo_script)], + "timeout_seconds": 10, + }, + { + "id": "extra_stdio", + "name": "Extra MCP", + "transport": "stdio", + "enabled": True, + "command": sys.executable, + "args": [str(self.demo_script)], + "timeout_seconds": 10, + }, + ] + } + self.registry_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8") + + sync_result = self.manager.sync_servers() + self.assertTrue(sync_result.get("success"), sync_result) + self.assertEqual(sync_result.get("servers_total"), 2, sync_result) + + by_id = {item.get("server_id"): item for item in sync_result.get("results") or []} + self.assertIn("extra_stdio", by_id, sync_result) + self.assertTrue(by_id["extra_stdio"].get("success"), sync_result) + + def test_stdio_container_path_rewrite_from_workspace(self): + fake_session = SimpleNamespace( + mode="docker", + container_name="fake-container", + mount_path="/workspace", + workspace_path=str(self.project_dir), + sandbox_bin="docker", + ) + server = { + "command": "python3", + "args": [ + str(self.project_dir / "scripts" / "demo.py"), + f"--root={self.project_dir / 'subdir'}", + ], + "cwd": str(self.project_dir), + "env": {"HELLO": "1"}, + } + client = _StdioMCPClient(server, timeout_seconds=10, protocol_version="2025-06-18", container_session=fake_session) + launch_cmd, cwd, _ = client._prepare_launch() + + self.assertTrue(str(launch_cmd[0]).endswith("docker"), launch_cmd) + self.assertEqual(launch_cmd[1:3], ["exec", "-i"], launch_cmd) + self.assertIn("-w", launch_cmd) + self.assertEqual(cwd, None) + joined = " ".join(launch_cmd) + self.assertIn("/workspace/scripts/demo.py", joined) + self.assertIn("--root=/workspace/subdir", joined) + self.assertIn("-e HELLO=1", joined) + + def test_stdio_container_command_fallback_for_host_abs_path(self): + fake_session = SimpleNamespace( + mode="docker", + container_name="fake-container", + mount_path="/workspace", + workspace_path=str(self.project_dir), + sandbox_bin="docker", + ) + server = { + "command": "/opt/homebrew/bin/npx", + "args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/jojo/Desktop"], + "cwd": "", + "env": {}, + } + client = _StdioMCPClient(server, timeout_seconds=10, protocol_version="2025-06-18", container_session=fake_session) + launch_cmd, cwd, _ = client._prepare_launch() + + self.assertEqual(cwd, None) + self.assertIn("fake-container", launch_cmd) + self.assertIn("npx", launch_cmd) + self.assertNotIn("/opt/homebrew/bin/npx", launch_cmd) + self.assertIn("/Users/jojo/Desktop", launch_cmd) + + +if __name__ == "__main__": + unittest.main()