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
|
PROJECT_MAX_STORAGE_MB=2048
|
||||||
MAX_ACTIVE_USER_CONTAINERS=8
|
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 uploads as _uploads
|
||||||
from . import sub_agent as _sub_agent
|
from . import sub_agent as _sub_agent
|
||||||
from . import custom_tools as _custom_tools
|
from . import custom_tools as _custom_tools
|
||||||
|
from . import mcp as _mcp
|
||||||
|
|
||||||
from .api import *
|
from .api import *
|
||||||
from .paths import *
|
from .paths import *
|
||||||
@ -63,9 +64,10 @@ from .auth import *
|
|||||||
from .uploads import *
|
from .uploads import *
|
||||||
from .sub_agent import *
|
from .sub_agent import *
|
||||||
from .custom_tools import *
|
from .custom_tools import *
|
||||||
|
from .mcp import *
|
||||||
|
|
||||||
__all__ = []
|
__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__", [])
|
__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,
|
TERMINAL_SANDBOX_MEMORY,
|
||||||
PROJECT_MAX_STORAGE_MB,
|
PROJECT_MAX_STORAGE_MB,
|
||||||
CUSTOM_TOOLS_ENABLED,
|
CUSTOM_TOOLS_ENABLED,
|
||||||
|
MCP_TOOLS_ENABLED,
|
||||||
)
|
)
|
||||||
except ImportError:
|
except ImportError:
|
||||||
import sys
|
import sys
|
||||||
@ -30,6 +31,7 @@ except ImportError:
|
|||||||
TERMINAL_SANDBOX_MEMORY,
|
TERMINAL_SANDBOX_MEMORY,
|
||||||
PROJECT_MAX_STORAGE_MB,
|
PROJECT_MAX_STORAGE_MB,
|
||||||
CUSTOM_TOOLS_ENABLED,
|
CUSTOM_TOOLS_ENABLED,
|
||||||
|
MCP_TOOLS_ENABLED,
|
||||||
)
|
)
|
||||||
|
|
||||||
from modules.file_manager import FileManager
|
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.easter_egg_manager import EasterEggManager
|
||||||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
||||||
from modules.custom_tool_executor import CustomToolExecutor
|
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:
|
try:
|
||||||
from config.limits import THINKING_FAST_INTERVAL
|
from config.limits import THINKING_FAST_INTERVAL
|
||||||
@ -154,6 +158,22 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
default_enabled=True,
|
default_enabled=True,
|
||||||
silent_when_disabled=False,
|
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_forced_category_states: Dict[str, Optional[bool]] = {}
|
||||||
self.admin_disabled_models: List[str] = []
|
self.admin_disabled_models: List[str] = []
|
||||||
self.admin_policy_ui_blocks: Dict[str, bool] = {}
|
self.admin_policy_ui_blocks: Dict[str, bool] = {}
|
||||||
@ -261,6 +281,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
|
|||||||
self.terminal_ops.set_container_session(session)
|
self.terminal_ops.set_container_session(session)
|
||||||
if getattr(self, "file_manager", None):
|
if getattr(self, "file_manager", None):
|
||||||
self.file_manager.set_container_session(session)
|
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):
|
if getattr(self, "sub_agent_manager", None):
|
||||||
try:
|
try:
|
||||||
self.sub_agent_manager.set_container_session(session)
|
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_registry import CustomToolRegistry, build_default_tool_category
|
||||||
from modules.custom_tool_executor import CustomToolExecutor
|
from modules.custom_tool_executor import CustomToolExecutor
|
||||||
|
from modules.mcp_server_registry import build_default_mcp_category
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from config.limits import THINKING_FAST_INTERVAL
|
from config.limits import THINKING_FAST_INTERVAL
|
||||||
@ -87,6 +88,14 @@ logger = setup_logger(__name__)
|
|||||||
DISABLE_LENGTH_CHECK = True
|
DISABLE_LENGTH_CHECK = True
|
||||||
|
|
||||||
class MainTerminalToolsDefinitionMixin:
|
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]:
|
def _inject_intent(self, properties: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""在工具参数中注入 intent(简短意图说明),仅当开关启用时。
|
"""在工具参数中注入 intent(简短意图说明),仅当开关启用时。
|
||||||
|
|
||||||
@ -184,6 +193,68 @@ class MainTerminalToolsDefinitionMixin:
|
|||||||
|
|
||||||
return tools
|
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]:
|
def define_tools(self) -> List[Dict]:
|
||||||
"""定义可用工具(添加确认工具)"""
|
"""定义可用工具(添加确认工具)"""
|
||||||
now = datetime.now()
|
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",
|
"type": "function",
|
||||||
"function": {
|
"function": {
|
||||||
@ -879,6 +975,10 @@ class MainTerminalToolsDefinitionMixin:
|
|||||||
custom_tools = self._build_custom_tools()
|
custom_tools = self._build_custom_tools()
|
||||||
if custom_tools:
|
if custom_tools:
|
||||||
tools.extend(custom_tools)
|
tools.extend(custom_tools)
|
||||||
|
# 附加 MCP 工具(由管理员统一配置)
|
||||||
|
mcp_tools = self._build_mcp_tools()
|
||||||
|
if mcp_tools:
|
||||||
|
tools.extend(mcp_tools)
|
||||||
if self.disabled_tools:
|
if self.disabled_tools:
|
||||||
tools = [
|
tools = [
|
||||||
tool for tool in tools
|
tool for tool in tools
|
||||||
|
|||||||
@ -97,6 +97,14 @@ logger = setup_logger(__name__)
|
|||||||
DISABLE_LENGTH_CHECK = True
|
DISABLE_LENGTH_CHECK = True
|
||||||
|
|
||||||
class MainTerminalToolsExecutionMixin:
|
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_SERIES_TOOLS = {
|
||||||
"terminal_session",
|
"terminal_session",
|
||||||
"terminal_input",
|
"terminal_input",
|
||||||
@ -597,13 +605,103 @@ class MainTerminalToolsExecutionMixin:
|
|||||||
pass
|
pass
|
||||||
custom_tool = self.custom_tool_registry.get_tool(tool_name)
|
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 系列工具执行
|
# Skill 强约束:未阅读对应 SKILL.md 时,阻止 terminal/sub-agent 系列工具执行
|
||||||
strict_error = self._check_skill_strict_prerequisite(tool_name, arguments)
|
strict_error = self._check_skill_strict_prerequisite(tool_name, arguments)
|
||||||
if strict_error:
|
if strict_error:
|
||||||
return json.dumps(strict_error, ensure_ascii=False)
|
return json.dumps(strict_error, ensure_ascii=False)
|
||||||
|
|
||||||
try:
|
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)
|
result = await self.custom_tool_executor.run(tool_name, arguments)
|
||||||
elif tool_name == "read_file":
|
elif tool_name == "read_file":
|
||||||
result = self._handle_read_tool(arguments)
|
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_registry import CustomToolRegistry, build_default_tool_category
|
||||||
from modules.custom_tool_executor import CustomToolExecutor
|
from modules.custom_tool_executor import CustomToolExecutor
|
||||||
|
from modules.mcp_server_registry import build_default_mcp_category
|
||||||
|
|
||||||
try:
|
try:
|
||||||
from config.limits import THINKING_FAST_INTERVAL
|
from config.limits import THINKING_FAST_INTERVAL
|
||||||
@ -224,6 +225,14 @@ class MainTerminalToolsPolicyMixin:
|
|||||||
default_enabled=True,
|
default_enabled=True,
|
||||||
silent_when_disabled=False,
|
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] = {}
|
new_states: Dict[str, bool] = {}
|
||||||
for key, cat in self.tool_categories_map.items():
|
for key, cat in self.tool_categories_map.items():
|
||||||
|
|||||||
@ -27,6 +27,10 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
|
|||||||
label="网络检索",
|
label="网络检索",
|
||||||
tools=["web_search", "extract_webpage", "save_webpage"],
|
tools=["web_search", "extract_webpage", "save_webpage"],
|
||||||
),
|
),
|
||||||
|
"mcp": ToolCategory(
|
||||||
|
label="MCP 工具",
|
||||||
|
tools=["list_mcp_servers"],
|
||||||
|
),
|
||||||
"file_edit": ToolCategory(
|
"file_edit": ToolCategory(
|
||||||
label="文件编辑",
|
label="文件编辑",
|
||||||
tools=[
|
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.paths import ADMIN_POLICY_FILE
|
||||||
from config.model_profiles import get_registered_model_keys
|
from config.model_profiles import get_registered_model_keys
|
||||||
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
|
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]:
|
def _allowed_models() -> set[str]:
|
||||||
return set(get_registered_model_keys(visible_only=False))
|
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}}"""
|
"""从 override 字典生成 {id: {label, tools, default_enabled}}"""
|
||||||
from core.tool_config import TOOL_CATEGORIES # 延迟导入避免循环
|
from core.tool_config import TOOL_CATEGORIES # 延迟导入避免循环
|
||||||
registry = CustomToolRegistry()
|
registry = CustomToolRegistry()
|
||||||
|
mcp_registry = MCPServerRegistry()
|
||||||
|
|
||||||
base: Dict[str, Dict[str, Any]] = {
|
base: Dict[str, Dict[str, Any]] = {
|
||||||
key: {
|
key: {
|
||||||
@ -167,6 +169,17 @@ def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, D
|
|||||||
"silent_when_disabled": False,
|
"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 [])
|
remove_ids = set(overrides.get("remove_categories") or [])
|
||||||
for rid in remove_ids:
|
for rid in remove_ids:
|
||||||
base.pop(rid, None)
|
base.pop(rid, None)
|
||||||
@ -227,6 +240,7 @@ def describe_defaults() -> Dict[str, Any]:
|
|||||||
"""返回默认(未覆盖)工具分类,用于前端渲染。"""
|
"""返回默认(未覆盖)工具分类,用于前端渲染。"""
|
||||||
from core.tool_config import TOOL_CATEGORIES
|
from core.tool_config import TOOL_CATEGORIES
|
||||||
registry = CustomToolRegistry()
|
registry = CustomToolRegistry()
|
||||||
|
mcp_registry = MCPServerRegistry()
|
||||||
|
|
||||||
categories = {
|
categories = {
|
||||||
key: {
|
key: {
|
||||||
@ -248,6 +262,16 @@ def describe_defaults() -> Dict[str, Any]:
|
|||||||
"silent_when_disabled": False,
|
"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 {
|
return {
|
||||||
"categories": categories,
|
"categories": categories,
|
||||||
"models": sorted(list(_allowed_models())),
|
"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`)、信息整合 |
|
| **信息检索** | 网络搜索(`web_search`)、网页内容提取(`extract_webpage`、`save_webpage`)、信息整合 |
|
||||||
|
| **MCP扩展** | 通过 MCP 服务扩展外部工具;可用 `list_mcp_servers` 查看已配置服务与工具别名 |
|
||||||
| **文件管理** | 批量处理文件、目录操作、自动化任务 |
|
| **文件管理** | 批量处理文件、目录操作、自动化任务 |
|
||||||
| **终端操作** | 执行命令(`run_command`)、运行Python代码(`run_python`)、持久终端会话(`terminal_session` 系列) |
|
| **终端操作** | 执行命令(`run_command`)、运行Python代码(`run_python`)、持久终端会话(`terminal_session` 系列) |
|
||||||
| **视觉理解** | 分析图片内容、识别文字/物体/表格(`vlm_analyze`/`view_image`) |
|
| **视觉理解** | 分析图片内容、识别文字/物体/表格(`vlm_analyze`/`view_image`) |
|
||||||
@ -116,6 +117,12 @@
|
|||||||
- `extract_webpage`:提取网页正文
|
- `extract_webpage`:提取网页正文
|
||||||
- `save_webpage`:保存网页为文本
|
- `save_webpage`:保存网页为文本
|
||||||
|
|
||||||
|
### 3.6 MCP 工具扩展
|
||||||
|
|
||||||
|
- 当你不确定当前有哪些 MCP 工具可用时,先调用 `list_mcp_servers`
|
||||||
|
- 需要刷新远程工具缓存时,可使用 `list_mcp_servers` 并传 `refresh=true`
|
||||||
|
- 调用具体 MCP 工具时,使用工具列表中的 `mcp__...` 别名
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 禁止做的事情
|
## 4. 禁止做的事情
|
||||||
|
|||||||
@ -18,6 +18,7 @@
|
|||||||
| **代码开发** | 编写、调试、重构代码;代码审查;技术方案设计 |
|
| **代码开发** | 编写、调试、重构代码;代码审查;技术方案设计 |
|
||||||
| **数据处理** | 处理表格、分析数据、生成图表和报告 |
|
| **数据处理** | 处理表格、分析数据、生成图表和报告 |
|
||||||
| **信息检索** | 网络搜索(`web_search`)、网页内容提取(`extract_webpage`、`save_webpage`)、信息整合 |
|
| **信息检索** | 网络搜索(`web_search`)、网页内容提取(`extract_webpage`、`save_webpage`)、信息整合 |
|
||||||
|
| **MCP扩展** | 通过 MCP 服务扩展外部工具;可用 `list_mcp_servers` 查看已配置服务与工具别名 |
|
||||||
| **文件管理** | 批量处理文件、目录操作、自动化任务 |
|
| **文件管理** | 批量处理文件、目录操作、自动化任务 |
|
||||||
| **终端操作** | 执行命令(`run_command`)、运行Python代码(`run_python`)、持久终端会话(`terminal_session` 系列) |
|
| **终端操作** | 执行命令(`run_command`)、运行Python代码(`run_python`)、持久终端会话(`terminal_session` 系列) |
|
||||||
| **视觉理解** | 自带多模态能力,可直接分析图片内容、识别文字/物体/表格/场景 |
|
| **视觉理解** | 自带多模态能力,可直接分析图片内容、识别文字/物体/表格/场景 |
|
||||||
@ -134,6 +135,12 @@
|
|||||||
- `extract_webpage`:提取网页正文
|
- `extract_webpage`:提取网页正文
|
||||||
- `save_webpage`:保存网页为文本
|
- `save_webpage`:保存网页为文本
|
||||||
|
|
||||||
|
### 3.6 MCP 工具扩展
|
||||||
|
|
||||||
|
- 当你不确定当前有哪些 MCP 工具可用时,先调用 `list_mcp_servers`
|
||||||
|
- 需要刷新远程工具缓存时,可使用 `list_mcp_servers` 并传 `refresh=true`
|
||||||
|
- 调用具体 MCP 工具时,使用工具列表中的 `mcp__...` 别名
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 禁止做的事情
|
## 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 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 .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 .security import rate_limited
|
||||||
from .utils_common import debug_log
|
from .utils_common import debug_log
|
||||||
from . import state # 使用动态 state,确保与入口实例保持一致
|
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 (
|
from .conversation import (
|
||||||
build_admin_dashboard_snapshot as _build_dashboard_rich,
|
build_admin_dashboard_snapshot as _build_dashboard_rich,
|
||||||
compute_workspace_storage,
|
compute_workspace_storage,
|
||||||
@ -513,6 +521,72 @@ def admin_custom_tools_reload_api():
|
|||||||
return jsonify({"success": False, "error": str(exc)}), 500
|
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 用户与监控 ------------------ #
|
# ------------------ API 用户与监控 ------------------ #
|
||||||
@admin_bp.route('/api/admin/api-dashboard', methods=['GET'])
|
@admin_bp.route('/api/admin/api-dashboard', methods=['GET'])
|
||||||
@api_login_required
|
@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 config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB
|
||||||
from core.web_terminal import WebTerminal
|
from core.web_terminal import WebTerminal
|
||||||
from modules.custom_tool_registry import CustomToolRegistry
|
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.usage_tracker import UsageTracker
|
||||||
from modules.user_container_manager import UserContainerManager
|
from modules.user_container_manager import UserContainerManager
|
||||||
from modules.user_manager import UserManager
|
from modules.user_manager import UserManager
|
||||||
@ -19,6 +21,8 @@ from modules.tool_approval_manager import ToolApprovalManager
|
|||||||
user_manager = UserManager()
|
user_manager = UserManager()
|
||||||
api_user_manager = ApiUserManager()
|
api_user_manager = ApiUserManager()
|
||||||
custom_tool_registry = CustomToolRegistry()
|
custom_tool_registry = CustomToolRegistry()
|
||||||
|
mcp_server_registry = MCPServerRegistry()
|
||||||
|
mcp_client_manager = MCPClientManager(mcp_server_registry)
|
||||||
container_manager = UserContainerManager()
|
container_manager = UserContainerManager()
|
||||||
user_terminals: Dict[str, WebTerminal] = {}
|
user_terminals: Dict[str, WebTerminal] = {}
|
||||||
terminal_rooms: Dict[str, set] = {}
|
terminal_rooms: Dict[str, set] = {}
|
||||||
@ -76,6 +80,8 @@ __all__ = [
|
|||||||
"user_manager",
|
"user_manager",
|
||||||
"api_user_manager",
|
"api_user_manager",
|
||||||
"custom_tool_registry",
|
"custom_tool_registry",
|
||||||
|
"mcp_server_registry",
|
||||||
|
"mcp_client_manager",
|
||||||
"container_manager",
|
"container_manager",
|
||||||
"user_terminals",
|
"user_terminals",
|
||||||
"terminal_rooms",
|
"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>
|
</div>
|
||||||
</section>
|
</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">
|
<section class="panel">
|
||||||
<div class="panel-title">
|
<div class="panel-title">
|
||||||
<h2>已删除分类</h2>
|
<h2>已删除分类</h2>
|
||||||
@ -253,6 +355,26 @@ interface CategoryFormItem {
|
|||||||
forced: boolean | null;
|
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({
|
const defaults = reactive({
|
||||||
categories: {} as Record<string, any>,
|
categories: {} as Record<string, any>,
|
||||||
models: [] as string[],
|
models: [] as string[],
|
||||||
@ -283,6 +405,9 @@ const form = reactive({
|
|||||||
const lastUpdated = ref<string | null>(null);
|
const lastUpdated = ref<string | null>(null);
|
||||||
const saving = ref(false);
|
const saving = ref(false);
|
||||||
const policyCache = ref<RawPolicy | null>(null);
|
const policyCache = ref<RawPolicy | null>(null);
|
||||||
|
const mcpServers = ref<MCPServerFormItem[]>([]);
|
||||||
|
const mcpLoading = ref(false);
|
||||||
|
const mcpSyncing = ref(false);
|
||||||
|
|
||||||
const targetPlaceholder = computed(() => {
|
const targetPlaceholder = computed(() => {
|
||||||
if (form.target_type === 'role') return '如:admin / user';
|
if (form.target_type === 'role') return '如:admin / user';
|
||||||
@ -534,6 +659,204 @@ function rebuildCategoryOverrides() {
|
|||||||
form.config.category_overrides = map;
|
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() {
|
async function fetchDefaults() {
|
||||||
if (!secondaryVerified.value) return;
|
if (!secondaryVerified.value) return;
|
||||||
const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' });
|
const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' });
|
||||||
@ -547,7 +870,6 @@ async function fetchDefaults() {
|
|||||||
policyCache.value = data.data;
|
policyCache.value = data.data;
|
||||||
lastUpdated.value = data.data?.updated_at || null;
|
lastUpdated.value = data.data?.updated_at || null;
|
||||||
applyScopeConfig();
|
applyScopeConfig();
|
||||||
banner.message = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function scopeConfig(): any {
|
function scopeConfig(): any {
|
||||||
@ -626,7 +948,7 @@ const handleVerifySecondary = async () => {
|
|||||||
await verifySecondary(secondaryPassword.value);
|
await verifySecondary(secondaryPassword.value);
|
||||||
if (secondaryVerified.value) {
|
if (secondaryVerified.value) {
|
||||||
secondaryPassword.value = '';
|
secondaryPassword.value = '';
|
||||||
fetchDefaults().catch((err) => {
|
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
banner.message = err?.message || '加载策略失败';
|
banner.message = err?.message || '加载策略失败';
|
||||||
banner.type = 'error';
|
banner.type = 'error';
|
||||||
@ -638,7 +960,7 @@ onMounted(async () => {
|
|||||||
document.addEventListener('click', handleDocClick);
|
document.addEventListener('click', handleDocClick);
|
||||||
await checkSecondary();
|
await checkSecondary();
|
||||||
if (secondaryVerified.value) {
|
if (secondaryVerified.value) {
|
||||||
fetchDefaults().catch((err) => {
|
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
banner.message = err?.message || '加载策略失败';
|
banner.message = err?.message || '加载策略失败';
|
||||||
banner.type = 'error';
|
banner.type = 'error';
|
||||||
@ -648,7 +970,7 @@ onMounted(async () => {
|
|||||||
|
|
||||||
watch(secondaryVerified, (val) => {
|
watch(secondaryVerified, (val) => {
|
||||||
if (val) {
|
if (val) {
|
||||||
fetchDefaults().catch((err) => {
|
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
banner.message = err?.message || '加载策略失败';
|
banner.message = err?.message || '加载策略失败';
|
||||||
banner.type = 'error';
|
banner.type = 'error';
|
||||||
@ -789,6 +1111,67 @@ onBeforeUnmount(() => {
|
|||||||
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
|
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 {
|
.panel-title {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@ -1378,6 +1378,40 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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
|
<section
|
||||||
v-else-if="activeTab === 'admin-monitor'"
|
v-else-if="activeTab === 'admin-monitor'"
|
||||||
key="admin-monitor"
|
key="admin-monitor"
|
||||||
@ -1521,9 +1555,25 @@ type PersonalTab =
|
|||||||
| 'image'
|
| 'image'
|
||||||
| 'theme'
|
| 'theme'
|
||||||
| 'tutorial'
|
| 'tutorial'
|
||||||
|
| 'mcp-config'
|
||||||
| 'admin-monitor';
|
| '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(() => {
|
const isAppShell = computed(() => {
|
||||||
if (typeof window === 'undefined') return false;
|
if (typeof window === 'undefined') return false;
|
||||||
const params = new URLSearchParams(window.location.search);
|
const params = new URLSearchParams(window.location.search);
|
||||||
@ -1533,6 +1583,9 @@ const isAppShell = computed(() => {
|
|||||||
|
|
||||||
const personalTabs = computed(() => {
|
const personalTabs = computed(() => {
|
||||||
const tabs = baseTabs.filter((tab) => (tab.id === 'app-update' ? isAppShell.value : true));
|
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) {
|
if (isAdmin.value) {
|
||||||
tabs.push({ id: 'admin-monitor' as const, label: '管理员监控', description: '系统总览' });
|
tabs.push({ id: 'admin-monitor' as const, label: '管理员监控', description: '系统总览' });
|
||||||
}
|
}
|
||||||
@ -1805,8 +1858,18 @@ const downloadLatestApp = () => {
|
|||||||
window.location.href = url;
|
window.location.href = url;
|
||||||
};
|
};
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(async () => {
|
||||||
hydrateAppVersionFromBridge();
|
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(
|
watch(
|
||||||
@ -1999,6 +2062,11 @@ const openApiAdmin = () => {
|
|||||||
personalization.closeDrawer();
|
personalization.closeDrawer();
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openMcpConfig = () => {
|
||||||
|
window.open('/admin/policy', '_blank', 'noopener');
|
||||||
|
personalization.closeDrawer();
|
||||||
|
};
|
||||||
|
|
||||||
// ===== 主题切换 =====
|
// ===== 主题切换 =====
|
||||||
const { setTheme, loadTheme } = useTheme();
|
const { setTheme, loadTheme } = useTheme();
|
||||||
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [
|
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 {
|
export function getToolIcon(tool: any): string {
|
||||||
const toolName = typeof tool === 'string' ? tool : tool?.name;
|
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';
|
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',
|
layers: '/static/icons/layers.svg',
|
||||||
keyboard: '/static/icons/keyboard.svg',
|
keyboard: '/static/icons/keyboard.svg',
|
||||||
menu: '/static/icons/menu.svg',
|
menu: '/static/icons/menu.svg',
|
||||||
|
mcpLogo: '/static/icons/mcp-logo.svg',
|
||||||
monitor: '/static/icons/monitor.svg',
|
monitor: '/static/icons/monitor.svg',
|
||||||
octagon: '/static/icons/octagon.svg',
|
octagon: '/static/icons/octagon.svg',
|
||||||
pencil: '/static/icons/pencil.svg',
|
pencil: '/static/icons/pencil.svg',
|
||||||
@ -71,12 +72,14 @@ export const TOOL_ICON_MAP = Object.freeze({
|
|||||||
get_sub_agent_status: 'bot',
|
get_sub_agent_status: 'bot',
|
||||||
web_search: 'search',
|
web_search: 'search',
|
||||||
trigger_easter_egg: 'sparkles',
|
trigger_easter_egg: 'sparkles',
|
||||||
|
list_mcp_servers: 'mcpLogo',
|
||||||
view_image: 'camera',
|
view_image: 'camera',
|
||||||
view_video: 'eye'
|
view_video: 'eye'
|
||||||
});
|
});
|
||||||
|
|
||||||
export const TOOL_CATEGORY_ICON_MAP = Object.freeze({
|
export const TOOL_CATEGORY_ICON_MAP = Object.freeze({
|
||||||
network: 'globe',
|
network: 'globe',
|
||||||
|
mcp: 'mcpLogo',
|
||||||
file_edit: 'pencil',
|
file_edit: 'pencil',
|
||||||
personalization: 'userPen',
|
personalization: 'userPen',
|
||||||
read_focus: 'eye',
|
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