feat: add MCP management and host-only runtime policy
This commit is contained in:
parent
e758df767f
commit
ed5315eef6
10
.env.example
10
.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
|
||||
|
||||
@ -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
|
||||
|
||||
23
config/mcp.py
Normal file
23
config/mcp.py
Normal file
@ -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",
|
||||
]
|
||||
@ -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)
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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__<server>__<tool>)
|
||||
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)
|
||||
|
||||
@ -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():
|
||||
|
||||
@ -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=[
|
||||
|
||||
199
docs/mcp-tool-config-guide.md
Normal file
199
docs/mcp-tool-config-guide.md
Normal file
@ -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__<server_id>__<tool_name>`
|
||||
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 <token>"
|
||||
},
|
||||
"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`
|
||||
@ -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())),
|
||||
|
||||
824
modules/mcp_client_manager.py
Normal file
824
modules/mcp_client_manager.py
Normal file
@ -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
|
||||
315
modules/mcp_server_registry.py
Normal file
315
modules/mcp_server_registry.py
Normal file
@ -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()
|
||||
@ -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. 禁止做的事情
|
||||
|
||||
@ -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. 禁止做的事情
|
||||
|
||||
156
scripts/mcp_calculator_server.py
Normal file
156
scripts/mcp_calculator_server.py
Normal file
@ -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())
|
||||
|
||||
140
scripts/mcp_demo_server.py
Normal file
140
scripts/mcp_demo_server.py
Normal file
@ -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())
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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",
|
||||
|
||||
12
static/icons/mcp-logo.svg
Normal file
12
static/icons/mcp-logo.svg
Normal file
@ -0,0 +1,12 @@
|
||||
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g clip-path="url(#clip0_19_13)">
|
||||
<path d="M18 84.8528L85.8822 16.9706C95.2548 7.59798 110.451 7.59798 119.823 16.9706V16.9706C129.196 26.3431 129.196 41.5391 119.823 50.9117L68.5581 102.177" stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M69.2652 101.47L119.823 50.9117C129.196 41.5391 144.392 41.5391 153.765 50.9117L154.118 51.2652C163.491 60.6378 163.491 75.8338 154.118 85.2063L92.7248 146.6C89.6006 149.724 89.6006 154.789 92.7248 157.913L105.331 170.52" stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||
<path d="M102.853 33.9411L52.6482 84.1457C43.2756 93.5183 43.2756 108.714 52.6482 118.087V118.087C62.0208 127.459 77.2167 127.459 86.5893 118.087L136.794 67.8822" stroke="black" stroke-width="12" stroke-linecap="round"/>
|
||||
</g>
|
||||
<defs>
|
||||
<clipPath id="clip0_19_13">
|
||||
<rect width="180" height="180" fill="white"/>
|
||||
</clipPath>
|
||||
</defs>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 973 B |
@ -207,6 +207,108 @@
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>MCP 服务配置(统一工具扩展)</h2>
|
||||
<div class="header-actions">
|
||||
<button type="button" class="ghost-btn" :disabled="mcpLoading" @click="fetchMcpServers">
|
||||
{{ mcpLoading ? '刷新中...' : '刷新' }}
|
||||
</button>
|
||||
<button type="button" class="ghost-btn" :disabled="mcpSyncing" @click="syncAllMcpServers">
|
||||
{{ mcpSyncing ? '同步中...' : '同步全部工具' }}
|
||||
</button>
|
||||
<button type="button" @click="addMcpServer">新增 MCP 服务</button>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!mcpServers.length" class="muted">暂无 MCP 服务,点击“新增 MCP 服务”开始配置。</div>
|
||||
<div class="mcp-list" v-else>
|
||||
<div class="mcp-item" v-for="(server, idx) in mcpServers" :key="server.id || `new-${idx}`">
|
||||
<div class="mcp-grid">
|
||||
<label>
|
||||
<span>ID</span>
|
||||
<input v-model="server.id" placeholder="如 demo_stdio" />
|
||||
</label>
|
||||
<label>
|
||||
<span>名称</span>
|
||||
<input v-model="server.name" placeholder="显示名称(可选)" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Transport</span>
|
||||
<select v-model="server.transport">
|
||||
<option value="stdio">stdio</option>
|
||||
<option value="streamable_http">streamable_http</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>启用</span>
|
||||
<select v-model="server.enabledText">
|
||||
<option value="true">true</option>
|
||||
<option value="false">false</option>
|
||||
</select>
|
||||
</label>
|
||||
<label v-if="server.transport === 'stdio'" class="wide">
|
||||
<span>command</span>
|
||||
<input v-model="server.command" placeholder="如 python3" />
|
||||
</label>
|
||||
<label v-if="server.transport === 'stdio'" class="wide">
|
||||
<span>args(每行一个,留空=无)</span>
|
||||
<textarea
|
||||
v-model="server.argsText"
|
||||
rows="3"
|
||||
placeholder="如\n/abs/path/mcp_server.py"
|
||||
></textarea>
|
||||
</label>
|
||||
<label v-if="server.transport === 'stdio'" class="wide">
|
||||
<span>cwd(可选)</span>
|
||||
<input v-model="server.cwd" placeholder="工作目录" />
|
||||
</label>
|
||||
<label v-if="server.transport === 'streamable_http'" class="wide">
|
||||
<span>url</span>
|
||||
<input v-model="server.url" placeholder="如 http://127.0.0.1:8000/mcp" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
<span>headers(JSON,可选)</span>
|
||||
<textarea
|
||||
v-model="server.headersText"
|
||||
rows="2"
|
||||
placeholder='如 {"Authorization":"Bearer xxx"}'
|
||||
></textarea>
|
||||
</label>
|
||||
<label class="wide">
|
||||
<span>env(JSON,可选,stdio 有效)</span>
|
||||
<textarea v-model="server.envText" rows="2" placeholder='如 {"PYTHONUNBUFFERED":"1"}'></textarea>
|
||||
</label>
|
||||
<label>
|
||||
<span>timeout(s)</span>
|
||||
<input v-model="server.timeoutText" placeholder="25" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
<span>include_tools(逗号分隔,可选)</span>
|
||||
<input v-model="server.includeToolsText" placeholder="如 echo,read_db" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
<span>exclude_tools(逗号分隔,可选)</span>
|
||||
<input v-model="server.excludeToolsText" placeholder="如 dangerous_tool" />
|
||||
</label>
|
||||
<label class="wide">
|
||||
<span>说明(可选)</span>
|
||||
<input v-model="server.description" placeholder="备注说明" />
|
||||
</label>
|
||||
</div>
|
||||
<div class="mcp-meta">
|
||||
<span>最近同步:{{ server.tools_cache_updated_at || '未同步' }}</span>
|
||||
<span v-if="server.last_error" class="error-text">错误:{{ server.last_error }}</span>
|
||||
<span>缓存工具数:{{ server.tools_cache_count }}</span>
|
||||
</div>
|
||||
<div class="mcp-actions">
|
||||
<button type="button" class="primary" @click="saveMcpServer(server)">保存</button>
|
||||
<button type="button" class="ghost-btn" @click="syncMcpServer(server.id)">同步工具</button>
|
||||
<button type="button" class="link danger" @click="deleteMcpServer(server.id, idx)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-title">
|
||||
<h2>已删除分类</h2>
|
||||
@ -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<string, any>,
|
||||
models: [] as string[],
|
||||
@ -283,6 +405,9 @@ const form = reactive({
|
||||
const lastUpdated = ref<string | null>(null);
|
||||
const saving = ref(false);
|
||||
const policyCache = ref<RawPolicy | null>(null);
|
||||
const mcpServers = ref<MCPServerFormItem[]>([]);
|
||||
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<string, string> {
|
||||
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<string, string> = {};
|
||||
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;
|
||||
|
||||
@ -1378,6 +1378,40 @@
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="activeTab === 'mcp-config'"
|
||||
key="mcp-config"
|
||||
class="personal-page admin-monitor-page"
|
||||
>
|
||||
<div class="admin-monitor-panel secondary">
|
||||
<div class="admin-monitor-heading">
|
||||
<p class="admin-monitor-eyebrow">MCP 配置</p>
|
||||
<div class="admin-monitor-title-row">
|
||||
<h3>MCP 服务器管理</h3>
|
||||
<span class="admin-monitor-chip alt">宿主机模式 · 管理员</span>
|
||||
</div>
|
||||
<p class="admin-monitor-desc">
|
||||
统一配置 MCP Server,支持新增、修改、同步与重载,集中管理工具扩展能力。
|
||||
</p>
|
||||
</div>
|
||||
<div class="admin-monitor-highlights">
|
||||
<span class="admin-monitor-tag">统一配置</span>
|
||||
<span class="admin-monitor-tag">工具同步</span>
|
||||
<span class="admin-monitor-tag">即时生效</span>
|
||||
<span class="admin-monitor-tag">仅宿主机管理员</span>
|
||||
</div>
|
||||
<div class="admin-monitor-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="admin-monitor-button"
|
||||
@click="openMcpConfig"
|
||||
>
|
||||
打开 MCP 配置页
|
||||
</button>
|
||||
<p class="admin-monitor-hint">新窗口打开 /admin/policy 页面。</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
<section
|
||||
v-else-if="activeTab === 'admin-monitor'"
|
||||
key="admin-monitor"
|
||||
@ -1521,9 +1555,25 @@ type PersonalTab =
|
||||
| 'image'
|
||||
| 'theme'
|
||||
| 'tutorial'
|
||||
| 'mcp-config'
|
||||
| 'admin-monitor';
|
||||
|
||||
const isAdmin = computed(() => (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[] }> = [
|
||||
|
||||
@ -94,6 +94,9 @@ const RELATIVE_TIME_RANGE_MAP: Record<string, string> = {
|
||||
|
||||
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';
|
||||
}
|
||||
|
||||
|
||||
@ -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',
|
||||
|
||||
257
test/test_mcp_integration.py
Normal file
257
test/test_mcp_integration.py
Normal file
@ -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()
|
||||
Loading…
Reference in New Issue
Block a user