diff --git a/core/main_terminal_parts/tools_definition.py b/core/main_terminal_parts/tools_definition.py index 0f0c4ce..a2bc422 100644 --- a/core/main_terminal_parts/tools_definition.py +++ b/core/main_terminal_parts/tools_definition.py @@ -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]: """定义可用工具(添加确认工具)""" diff --git a/core/main_terminal_parts/tools_policy.py b/core/main_terminal_parts/tools_policy.py index 4528c24..088b361 100644 --- a/core/main_terminal_parts/tools_policy.py +++ b/core/main_terminal_parts/tools_policy.py @@ -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]]: diff --git a/modules/admin_policy_manager.py b/modules/admin_policy_manager.py index f1a0000..683c9f9 100644 --- a/modules/admin_policy_manager.py +++ b/modules/admin_policy_manager.py @@ -172,13 +172,23 @@ 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, + } remove_ids = set(overrides.get("remove_categories") or []) for rid in remove_ids: @@ -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,10 +304,21 @@ 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, + } return { "categories": categories, diff --git a/modules/mcp_server_registry.py b/modules/mcp_server_registry.py index fa9e646..32bab30 100644 --- a/modules/mcp_server_registry.py +++ b/modules/mcp_server_registry.py @@ -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 { diff --git a/modules/personalization_manager.py b/modules/personalization_manager.py index da3bf40..bd16232 100644 --- a/modules/personalization_manager.py +++ b/modules/personalization_manager.py @@ -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) diff --git a/server/context.py b/server/context.py index 6e1f283..61f9147 100644 --- a/server/context.py +++ b/server/context.py @@ -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 diff --git a/static/src/app/methods/tooling.ts b/static/src/app/methods/tooling.ts index 0762595..fda3f88 100644 --- a/static/src/app/methods/tooling.ts +++ b/static/src/app/methods/tooling.ts @@ -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); },