feat: add MCP management and host-only runtime policy

This commit is contained in:
JOJO 2026-04-26 23:49:04 +08:00
parent e758df767f
commit ed5315eef6
24 changed files with 2756 additions and 10 deletions

View File

@ -47,3 +47,13 @@ TERMINAL_SANDBOX_REQUIRE=0
# === 资源控制 ================================================================
PROJECT_MAX_STORAGE_MB=2048
MAX_ACTIVE_USER_CONTAINERS=8
# === MCP 工具扩展(可选) =====================================================
# 1=启用0=禁用
MCP_TOOLS_ENABLED=1
# MCP 服务配置文件路径(默认 data/mcp_servers.json
MCP_SERVERS_FILE=./data/mcp_servers.json
# MCP 协议版本(默认 2025-06-18
MCP_PROTOCOL_VERSION=2025-06-18
# MCP 默认超时(秒)
MCP_DEFAULT_TIMEOUT_SECONDS=25

View File

@ -48,6 +48,7 @@ from . import auth as _auth
from . import uploads as _uploads
from . import sub_agent as _sub_agent
from . import custom_tools as _custom_tools
from . import mcp as _mcp
from .api import *
from .paths import *
@ -63,9 +64,10 @@ from .auth import *
from .uploads import *
from .sub_agent import *
from .custom_tools import *
from .mcp import *
__all__ = []
for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools):
for module in (_api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp):
__all__ += getattr(module, "__all__", [])
del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools
del _api, _paths, _limits, _terminal, _conversation, _security, _ui, _memory, _ocr, _todo, _auth, _uploads, _sub_agent, _custom_tools, _mcp

23
config/mcp.py Normal file
View File

@ -0,0 +1,23 @@
"""MCPModel 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",
]

View File

@ -14,6 +14,7 @@ try:
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
MCP_TOOLS_ENABLED,
)
except ImportError:
import sys
@ -30,6 +31,7 @@ except ImportError:
TERMINAL_SANDBOX_MEMORY,
PROJECT_MAX_STORAGE_MB,
CUSTOM_TOOLS_ENABLED,
MCP_TOOLS_ENABLED,
)
from modules.file_manager import FileManager
@ -44,6 +46,8 @@ from modules.ocr_client import OCRClient
from modules.easter_egg_manager import EasterEggManager
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor
from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category
from modules.mcp_client_manager import MCPClientManager
try:
from config.limits import THINKING_FAST_INTERVAL
@ -154,6 +158,22 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
default_enabled=True,
silent_when_disabled=False,
)
self.mcp_tools_enabled = bool(MCP_TOOLS_ENABLED)
self.mcp_server_registry = MCPServerRegistry()
self.mcp_client_manager = MCPClientManager(
self.mcp_server_registry,
container_session=container_session,
)
self.mcp_tool_alias_map: Dict[str, object] = {}
if self.mcp_tools_enabled:
default_mcp_cat = build_default_mcp_category()
if "mcp" not in self.tool_categories_map:
self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))(
label=default_mcp_cat["label"],
tools=default_mcp_cat["tools"],
default_enabled=True,
silent_when_disabled=False,
)
self.admin_forced_category_states: Dict[str, Optional[bool]] = {}
self.admin_disabled_models: List[str] = []
self.admin_policy_ui_blocks: Dict[str, bool] = {}
@ -261,6 +281,8 @@ class MainTerminal(MainTerminalCommandMixin, MainTerminalContextMixin, MainTermi
self.terminal_ops.set_container_session(session)
if getattr(self, "file_manager", None):
self.file_manager.set_container_session(session)
if getattr(self, "mcp_client_manager", None):
self.mcp_client_manager.set_container_session(session)
if getattr(self, "sub_agent_manager", None):
try:
self.sub_agent_manager.set_container_session(session)

View File

@ -63,6 +63,7 @@ from modules.skills_manager import (
)
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor
from modules.mcp_server_registry import build_default_mcp_category
try:
from config.limits import THINKING_FAST_INTERVAL
@ -87,6 +88,14 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
class MainTerminalToolsDefinitionMixin:
@staticmethod
def _mcp_disabled_message() -> str:
return "当前为docker模式MCP仅支持宿主机模式"
def _is_mcp_disabled_in_docker_mode(self) -> bool:
session = getattr(self, "container_session", None)
return bool(session and getattr(session, "mode", None) == "docker")
def _inject_intent(self, properties: Dict[str, Any]) -> Dict[str, Any]:
"""在工具参数中注入 intent简短意图说明仅当开关启用时。
@ -184,6 +193,68 @@ class MainTerminalToolsDefinitionMixin:
return tools
def _build_mcp_tools(self) -> List[Dict]:
if not getattr(self, "mcp_tools_enabled", False):
if hasattr(self, "tool_categories_map") and "mcp" in self.tool_categories_map:
self.tool_categories_map["mcp"].tools = []
self.mcp_tool_alias_map = {}
return []
if self._is_mcp_disabled_in_docker_mode():
self.mcp_tool_alias_map = {}
mcp_category_tools = ["list_mcp_servers"]
if "mcp" in self.tool_categories_map:
self.tool_categories_map["mcp"].tools = mcp_category_tools
elif getattr(self, "mcp_tools_enabled", False):
default_mcp_cat = build_default_mcp_category()
self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))(
label=default_mcp_cat["label"],
tools=mcp_category_tools,
default_enabled=True,
silent_when_disabled=False,
)
return []
# 类别层面的禁用优先生效(即使策略里尚未填充具体工具列表)
mcp_cat = getattr(self, "tool_categories_map", {}).get("mcp")
if mcp_cat is not None:
mcp_enabled = self.tool_category_states.get("mcp", getattr(mcp_cat, "default_enabled", True))
forced = getattr(self, "admin_forced_category_states", {}).get("mcp")
if isinstance(forced, bool):
mcp_enabled = forced
if not mcp_enabled:
self.mcp_tool_alias_map = {}
self.tool_categories_map["mcp"].tools = []
return []
manager = getattr(self, "mcp_client_manager", None)
if manager is None:
self.mcp_tool_alias_map = {}
return []
try:
tool_defs, alias_map = manager.build_openai_tools(
ensure_discovery=True,
discovery_timeout_seconds=10,
)
except Exception as exc:
logger.warning("构建 MCP 工具列表失败: %s", exc)
tool_defs, alias_map = [], {}
# 保存映射,供执行层按 alias 分发到具体 server/tool
self.mcp_tool_alias_map = alias_map or {}
mcp_category_tools = ["list_mcp_servers", *list((alias_map or {}).keys())]
# 同步 mcp 分类列表,避免策略页显示旧数据
if "mcp" in self.tool_categories_map:
self.tool_categories_map["mcp"].tools = mcp_category_tools
elif getattr(self, "mcp_tools_enabled", False):
default_mcp_cat = build_default_mcp_category()
self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))(
label=default_mcp_cat["label"],
tools=mcp_category_tools,
default_enabled=True,
silent_when_disabled=False,
)
return tool_defs
def define_tools(self) -> List[Dict]:
"""定义可用工具(添加确认工具)"""
now = datetime.now()
@ -592,6 +663,31 @@ class MainTerminalToolsDefinitionMixin:
}
}
},
{
"type": "function",
"function": {
"name": "list_mcp_servers",
"description": "列出当前已配置的 MCP 服务与工具映射mcp__... 别名)。可选刷新远程 tools/list 缓存后再返回。",
"parameters": {
"type": "object",
"properties": self._inject_intent({
"refresh": {
"type": "boolean",
"description": "是否先同步 MCP 服务工具缓存(默认 false"
},
"server_id": {
"type": "string",
"description": "仅查看指定 MCP 服务(可选)"
},
"include_disabled": {
"type": "boolean",
"description": "是否包含已禁用服务(默认 false"
}
}),
"required": []
}
}
},
{
"type": "function",
"function": {
@ -879,6 +975,10 @@ class MainTerminalToolsDefinitionMixin:
custom_tools = self._build_custom_tools()
if custom_tools:
tools.extend(custom_tools)
# 附加 MCP 工具(由管理员统一配置)
mcp_tools = self._build_mcp_tools()
if mcp_tools:
tools.extend(mcp_tools)
if self.disabled_tools:
tools = [
tool for tool in tools

View File

@ -97,6 +97,14 @@ logger = setup_logger(__name__)
DISABLE_LENGTH_CHECK = True
class MainTerminalToolsExecutionMixin:
@staticmethod
def _mcp_disabled_message() -> str:
return "当前为docker模式MCP仅支持宿主机模式"
def _is_mcp_disabled_in_docker_mode(self) -> bool:
session = getattr(self, "container_session", None)
return bool(session and getattr(session, "mode", None) == "docker")
_TERMINAL_SERIES_TOOLS = {
"terminal_session",
"terminal_input",
@ -597,13 +605,103 @@ class MainTerminalToolsExecutionMixin:
pass
custom_tool = self.custom_tool_registry.get_tool(tool_name)
# MCP 工具 alias 识别mcp__<server>__<tool>
is_mcp_tool_alias = bool(
isinstance(tool_name, str) and tool_name.startswith("mcp__")
)
if (tool_name == "list_mcp_servers" or is_mcp_tool_alias) and self._is_mcp_disabled_in_docker_mode():
blocked = self._mcp_disabled_message()
return json.dumps(
{
"success": False,
"error": blocked,
"message": blocked,
},
ensure_ascii=False,
)
# Skill 强约束:未阅读对应 SKILL.md 时,阻止 terminal/sub-agent 系列工具执行
strict_error = self._check_skill_strict_prerequisite(tool_name, arguments)
if strict_error:
return json.dumps(strict_error, ensure_ascii=False)
try:
if custom_tool:
if tool_name == "list_mcp_servers":
manager = getattr(self, "mcp_client_manager", None)
registry = getattr(self, "mcp_server_registry", None)
if manager is None or registry is None:
result = {"success": False, "error": "MCP 模块不可用"}
else:
refresh = bool(arguments.get("refresh", False))
include_disabled = bool(arguments.get("include_disabled", False))
server_id = str(arguments.get("server_id") or "").strip() or None
sync_result = None
if refresh:
sync_result = await asyncio.to_thread(
manager.sync_servers,
server_id=server_id,
)
await asyncio.to_thread(registry.reload)
servers = registry.list_servers(include_disabled=include_disabled)
if server_id:
servers = [item for item in servers if item.get("id") == server_id]
# 读取当前 alias 映射(不强制再次发现)
_, alias_map = manager.build_openai_tools(ensure_discovery=False)
aliases_by_server = {}
for alias, binding in (alias_map or {}).items():
sid = getattr(binding, "server_id", "")
if not sid:
continue
aliases_by_server.setdefault(sid, []).append(alias)
items = []
for item in servers:
sid = str(item.get("id") or "")
cache_tools = item.get("tools_cache") or []
cache_names = [
str((tool or {}).get("name") or "").strip()
for tool in cache_tools
if isinstance(tool, dict)
]
cache_names = [name for name in cache_names if name]
aliases = sorted(aliases_by_server.get(sid, []))
items.append(
{
"id": sid,
"name": item.get("name") or sid,
"enabled": bool(item.get("enabled", True)),
"transport": item.get("transport"),
"timeout_seconds": item.get("timeout_seconds"),
"tools_cache_count": len(cache_names),
"tools_cache_names": cache_names,
"tool_aliases": aliases,
"tools_cache_updated_at": item.get("tools_cache_updated_at"),
"last_error": item.get("last_error") or "",
}
)
result = {
"success": True,
"count": len(items),
"servers": items,
"sync_result": sync_result,
}
elif is_mcp_tool_alias:
if not getattr(self, "mcp_tools_enabled", False):
result = {"success": False, "error": "MCP 功能未启用"}
return json.dumps(result, ensure_ascii=False)
manager = getattr(self, "mcp_client_manager", None)
if manager is None:
result = {"success": False, "error": "MCP 管理器不可用"}
else:
result = await asyncio.to_thread(
manager.call_tool_by_alias,
tool_name,
arguments,
alias_map=getattr(self, "mcp_tool_alias_map", None),
)
elif custom_tool:
result = await self.custom_tool_executor.run(tool_name, arguments)
elif tool_name == "read_file":
result = self._handle_read_tool(arguments)

View File

@ -63,6 +63,7 @@ from modules.skills_manager import (
)
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.custom_tool_executor import CustomToolExecutor
from modules.mcp_server_registry import build_default_mcp_category
try:
from config.limits import THINKING_FAST_INTERVAL
@ -224,6 +225,14 @@ class MainTerminalToolsPolicyMixin:
default_enabled=True,
silent_when_disabled=False,
)
if getattr(self, "mcp_tools_enabled", False) and "mcp" not in self.tool_categories_map:
default_mcp_cat = build_default_mcp_category()
self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))(
label=default_mcp_cat["label"],
tools=[],
default_enabled=True,
silent_when_disabled=False,
)
# 重新构建启用状态映射,保留已有值
new_states: Dict[str, bool] = {}
for key, cat in self.tool_categories_map.items():

View File

@ -27,6 +27,10 @@ TOOL_CATEGORIES: Dict[str, ToolCategory] = {
label="网络检索",
tools=["web_search", "extract_webpage", "save_webpage"],
),
"mcp": ToolCategory(
label="MCP 工具",
tools=["list_mcp_servers"],
),
"file_edit": ToolCategory(
label="文件编辑",
tools=[

View File

@ -0,0 +1,199 @@
# 本项目新增 MCP 工具配置指南
> 最后更新2026-04-26
> 适用目录:`/Users/jojo/Desktop/agents/正在修复中/agents`
本文面向后续维护者,说明如何在当前项目里新增并接入一个 MCP 工具MCP Server
---
## 1. 先了解本项目里的 MCP 架构
当前实现是「**本项目作为 MCP 客户端桥接层**」:
1. 在后台配置一个或多个 MCP Serverstdio / 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`

View File

@ -16,6 +16,7 @@ from typing import Dict, Any, Tuple
from config.paths import ADMIN_POLICY_FILE
from config.model_profiles import get_registered_model_keys
from modules.custom_tool_registry import CustomToolRegistry, build_default_tool_category
from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category
def _allowed_models() -> set[str]:
return set(get_registered_model_keys(visible_only=False))
@ -145,6 +146,7 @@ def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, D
"""从 override 字典生成 {id: {label, tools, default_enabled}}"""
from core.tool_config import TOOL_CATEGORIES # 延迟导入避免循环
registry = CustomToolRegistry()
mcp_registry = MCPServerRegistry()
base: Dict[str, Dict[str, Any]] = {
key: {
@ -167,6 +169,17 @@ def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, D
"silent_when_disabled": False,
}
# 注入 MCP 工具分类(动态)
mcp_cat = build_default_mcp_category()
mcp_cat_id = mcp_cat.get("id", "mcp")
mcp_tools = ["list_mcp_servers", *mcp_registry.build_cached_alias_list()]
base[mcp_cat_id] = {
"label": mcp_cat.get("label", "MCP 工具"),
"tools": mcp_tools,
"default_enabled": True,
"silent_when_disabled": False,
}
remove_ids = set(overrides.get("remove_categories") or [])
for rid in remove_ids:
base.pop(rid, None)
@ -227,6 +240,7 @@ def describe_defaults() -> Dict[str, Any]:
"""返回默认(未覆盖)工具分类,用于前端渲染。"""
from core.tool_config import TOOL_CATEGORIES
registry = CustomToolRegistry()
mcp_registry = MCPServerRegistry()
categories = {
key: {
@ -248,6 +262,16 @@ def describe_defaults() -> Dict[str, Any]:
"silent_when_disabled": False,
}
# MCP 工具分类
mcp_cat = build_default_mcp_category()
mcp_cat_id = mcp_cat.get("id", "mcp")
categories[mcp_cat_id] = {
"label": mcp_cat.get("label", "MCP 工具"),
"tools": ["list_mcp_servers", *mcp_registry.build_cached_alias_list()],
"default_enabled": True,
"silent_when_disabled": False,
}
return {
"categories": categories,
"models": sorted(list(_allowed_models())),

View 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

View 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()

View File

@ -18,6 +18,7 @@
| **代码开发** | 编写、调试、重构代码;代码审查;技术方案设计 |
| **数据处理** | 处理表格、分析数据、生成图表和报告 |
| **信息检索** | 网络搜索(`web_search`)、网页内容提取(`extract_webpage`、`save_webpage`)、信息整合 |
| **MCP扩展** | 通过 MCP 服务扩展外部工具;可用 `list_mcp_servers` 查看已配置服务与工具别名 |
| **文件管理** | 批量处理文件、目录操作、自动化任务 |
| **终端操作** | 执行命令(`run_command`、运行Python代码`run_python`)、持久终端会话(`terminal_session` 系列) |
| **视觉理解** | 分析图片内容、识别文字/物体/表格(`vlm_analyze`/`view_image` |
@ -116,6 +117,12 @@
- `extract_webpage`:提取网页正文
- `save_webpage`:保存网页为文本
### 3.6 MCP 工具扩展
- 当你不确定当前有哪些 MCP 工具可用时,先调用 `list_mcp_servers`
- 需要刷新远程工具缓存时,可使用 `list_mcp_servers` 并传 `refresh=true`
- 调用具体 MCP 工具时,使用工具列表中的 `mcp__...` 别名
---
## 4. 禁止做的事情

View File

@ -18,6 +18,7 @@
| **代码开发** | 编写、调试、重构代码;代码审查;技术方案设计 |
| **数据处理** | 处理表格、分析数据、生成图表和报告 |
| **信息检索** | 网络搜索(`web_search`)、网页内容提取(`extract_webpage`、`save_webpage`)、信息整合 |
| **MCP扩展** | 通过 MCP 服务扩展外部工具;可用 `list_mcp_servers` 查看已配置服务与工具别名 |
| **文件管理** | 批量处理文件、目录操作、自动化任务 |
| **终端操作** | 执行命令(`run_command`、运行Python代码`run_python`)、持久终端会话(`terminal_session` 系列) |
| **视觉理解** | 自带多模态能力,可直接分析图片内容、识别文字/物体/表格/场景 |
@ -134,6 +135,12 @@
- `extract_webpage`:提取网页正文
- `save_webpage`:保存网页为文本
### 3.6 MCP 工具扩展
- 当你不确定当前有哪些 MCP 工具可用时,先调用 `list_mcp_servers`
- 需要刷新远程工具缓存时,可使用 `list_mcp_servers` 并传 `refresh=true`
- 调用具体 MCP 工具时,使用工具列表中的 `mcp__...` 别名
---
## 4. 禁止做的事情

View 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
View 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())

View File

@ -8,10 +8,18 @@ from flask import Blueprint, jsonify, send_from_directory, request, current_app,
from werkzeug.security import check_password_hash
from .auth_helpers import login_required, admin_required, admin_api_required, api_login_required, get_current_user_record, resolve_admin_policy
from .context import with_terminal
from .security import rate_limited
from .utils_common import debug_log
from . import state # 使用动态 state确保与入口实例保持一致
from .state import custom_tool_registry, user_manager, container_manager, PROJECT_MAX_STORAGE_MB
from .state import (
custom_tool_registry,
mcp_server_registry,
mcp_client_manager,
user_manager,
container_manager,
PROJECT_MAX_STORAGE_MB,
)
from .conversation import (
build_admin_dashboard_snapshot as _build_dashboard_rich,
compute_workspace_storage,
@ -513,6 +521,72 @@ def admin_custom_tools_reload_api():
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/admin/mcp-servers', methods=['GET', 'POST', 'DELETE'])
@api_login_required
@admin_api_required
def admin_mcp_servers_api():
"""MCP 服务配置管理(仅全局管理员)。"""
guard = _secondary_required()
if guard:
return guard
try:
if request.method == 'GET':
mcp_server_registry.reload()
return jsonify({"success": True, "data": mcp_server_registry.list_servers()})
if request.method == 'POST':
payload = request.get_json() or {}
saved = mcp_server_registry.upsert_server(payload)
return jsonify({"success": True, "data": saved})
# DELETE
server_id = request.args.get("id") or (request.get_json() or {}).get("id")
if not server_id:
return jsonify({"success": False, "error": "缺少 id"}), 400
removed = mcp_server_registry.delete_server(server_id)
if removed:
return jsonify({"success": True, "data": {"deleted": server_id}})
return jsonify({"success": False, "error": "未找到该服务"}), 404
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
logging.exception("mcp-servers API error")
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/admin/mcp-servers/reload', methods=['POST'])
@api_login_required
@admin_api_required
def admin_mcp_servers_reload_api():
guard = _secondary_required()
if guard:
return guard
try:
servers = mcp_server_registry.reload()
return jsonify({"success": True, "data": {"count": len(servers)}})
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
@admin_bp.route('/api/admin/mcp-servers/sync', methods=['POST'])
@api_login_required
@admin_api_required
@with_terminal
def admin_mcp_servers_sync_api(terminal, workspace, username):
"""同步 MCP 服务工具缓存tools/list"""
guard = _secondary_required()
if guard:
return guard
payload = request.get_json() or {}
target_id = (payload.get("id") or "").strip() or None
manager = getattr(terminal, "mcp_client_manager", None) or mcp_client_manager
try:
result = manager.sync_servers(server_id=target_id)
return jsonify({"success": bool(result.get("success", True)), "data": result})
except Exception as exc:
logging.exception("mcp-servers sync API error")
return jsonify({"success": False, "error": str(exc)}), 500
# ------------------ API 用户与监控 ------------------ #
@admin_bp.route('/api/admin/api-dashboard', methods=['GET'])
@api_login_required

View File

@ -9,6 +9,8 @@ from typing import Dict, Any, Optional
from config import LOGS_DIR, PROJECT_MAX_STORAGE_BYTES, PROJECT_MAX_STORAGE_MB
from core.web_terminal import WebTerminal
from modules.custom_tool_registry import CustomToolRegistry
from modules.mcp_server_registry import MCPServerRegistry
from modules.mcp_client_manager import MCPClientManager
from modules.usage_tracker import UsageTracker
from modules.user_container_manager import UserContainerManager
from modules.user_manager import UserManager
@ -19,6 +21,8 @@ from modules.tool_approval_manager import ToolApprovalManager
user_manager = UserManager()
api_user_manager = ApiUserManager()
custom_tool_registry = CustomToolRegistry()
mcp_server_registry = MCPServerRegistry()
mcp_client_manager = MCPClientManager(mcp_server_registry)
container_manager = UserContainerManager()
user_terminals: Dict[str, WebTerminal] = {}
terminal_rooms: Dict[str, set] = {}
@ -76,6 +80,8 @@ __all__ = [
"user_manager",
"api_user_manager",
"custom_tool_registry",
"mcp_server_registry",
"mcp_client_manager",
"container_manager",
"user_terminals",
"terminal_rooms",

12
static/icons/mcp-logo.svg Normal file
View 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

View File

@ -207,6 +207,108 @@
</div>
</section>
<section class="panel">
<div class="panel-title">
<h2>MCP 服务配置统一工具扩展</h2>
<div class="header-actions">
<button type="button" class="ghost-btn" :disabled="mcpLoading" @click="fetchMcpServers">
{{ mcpLoading ? '刷新中...' : '刷新' }}
</button>
<button type="button" class="ghost-btn" :disabled="mcpSyncing" @click="syncAllMcpServers">
{{ mcpSyncing ? '同步中...' : '同步全部工具' }}
</button>
<button type="button" @click="addMcpServer">新增 MCP 服务</button>
</div>
</div>
<div v-if="!mcpServers.length" class="muted">暂无 MCP 服务点击新增 MCP 服务开始配置</div>
<div class="mcp-list" v-else>
<div class="mcp-item" v-for="(server, idx) in mcpServers" :key="server.id || `new-${idx}`">
<div class="mcp-grid">
<label>
<span>ID</span>
<input v-model="server.id" placeholder="如 demo_stdio" />
</label>
<label>
<span>名称</span>
<input v-model="server.name" placeholder="显示名称(可选)" />
</label>
<label>
<span>Transport</span>
<select v-model="server.transport">
<option value="stdio">stdio</option>
<option value="streamable_http">streamable_http</option>
</select>
</label>
<label>
<span>启用</span>
<select v-model="server.enabledText">
<option value="true">true</option>
<option value="false">false</option>
</select>
</label>
<label v-if="server.transport === 'stdio'" class="wide">
<span>command</span>
<input v-model="server.command" placeholder="如 python3" />
</label>
<label v-if="server.transport === 'stdio'" class="wide">
<span>args每行一个留空=</span>
<textarea
v-model="server.argsText"
rows="3"
placeholder="如\n/abs/path/mcp_server.py"
></textarea>
</label>
<label v-if="server.transport === 'stdio'" class="wide">
<span>cwd可选</span>
<input v-model="server.cwd" placeholder="工作目录" />
</label>
<label v-if="server.transport === 'streamable_http'" class="wide">
<span>url</span>
<input v-model="server.url" placeholder="如 http://127.0.0.1:8000/mcp" />
</label>
<label class="wide">
<span>headersJSON可选</span>
<textarea
v-model="server.headersText"
rows="2"
placeholder='如 {"Authorization":"Bearer xxx"}'
></textarea>
</label>
<label class="wide">
<span>envJSON可选stdio 有效</span>
<textarea v-model="server.envText" rows="2" placeholder='如 {"PYTHONUNBUFFERED":"1"}'></textarea>
</label>
<label>
<span>timeout(s)</span>
<input v-model="server.timeoutText" placeholder="25" />
</label>
<label class="wide">
<span>include_tools逗号分隔可选</span>
<input v-model="server.includeToolsText" placeholder="如 echo,read_db" />
</label>
<label class="wide">
<span>exclude_tools逗号分隔可选</span>
<input v-model="server.excludeToolsText" placeholder="如 dangerous_tool" />
</label>
<label class="wide">
<span>说明可选</span>
<input v-model="server.description" placeholder="备注说明" />
</label>
</div>
<div class="mcp-meta">
<span>最近同步{{ server.tools_cache_updated_at || '未同步' }}</span>
<span v-if="server.last_error" class="error-text">错误{{ server.last_error }}</span>
<span>缓存工具数{{ server.tools_cache_count }}</span>
</div>
<div class="mcp-actions">
<button type="button" class="primary" @click="saveMcpServer(server)">保存</button>
<button type="button" class="ghost-btn" @click="syncMcpServer(server.id)">同步工具</button>
<button type="button" class="link danger" @click="deleteMcpServer(server.id, idx)">删除</button>
</div>
</div>
</div>
</section>
<section class="panel">
<div class="panel-title">
<h2>已删除分类</h2>
@ -253,6 +355,26 @@ interface CategoryFormItem {
forced: boolean | null;
}
interface MCPServerFormItem {
id: string;
name: string;
description: string;
transport: 'stdio' | 'streamable_http';
enabledText: 'true' | 'false';
command: string;
argsText: string;
cwd: string;
url: string;
headersText: string;
envText: string;
timeoutText: string;
includeToolsText: string;
excludeToolsText: string;
tools_cache_updated_at: string;
last_error: string;
tools_cache_count: number;
}
const defaults = reactive({
categories: {} as Record<string, any>,
models: [] as string[],
@ -283,6 +405,9 @@ const form = reactive({
const lastUpdated = ref<string | null>(null);
const saving = ref(false);
const policyCache = ref<RawPolicy | null>(null);
const mcpServers = ref<MCPServerFormItem[]>([]);
const mcpLoading = ref(false);
const mcpSyncing = ref(false);
const targetPlaceholder = computed(() => {
if (form.target_type === 'role') return '如admin / user';
@ -534,6 +659,204 @@ function rebuildCategoryOverrides() {
form.config.category_overrides = map;
}
function splitCsv(value: string): string[] {
return (value || '')
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function linesToList(value: string): string[] {
return (value || '')
.split('\n')
.map((item) => item.trim())
.filter(Boolean);
}
function parseJsonDict(label: string, raw: string): Record<string, string> {
const text = (raw || '').trim();
if (!text) return {};
let parsed: any;
try {
parsed = JSON.parse(text);
} catch (error: any) {
throw new Error(`${label} 不是合法 JSON${error?.message || error}`);
}
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} 必须是 JSON 对象`);
}
const output: Record<string, string> = {};
Object.entries(parsed).forEach(([k, v]) => {
const key = String(k || '').trim();
if (!key) return;
output[key] = String(v ?? '');
});
return output;
}
function mapMcpServer(raw: any): MCPServerFormItem {
const transport = raw?.transport === 'streamable_http' ? 'streamable_http' : 'stdio';
const args = Array.isArray(raw?.args) ? raw.args.filter((x: any) => typeof x === 'string') : [];
const toolsCache = Array.isArray(raw?.tools_cache) ? raw.tools_cache : [];
return {
id: String(raw?.id || ''),
name: String(raw?.name || raw?.id || ''),
description: String(raw?.description || ''),
transport,
enabledText: raw?.enabled === false ? 'false' : 'true',
command: String(raw?.command || ''),
argsText: args.join('\n'),
cwd: String(raw?.cwd || ''),
url: String(raw?.url || ''),
headersText: raw?.headers && typeof raw.headers === 'object' ? JSON.stringify(raw.headers, null, 2) : '',
envText: raw?.env && typeof raw.env === 'object' ? JSON.stringify(raw.env, null, 2) : '',
timeoutText: String(raw?.timeout_seconds ?? 25),
includeToolsText: Array.isArray(raw?.include_tools) ? raw.include_tools.join(',') : '',
excludeToolsText: Array.isArray(raw?.exclude_tools) ? raw.exclude_tools.join(',') : '',
tools_cache_updated_at: String(raw?.tools_cache_updated_at || ''),
last_error: String(raw?.last_error || ''),
tools_cache_count: toolsCache.length
};
}
function normalizeMcpPayload(server: MCPServerFormItem) {
const id = server.id.trim();
if (!id) throw new Error('MCP 服务 ID 不能为空');
return {
id,
name: server.name.trim() || id,
description: server.description.trim(),
transport: server.transport,
enabled: server.enabledText === 'true',
command: server.command.trim(),
args: linesToList(server.argsText),
cwd: server.cwd.trim(),
url: server.url.trim(),
headers: parseJsonDict('headers', server.headersText),
env: parseJsonDict('env', server.envText),
timeout_seconds: Number(server.timeoutText || 25),
include_tools: splitCsv(server.includeToolsText),
exclude_tools: splitCsv(server.excludeToolsText)
};
}
async function fetchMcpServers() {
if (!secondaryVerified.value) return;
mcpLoading.value = true;
try {
const resp = await fetch('/api/admin/mcp-servers', { credentials: 'same-origin' });
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '加载 MCP 服务失败');
}
mcpServers.value = (data.data || []).map((item: any) => mapMcpServer(item));
} catch (error: any) {
banner.message = error?.message || '加载 MCP 服务失败';
banner.type = 'error';
} finally {
mcpLoading.value = false;
}
}
function addMcpServer() {
mcpServers.value.unshift(
mapMcpServer({
id: '',
name: '',
transport: 'stdio',
enabled: true,
timeout_seconds: 25
})
);
}
async function saveMcpServer(server: MCPServerFormItem) {
if (!secondaryVerified.value) return;
try {
const payload = normalizeMcpPayload(server);
const resp = await fetch('/api/admin/mcp-servers', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '保存 MCP 服务失败');
}
banner.message = `MCP 服务 ${payload.id} 已保存`;
banner.type = 'success';
await syncMcpServer(payload.id, true);
} catch (error: any) {
banner.message = error?.message || '保存 MCP 服务失败';
banner.type = 'error';
}
}
async function deleteMcpServer(id: string, idx: number) {
if (!secondaryVerified.value) return;
const target = String(id || '').trim();
if (!target) {
mcpServers.value.splice(idx, 1);
return;
}
try {
const resp = await fetch(`/api/admin/mcp-servers?id=${encodeURIComponent(target)}`, {
method: 'DELETE',
credentials: 'same-origin'
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '删除 MCP 服务失败');
}
banner.message = `已删除 MCP 服务 ${target}`;
banner.type = 'success';
await fetchMcpServers();
await fetchDefaults();
} catch (error: any) {
banner.message = error?.message || '删除 MCP 服务失败';
banner.type = 'error';
}
}
async function syncMcpServer(id?: string, silent = false) {
if (!secondaryVerified.value) return;
const target = String(id || '').trim();
if (typeof id !== 'undefined' && !target) {
banner.message = '请先保存服务后再同步工具';
banner.type = 'error';
return;
}
if (!silent) mcpSyncing.value = true;
try {
const resp = await fetch('/api/admin/mcp-servers/sync', {
method: 'POST',
credentials: 'same-origin',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(target ? { id: target } : {})
});
const data = await resp.json();
if (!resp.ok || !data.success) {
throw new Error(data.error || '同步 MCP 工具失败');
}
if (!silent) {
banner.message = target ? `已同步 ${target}` : '已同步全部 MCP 服务';
banner.type = 'success';
}
await fetchMcpServers();
await fetchDefaults();
} catch (error: any) {
banner.message = error?.message || '同步 MCP 工具失败';
banner.type = 'error';
} finally {
if (!silent) mcpSyncing.value = false;
}
}
async function syncAllMcpServers() {
await syncMcpServer();
}
async function fetchDefaults() {
if (!secondaryVerified.value) return;
const resp = await fetch('/api/admin/policy', { credentials: 'same-origin' });
@ -547,7 +870,6 @@ async function fetchDefaults() {
policyCache.value = data.data;
lastUpdated.value = data.data?.updated_at || null;
applyScopeConfig();
banner.message = '';
}
function scopeConfig(): any {
@ -626,7 +948,7 @@ const handleVerifySecondary = async () => {
await verifySecondary(secondaryPassword.value);
if (secondaryVerified.value) {
secondaryPassword.value = '';
fetchDefaults().catch((err) => {
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
@ -638,7 +960,7 @@ onMounted(async () => {
document.addEventListener('click', handleDocClick);
await checkSecondary();
if (secondaryVerified.value) {
fetchDefaults().catch((err) => {
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
@ -648,7 +970,7 @@ onMounted(async () => {
watch(secondaryVerified, (val) => {
if (val) {
fetchDefaults().catch((err) => {
Promise.all([fetchDefaults(), fetchMcpServers()]).catch((err) => {
console.error(err);
banner.message = err?.message || '加载策略失败';
banner.type = 'error';
@ -789,6 +1111,67 @@ onBeforeUnmount(() => {
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.4);
}
.mcp-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.mcp-item {
border: 1px solid rgba(118, 103, 84, 0.2);
border-radius: 14px;
padding: 12px;
background: rgba(255, 255, 255, 0.92);
}
.mcp-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 10px;
}
.mcp-grid label {
display: flex;
flex-direction: column;
gap: 6px;
color: #5b4d3b;
font-size: 13px;
}
.mcp-grid label.wide {
grid-column: 1 / -1;
}
.mcp-grid textarea,
.mcp-grid input,
.mcp-grid select {
width: 100%;
box-sizing: border-box;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(0, 0, 0, 0.12);
background: #fff;
}
.mcp-meta {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
color: #8a7a62;
font-size: 12px;
}
.mcp-actions {
margin-top: 8px;
display: flex;
gap: 8px;
}
.error-text {
color: #b05b3c;
}
.panel-title {
display: flex;
align-items: center;

View File

@ -1378,6 +1378,40 @@
</div>
</div>
</section>
<section
v-else-if="activeTab === 'mcp-config'"
key="mcp-config"
class="personal-page admin-monitor-page"
>
<div class="admin-monitor-panel secondary">
<div class="admin-monitor-heading">
<p class="admin-monitor-eyebrow">MCP 配置</p>
<div class="admin-monitor-title-row">
<h3>MCP 服务器管理</h3>
<span class="admin-monitor-chip alt">宿主机模式 · 管理员</span>
</div>
<p class="admin-monitor-desc">
统一配置 MCP Server支持新增修改同步与重载集中管理工具扩展能力
</p>
</div>
<div class="admin-monitor-highlights">
<span class="admin-monitor-tag">统一配置</span>
<span class="admin-monitor-tag">工具同步</span>
<span class="admin-monitor-tag">即时生效</span>
<span class="admin-monitor-tag">仅宿主机管理员</span>
</div>
<div class="admin-monitor-actions">
<button
type="button"
class="admin-monitor-button"
@click="openMcpConfig"
>
打开 MCP 配置页
</button>
<p class="admin-monitor-hint">新窗口打开 /admin/policy 页面</p>
</div>
</div>
</section>
<section
v-else-if="activeTab === 'admin-monitor'"
key="admin-monitor"
@ -1521,9 +1555,25 @@ type PersonalTab =
| 'image'
| 'theme'
| 'tutorial'
| 'mcp-config'
| 'admin-monitor';
const isAdmin = computed(() => (resourceStore.usageQuota.role || '').toLowerCase() === 'admin');
const sessionRole = ref('');
const sessionHostMode = ref(false);
const isAdmin = computed(() => {
const quotaRole = (resourceStore.usageQuota.role || '').toLowerCase();
const loginRole = (sessionRole.value || '').toLowerCase();
return quotaRole === 'admin' || loginRole === 'admin';
});
const isHostMode = computed(() => {
const mode = (resourceStore.containerStatus?.mode || '').toLowerCase();
if (mode) {
return mode === 'host';
}
return sessionHostMode.value;
});
const showMcpConfigEntry = computed(() => isAdmin.value && isHostMode.value);
const isAppShell = computed(() => {
if (typeof window === 'undefined') return false;
const params = new URLSearchParams(window.location.search);
@ -1533,6 +1583,9 @@ const isAppShell = computed(() => {
const personalTabs = computed(() => {
const tabs = baseTabs.filter((tab) => (tab.id === 'app-update' ? isAppShell.value : true));
if (showMcpConfigEntry.value) {
tabs.push({ id: 'mcp-config' as const, label: 'MCP 配置', description: 'Server 管理' });
}
if (isAdmin.value) {
tabs.push({ id: 'admin-monitor' as const, label: '管理员监控', description: '系统总览' });
}
@ -1805,8 +1858,18 @@ const downloadLatestApp = () => {
window.location.href = url;
};
onMounted(() => {
onMounted(async () => {
hydrateAppVersionFromBridge();
try {
const resp = await fetch('/api/session-status', { credentials: 'same-origin' });
if (!resp.ok) return;
const payload = await resp.json();
const snapshot = payload?.session || {};
sessionRole.value = String(snapshot.role || '');
sessionHostMode.value = !!snapshot.host_mode;
} catch (_err) {
sessionRole.value = '';
}
});
watch(
@ -1999,6 +2062,11 @@ const openApiAdmin = () => {
personalization.closeDrawer();
};
const openMcpConfig = () => {
window.open('/admin/policy', '_blank', 'noopener');
personalization.closeDrawer();
};
// ===== =====
const { setTheme, loadTheme } = useTheme();
const themeOptions: Array<{ id: ThemeKey; label: string; desc: string; swatches: string[] }> = [

View File

@ -94,6 +94,9 @@ const RELATIVE_TIME_RANGE_MAP: Record<string, string> = {
export function getToolIcon(tool: any): string {
const toolName = typeof tool === 'string' ? tool : tool?.name;
if (typeof toolName === 'string' && toolName.startsWith('mcp__')) {
return 'mcpLogo';
}
return TOOL_ICON_MAP[toolName as keyof typeof TOOL_ICON_MAP] || 'settings';
}

View File

@ -21,6 +21,7 @@ export const ICONS = Object.freeze({
layers: '/static/icons/layers.svg',
keyboard: '/static/icons/keyboard.svg',
menu: '/static/icons/menu.svg',
mcpLogo: '/static/icons/mcp-logo.svg',
monitor: '/static/icons/monitor.svg',
octagon: '/static/icons/octagon.svg',
pencil: '/static/icons/pencil.svg',
@ -71,12 +72,14 @@ export const TOOL_ICON_MAP = Object.freeze({
get_sub_agent_status: 'bot',
web_search: 'search',
trigger_easter_egg: 'sparkles',
list_mcp_servers: 'mcpLogo',
view_image: 'camera',
view_video: 'eye'
});
export const TOOL_CATEGORY_ICON_MAP = Object.freeze({
network: 'globe',
mcp: 'mcpLogo',
file_edit: 'pencil',
personalization: 'userPen',
read_focus: 'eye',

View 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()