fix(mcp): split categories by server and preserve toggle states

This commit is contained in:
JOJO 2026-04-27 01:42:40 +08:00
parent 2c75bb01a0
commit 3c1773dc01
7 changed files with 292 additions and 23 deletions

View File

@ -63,7 +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
from modules.mcp_server_registry import MCPServerRegistry, build_default_mcp_category
try:
from config.limits import THINKING_FAST_INTERVAL
@ -196,22 +196,37 @@ class MainTerminalToolsDefinitionMixin:
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.tool_categories_map["mcp"].tools = ["list_mcp_servers"]
stale_keys = [
key for key in list(getattr(self, "tool_categories_map", {}).keys())
if isinstance(key, str) and key.startswith("mcp_server__")
]
for key in stale_keys:
self.tool_categories_map.pop(key, None)
self.tool_category_states.pop(key, None)
self._refresh_disabled_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
self.tool_categories_map["mcp"].tools = ["list_mcp_servers"]
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,
tools=["list_mcp_servers"],
default_enabled=True,
silent_when_disabled=False,
)
stale_keys = [
key for key in list(getattr(self, "tool_categories_map", {}).keys())
if isinstance(key, str) and key.startswith("mcp_server__")
]
for key in stale_keys:
self.tool_categories_map.pop(key, None)
self.tool_category_states.pop(key, None)
self._refresh_disabled_tools()
return []
# 类别层面的禁用优先生效(即使策略里尚未填充具体工具列表)
mcp_cat = getattr(self, "tool_categories_map", {}).get("mcp")
@ -222,7 +237,15 @@ class MainTerminalToolsDefinitionMixin:
mcp_enabled = forced
if not mcp_enabled:
self.mcp_tool_alias_map = {}
self.tool_categories_map["mcp"].tools = []
self.tool_categories_map["mcp"].tools = ["list_mcp_servers"]
stale_keys = [
key for key in list(getattr(self, "tool_categories_map", {}).keys())
if isinstance(key, str) and key.startswith("mcp_server__")
]
for key in stale_keys:
self.tool_categories_map.pop(key, None)
self.tool_category_states.pop(key, None)
self._refresh_disabled_tools()
return []
manager = getattr(self, "mcp_client_manager", None)
if manager is None:
@ -238,22 +261,97 @@ class MainTerminalToolsDefinitionMixin:
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 分类列表,避免策略页显示旧数据
alias_map = alias_map or {}
if "mcp" in self.tool_categories_map:
self.tool_categories_map["mcp"].tools = mcp_category_tools
self.tool_categories_map["mcp"].tools = ["list_mcp_servers"]
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,
tools=["list_mcp_servers"],
default_enabled=True,
silent_when_disabled=False,
)
server_aliases: Dict[str, List[str]] = {}
for alias, binding in alias_map.items():
sid = str(getattr(binding, "server_id", "") or "").strip()
if not sid:
continue
server_aliases.setdefault(sid, []).append(alias)
return tool_defs
enabled_servers = []
try:
enabled_servers = list(self.mcp_server_registry.list_enabled_servers())
except Exception:
enabled_servers = []
active_server_category_ids = set()
for server in enabled_servers:
sid = str(server.get("id") or "").strip()
if not sid:
continue
cat_id = MCPServerRegistry.make_server_category_id(sid)
active_server_category_ids.add(cat_id)
aliases = list(server_aliases.get(sid, []))
existing = self.tool_categories_map.get(cat_id)
label = MCPServerRegistry.make_server_category_label(server)
if existing is not None:
existing.label = label
existing.tools = aliases
else:
self.tool_categories_map[cat_id] = type(next(iter(TOOL_CATEGORIES.values())))(
label=label,
tools=aliases,
default_enabled=True,
silent_when_disabled=False,
)
if cat_id not in self.tool_category_states:
self.tool_category_states[cat_id] = True
stale_keys = [
key for key in list(self.tool_categories_map.keys())
if isinstance(key, str)
and key.startswith("mcp_server__")
and key not in active_server_category_ids
]
for key in stale_keys:
self.tool_categories_map.pop(key, None)
self.tool_category_states.pop(key, None)
if hasattr(self, "_prune_runtime_tool_overrides"):
self._prune_runtime_tool_overrides()
if hasattr(self, "_apply_runtime_tool_overrides"):
self._apply_runtime_tool_overrides()
filtered_tools: List[Dict[str, Any]] = []
filtered_alias_map: Dict[str, Any] = {}
for tool_def in tool_defs:
alias = str(((tool_def or {}).get("function") or {}).get("name") or "").strip()
if not alias:
continue
binding = alias_map.get(alias)
if binding is None:
continue
sid = str(getattr(binding, "server_id", "") or "").strip()
if not sid:
continue
cat_id = MCPServerRegistry.make_server_category_id(sid)
cat = self.tool_categories_map.get(cat_id)
cat_enabled = self.tool_category_states.get(
cat_id,
getattr(cat, "default_enabled", True) if cat is not None else True,
)
forced = getattr(self, "admin_forced_category_states", {}).get(cat_id)
if isinstance(forced, bool):
cat_enabled = forced
if not cat_enabled:
continue
filtered_tools.append(tool_def)
filtered_alias_map[alias] = binding
self.mcp_tool_alias_map = filtered_alias_map
self._refresh_disabled_tools()
return filtered_tools
def define_tools(self) -> List[Dict]:
"""定义可用工具(添加确认工具)"""

View File

@ -87,6 +87,34 @@ DISABLE_LENGTH_CHECK = True
PERMISSION_MODES = {"readonly", "approval", "unrestricted"}
class MainTerminalToolsPolicyMixin:
def _ensure_runtime_tool_overrides(self) -> Dict[str, bool]:
overrides = getattr(self, "runtime_tool_category_overrides", None)
if not isinstance(overrides, dict):
overrides = {}
self.runtime_tool_category_overrides = overrides
return overrides
def _prune_runtime_tool_overrides(self) -> None:
overrides = self._ensure_runtime_tool_overrides()
valid_keys = set(getattr(self, "tool_categories_map", {}).keys())
for key in list(overrides.keys()):
if key not in valid_keys:
overrides.pop(key, None)
def _apply_runtime_tool_overrides(self) -> None:
overrides = self._ensure_runtime_tool_overrides()
if not overrides:
return
categories = getattr(self, "tool_categories_map", {}) or {}
for key, value in list(overrides.items()):
if key not in categories:
overrides.pop(key, None)
continue
forced = getattr(self, "admin_forced_category_states", {}).get(key)
if isinstance(forced, bool):
continue
self.tool_category_states[key] = bool(value)
def apply_personalization_preferences(self, config: Optional[Dict[str, Any]] = None):
"""Apply persisted personalization settings that affect runtime behavior."""
try:
@ -141,6 +169,8 @@ class MainTerminalToolsPolicyMixin:
# Reset category states to defaults before applying overrides
for key, category in self.tool_categories_map.items():
self.tool_category_states[key] = False if key in disabled_categories else category.default_enabled
self._prune_runtime_tool_overrides()
self._apply_runtime_tool_overrides()
self._refresh_disabled_tools()
# 默认模型偏好(优先应用,再处理运行模式)
@ -205,7 +235,9 @@ class MainTerminalToolsPolicyMixin:
forced = self.admin_forced_category_states.get(category)
if isinstance(forced, bool) and forced != enabled:
raise ValueError("该类别被管理员强制为启用/禁用,无法修改")
self.tool_category_states[category] = bool(enabled)
final_enabled = bool(enabled)
self.tool_category_states[category] = final_enabled
self._ensure_runtime_tool_overrides()[category] = final_enabled
self._refresh_disabled_tools()
def set_admin_policy(
@ -229,10 +261,12 @@ class MainTerminalToolsPolicyMixin:
default_mcp_cat = build_default_mcp_category()
self.tool_categories_map["mcp"] = type(next(iter(TOOL_CATEGORIES.values())))(
label=default_mcp_cat["label"],
tools=[],
tools=["list_mcp_servers"],
default_enabled=True,
silent_when_disabled=False,
)
elif "mcp" in self.tool_categories_map:
self.tool_categories_map["mcp"].tools = ["list_mcp_servers"]
# 重新构建启用状态映射,保留已有值
new_states: Dict[str, bool] = {}
for key, cat in self.tool_categories_map.items():
@ -248,6 +282,8 @@ class MainTerminalToolsPolicyMixin:
self.admin_forced_category_states = forced_category_states or {}
self.admin_disabled_models = disabled_models or []
self._prune_runtime_tool_overrides()
self._apply_runtime_tool_overrides()
self._refresh_disabled_tools()
def get_tool_settings_snapshot(self) -> List[Dict[str, object]]:

View File

@ -172,10 +172,20 @@ def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, D
# 注入 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,
"tools": ["list_mcp_servers"],
"default_enabled": True,
"silent_when_disabled": False,
}
for server in mcp_registry.list_enabled_servers():
sid = str(server.get("id") or "").strip()
if not sid:
continue
cat_id = MCPServerRegistry.make_server_category_id(sid)
base[cat_id] = {
"label": MCPServerRegistry.make_server_category_label(server),
"tools": mcp_registry.build_cached_alias_list(server_id=sid),
"default_enabled": True,
"silent_when_disabled": False,
}
@ -200,6 +210,33 @@ def _collect_categories_with_overrides(overrides: Dict[str, Any]) -> Dict[str, D
"default_enabled": default_enabled,
"silent_when_disabled": bool(payload.get("silent_when_disabled", False)),
}
# MCP 分类由运行时动态决定:基础 mcp 仅保留原生 list_mcp_servers
# 每个 mcp_server__* 分类独立承载该服务别名工具,避免旧策略覆盖造成错乱。
base["mcp"] = {
**(base.get("mcp") or {}),
"label": (base.get("mcp") or {}).get("label") or mcp_cat.get("label", "MCP 工具"),
"tools": ["list_mcp_servers"],
"default_enabled": bool((base.get("mcp") or {}).get("default_enabled", True)),
"silent_when_disabled": bool((base.get("mcp") or {}).get("silent_when_disabled", False)),
}
active_server_categories = set()
for server in mcp_registry.list_enabled_servers():
sid = str(server.get("id") or "").strip()
if not sid:
continue
cat_id = MCPServerRegistry.make_server_category_id(sid)
active_server_categories.add(cat_id)
existing = base.get(cat_id) or {}
base[cat_id] = {
"label": MCPServerRegistry.make_server_category_label(server),
"tools": mcp_registry.build_cached_alias_list(server_id=sid),
"default_enabled": bool(existing.get("default_enabled", True)),
"silent_when_disabled": bool(existing.get("silent_when_disabled", False)),
}
for cid in list(base.keys()):
if isinstance(cid, str) and cid.startswith("mcp_server__") and cid not in active_server_categories:
base.pop(cid, None)
return base
@ -267,7 +304,18 @@ def describe_defaults() -> Dict[str, Any]:
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()],
"tools": ["list_mcp_servers"],
"default_enabled": True,
"silent_when_disabled": False,
}
for server in mcp_registry.list_enabled_servers():
sid = str(server.get("id") or "").strip()
if not sid:
continue
cat_id = MCPServerRegistry.make_server_category_id(sid)
categories[cat_id] = {
"label": MCPServerRegistry.make_server_category_label(server),
"tools": mcp_registry.build_cached_alias_list(server_id=sid),
"default_enabled": True,
"silent_when_disabled": False,
}

View File

@ -106,6 +106,24 @@ class MCPServerRegistry:
remain = max(8, max_len - len(prefix))
return f"{prefix}{tid[:remain]}"
@classmethod
def make_server_category_id(cls, server_id: str, *, max_len: int = 64) -> str:
sid = cls._safe_slug(server_id, "server")
prefix = "mcp_server__"
remain = max(8, max_len - len(prefix))
return f"{prefix}{sid[:remain]}"
@classmethod
def make_server_category_label(cls, server: Any) -> str:
if isinstance(server, dict):
name = str(server.get("name") or server.get("id") or "").strip()
sid = str(server.get("id") or "").strip()
else:
name = str(server or "").strip()
sid = ""
target = name or sid or "未命名服务"
return f"MCP · {target}"
@staticmethod
def build_default_mcp_category() -> Dict[str, Any]:
return {

View File

@ -605,11 +605,15 @@ def _sanitize_tool_categories(value: Any, allowed: set) -> list:
if not isinstance(value, list):
return []
result = []
dynamic_prefixes = ("mcp_server__",)
for item in value:
if not isinstance(item, str):
continue
candidate = item.strip()
if not candidate or candidate not in allowed:
if not candidate:
continue
dynamic_ok = candidate == "custom" or any(candidate.startswith(prefix) for prefix in dynamic_prefixes)
if candidate not in allowed and not dynamic_ok:
continue
if candidate not in result:
result.append(candidate)

View File

@ -149,6 +149,47 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
terminal.user_role = "admin"
if has_request_context():
session['workspace_id'] = getattr(workspace, "workspace_id", None)
# 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类)
try:
from core.tool_config import ToolCategory
from modules import admin_policy_manager
record = get_current_user_record()
role = get_current_user_role(record) if record else "admin"
invite_code = getattr(record, "invite_code", None) if record else None
policy = admin_policy_manager.get_effective_policy(
record.username if record else username,
role,
invite_code,
)
categories_map = {
cid: ToolCategory(
label=cat.get("label") or cid,
tools=list(cat.get("tools") or []),
default_enabled=bool(cat.get("default_enabled", True)),
silent_when_disabled=bool(cat.get("silent_when_disabled", False)),
)
for cid, cat in policy.get("categories", {}).items()
}
forced_states = policy.get("forced_category_states") or {}
disabled_models = policy.get("disabled_models") or []
terminal.set_admin_policy(categories_map, forced_states, disabled_models)
terminal.admin_policy_ui_blocks = policy.get("ui_blocks") or {}
terminal.admin_policy_version = policy.get("updated_at")
if terminal.model_key in disabled_models:
for candidate in get_registered_model_keys(visible_only=True):
if candidate not in disabled_models:
try:
terminal.set_model(candidate)
if has_request_context():
session["model_key"] = terminal.model_key
break
except Exception:
continue
except Exception as exc:
debug_log(f"[admin_policy][host_mode] 应用失败: {exc}")
_ensure_workspace_skills_synced(terminal, workspace)
return terminal, workspace

View File

@ -17,6 +17,9 @@ import { debugLog } from './common';
export const toolingMethods = {
toolCategoryIcon(categoryId) {
if (typeof categoryId === 'string' && categoryId.startsWith('mcp_server__')) {
return this.toolCategoryIcons.mcp || 'settings';
}
return this.toolCategoryIcons[categoryId] || 'settings';
},
@ -142,13 +145,34 @@ export const toolingMethods = {
locked: !!item.locked,
locked_state: typeof item.locked_state === 'boolean' ? item.locked_state : null
}));
let ordered = normalized;
const mcpServerCategories = normalized
.filter((item) => typeof item.id === 'string' && item.id.startsWith('mcp_server__'))
.sort((a, b) => String(a.label || '').localeCompare(String(b.label || '')));
if (mcpServerCategories.length > 0) {
const nonMcpServer = normalized.filter(
(item) => !(typeof item.id === 'string' && item.id.startsWith('mcp_server__'))
);
const mcpIndex = nonMcpServer.findIndex((item) => item.id === 'mcp');
if (mcpIndex >= 0) {
ordered = [
...nonMcpServer.slice(0, mcpIndex + 1),
...mcpServerCategories,
...nonMcpServer.slice(mcpIndex + 1)
];
} else {
ordered = [...mcpServerCategories, ...nonMcpServer];
}
}
debugLog('[ToolSettings] Snapshot applied', {
received: categories.length,
normalized,
anyEnabled: normalized.some((cat) => cat.enabled),
toolExamples: normalized.slice(0, 3)
normalized: ordered,
anyEnabled: ordered.some((cat) => cat.enabled),
toolExamples: ordered.slice(0, 3)
});
this.toolSetSettings(normalized);
this.toolSetSettings(ordered);
this.toolSetSettingsLoading(false);
},