diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index 18f4d47e0..da034937f 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -13,6 +13,7 @@ ) from astrbot import logger +from astrbot.core.agent.mcp_scope import strip_mcp_scope_fields from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.utils.log_pipe import LogPipe @@ -42,6 +43,7 @@ def _prepare_config(config: dict) -> dict: first_key = next(iter(config["mcpServers"])) config = config["mcpServers"][first_key] config.pop("active", None) + strip_mcp_scope_fields(config) return config @@ -364,7 +366,12 @@ class MCPTool(FunctionTool, Generic[TContext]): """A function tool that calls an MCP service.""" def __init__( - self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs + self, + mcp_tool: mcp.Tool, + mcp_client: MCPClient, + mcp_server_name: str, + mcp_server_scopes: tuple[str, ...] | None = None, + **kwargs, ) -> None: super().__init__( name=mcp_tool.name, @@ -374,6 +381,7 @@ def __init__( self.mcp_tool = mcp_tool self.mcp_client = mcp_client self.mcp_server_name = mcp_server_name + self.mcp_server_scopes = mcp_server_scopes async def call( self, context: ContextWrapper[TContext], **kwargs diff --git a/astrbot/core/agent/mcp_scope.py b/astrbot/core/agent/mcp_scope.py new file mode 100644 index 000000000..fe60e9b0d --- /dev/null +++ b/astrbot/core/agent/mcp_scope.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from collections.abc import Iterable, Mapping +from typing import Any + +MCP_SCOPE_FIELDS = ("scopes", "agent_scope") +MCP_SCOPE_WILDCARDS = {"*", "all"} + + +def _normalize_scope_token(value: Any) -> str | None: + token = str(value).strip().lower() + return token or None + + +def normalize_mcp_scope_value(raw_scope: Any) -> tuple[str, ...] | None: + """Normalize a raw MCP scope value. + + Returns: + - None: no scope restriction (visible to all agents). + - tuple[str, ...]: explicit allow list; empty tuple means visible to none. + """ + if raw_scope is None: + return None + + if isinstance(raw_scope, str): + values: Iterable[Any] = [raw_scope] + elif isinstance(raw_scope, Mapping): + return () + elif isinstance(raw_scope, Iterable): + values = raw_scope + else: + return () + + normalized: list[str] = [] + for value in values: + token = _normalize_scope_token(value) + if not token: + continue + if token in MCP_SCOPE_WILDCARDS: + return ("*",) + if token not in normalized: + normalized.append(token) + return tuple(normalized) + + +def get_mcp_scopes_from_config( + config: Mapping[str, Any] | None, +) -> tuple[str, ...] | None: + """Extract and normalize MCP scope config from server config.""" + if not isinstance(config, Mapping): + return None + + raw_scope = config.get("scopes", None) + if raw_scope is None: + raw_scope = config.get("agent_scope", None) + + return normalize_mcp_scope_value(raw_scope) + + +def strip_mcp_scope_fields(config: dict[str, Any]) -> None: + """Remove MCP scope-only fields before passing config to MCP transport layer.""" + for key in MCP_SCOPE_FIELDS: + config.pop(key, None) + + +def is_scope_allowed_for_agent( + scopes: tuple[str, ...] | None, + agent_name: str | None, +) -> bool: + """Return whether an agent can see a tool with the given scopes.""" + if scopes is None: + return True + + if "*" in scopes: + return True + + normalized_agent = _normalize_scope_token(agent_name) + if not normalized_agent: + return False + return normalized_agent in scopes + + +def is_mcp_tool_visible_to_agent(tool: Any, agent_name: str | None) -> bool: + """Return whether an MCP tool is visible to the target agent.""" + return is_scope_allowed_for_agent( + normalize_mcp_scope_value(getattr(tool, "mcp_server_scopes", None)), + agent_name, + ) diff --git a/astrbot/core/astr_agent_tool_exec.py b/astrbot/core/astr_agent_tool_exec.py index 230faaf1c..30ede78f0 100644 --- a/astrbot/core/astr_agent_tool_exec.py +++ b/astrbot/core/astr_agent_tool_exec.py @@ -10,6 +10,7 @@ from astrbot import logger from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool +from astrbot.core.agent.mcp_scope import is_mcp_tool_visible_to_agent from astrbot.core.agent.message import Message from astrbot.core.agent.run_context import ContextWrapper from astrbot.core.agent.tool import FunctionTool, ToolSet @@ -95,14 +96,23 @@ async def _execute_handoff( # make toolset for the agent tools = tool.agent.tools + target_agent_name = tool.agent.name or "" if tools: toolset = ToolSet() for t in tools: if isinstance(t, str): _t = llm_tools.get_func(t) + if isinstance(_t, MCPTool) and not is_mcp_tool_visible_to_agent( + _t, target_agent_name + ): + continue if _t: toolset.add_tool(_t) elif isinstance(t, FunctionTool): + if isinstance(t, MCPTool) and not is_mcp_tool_visible_to_agent( + t, target_agent_name + ): + continue toolset.add_tool(t) else: toolset = None diff --git a/astrbot/core/astr_main_agent.py b/astrbot/core/astr_main_agent.py index fddde68d9..4838f7d39 100644 --- a/astrbot/core/astr_main_agent.py +++ b/astrbot/core/astr_main_agent.py @@ -14,6 +14,7 @@ from astrbot.core import logger from astrbot.core.agent.handoff import HandoffTool from astrbot.core.agent.mcp_client import MCPTool +from astrbot.core.agent.mcp_scope import is_mcp_tool_visible_to_agent from astrbot.core.agent.message import TextPart from astrbot.core.agent.tool import ToolSet from astrbot.core.astr_agent_context import AgentContextWrapper, AstrAgentContext @@ -392,6 +393,12 @@ async def _ensure_persona_and_skills( tool.name for tool in tmgr.func_list if not isinstance(tool, HandoffTool) + and ( + not isinstance(tool, MCPTool) + or is_mcp_tool_visible_to_agent( + tool, str(a.get("name", "")) + ) + ) ] ) continue @@ -400,6 +407,13 @@ async def _ensure_persona_and_skills( for t in tools: name = str(t).strip() if name: + tool = tmgr.get_func(name) + if isinstance( + tool, MCPTool + ) and not is_mcp_tool_visible_to_agent( + tool, str(a.get("name", "")) + ): + continue assigned_tools.add(name) if req.func_tool is None: @@ -748,36 +762,84 @@ def _sanitize_context_by_modalities( req.contexts = sanitized_contexts -def _plugin_tool_fix(event: AstrMessageEvent, req: ProviderRequest) -> None: +def _filter_visible_mcp_tools( + tools: ToolSet | None, + agent_name: str, +) -> ToolSet | None: + if not tools: + return tools + + filtered = ToolSet() + for tool in tools.tools: + if isinstance(tool, MCPTool) and not is_mcp_tool_visible_to_agent( + tool, agent_name + ): + continue + filtered.add_tool(tool) + return filtered + + +def _filter_tools_by_plugins( + tools: ToolSet, + plugins_name: set[str], +) -> ToolSet: + filtered = ToolSet() + for tool in tools.tools: + if isinstance(tool, MCPTool): + # MCP visibility has already been filtered by agent scope. + filtered.add_tool(tool) + continue + + mp = tool.handler_module_path + if not mp: + continue + plugin = star_map.get(mp) + if not plugin: + continue + if plugin.name in plugins_name or plugin.reserved: + filtered.add_tool(tool) + + return filtered + + +def _inject_global_mcp_tools( + base_tools: ToolSet | None, + agent_name: str, +) -> ToolSet: + result = ToolSet() + if base_tools: + for tool in base_tools.tools: + result.add_tool(tool) + + for tool in llm_tools.func_list: + if isinstance(tool, MCPTool) and is_mcp_tool_visible_to_agent(tool, agent_name): + result.add_tool(tool) + return result + + +def _plugin_tool_fix( + event: AstrMessageEvent, + req: ProviderRequest, + inject_mcp: bool = True, + *, + agent_name: str = "main", +) -> None: """根据事件中的插件设置,过滤请求中的工具列表。 注意:没有 handler_module_path 的工具(如 MCP 工具)会被保留, 因为它们不属于任何插件,不应被插件过滤逻辑影响。 + + Args: + event: 消息事件 + req: 提供者请求 + inject_mcp: 是否自动注入全局 MCP 工具,默认 True(向后兼容) """ + req.func_tool = _filter_visible_mcp_tools(req.func_tool, agent_name) + if event.plugins_name is not None and req.func_tool: - new_tool_set = ToolSet() - for tool in req.func_tool.tools: - if isinstance(tool, MCPTool): - # 保留 MCP 工具 - new_tool_set.add_tool(tool) - continue - mp = tool.handler_module_path - if not mp: - continue - plugin = star_map.get(mp) - if not plugin: - continue - if plugin.name in event.plugins_name or plugin.reserved: - new_tool_set.add_tool(tool) - req.func_tool = new_tool_set - else: - # mcp tools - tool_set = req.func_tool - if not tool_set: - tool_set = ToolSet() - for tool in llm_tools.func_list: - if isinstance(tool, MCPTool): - tool_set.add_tool(tool) + req.func_tool = _filter_tools_by_plugins(req.func_tool, set(event.plugins_name)) + elif inject_mcp: + req.func_tool = _inject_global_mcp_tools(req.func_tool, agent_name) async def _handle_webchat( @@ -1074,7 +1136,8 @@ async def build_main_agent( req.session_id = event.unified_msg_origin _modalities_fix(provider, req) - _plugin_tool_fix(event, req) + inject_mcp = config.subagent_orchestrator.get("inject_global_mcp_tools", True) + _plugin_tool_fix(event, req, inject_mcp, agent_name="main") _sanitize_context_by_modalities(config, provider, req) if config.llm_safety_mode: diff --git a/astrbot/core/config/default.py b/astrbot/core/config/default.py index b50bcd8de..1b6232721 100644 --- a/astrbot/core/config/default.py +++ b/astrbot/core/config/default.py @@ -145,6 +145,7 @@ "subagent_orchestrator": { "main_enable": False, "remove_main_duplicate_tools": False, + "inject_global_mcp_tools": True, "router_system_prompt": ( "You are a task router. Your job is to chat naturally, recognize user intent, " "and delegate work to the most suitable subagent using transfer_to_* tools. " diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index 106b42cc5..b6b44bdc8 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -12,6 +12,10 @@ from astrbot import logger from astrbot.core import sp from astrbot.core.agent.mcp_client import MCPClient, MCPTool +from astrbot.core.agent.mcp_scope import ( + get_mcp_scopes_from_config, + strip_mcp_scope_fields, +) from astrbot.core.agent.tool import FunctionTool, ToolSet from astrbot.core.utils.astrbot_path import get_astrbot_data_path @@ -45,6 +49,7 @@ def _prepare_config(config: dict) -> dict: first_key = next(iter(config["mcpServers"])) config = config["mcpServers"][first_key] config.pop("active", None) + strip_mcp_scope_fields(config) return config @@ -250,6 +255,7 @@ async def _init_mcp_client(self, name: str, config: dict) -> None: # 先清理之前的客户端,如果存在 if name in self.mcp_client_dict: await self._terminate_mcp_client(name) + mcp_server_scopes = get_mcp_scopes_from_config(config) mcp_client = MCPClient() mcp_client.name = name @@ -272,6 +278,7 @@ async def _init_mcp_client(self, name: str, config: dict) -> None: mcp_tool=tool, mcp_client=mcp_client, mcp_server_name=name, + mcp_server_scopes=mcp_server_scopes, ) self.func_list.append(func_tool) diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 333700410..407204302 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -329,15 +329,21 @@ async def get_tool_list(self): if isinstance(tool, MCPTool): origin = "mcp" origin_name = tool.mcp_server_name + mcp_server_name = tool.mcp_server_name + mcp_server_scopes = tool.mcp_server_scopes elif tool.handler_module_path and star_map.get( tool.handler_module_path ): star = star_map[tool.handler_module_path] origin = "plugin" origin_name = star.name + mcp_server_name = None + mcp_server_scopes = None else: origin = "unknown" origin_name = "unknown" + mcp_server_name = None + mcp_server_scopes = None tool_info = { "name": tool.name, @@ -346,6 +352,8 @@ async def get_tool_list(self): "active": tool.active, "origin": origin, "origin_name": origin_name, + "mcp_server_name": mcp_server_name, + "mcp_server_scopes": mcp_server_scopes, } tools_dict.append(tool_info) return Response().ok(data=tools_dict).__dict__ diff --git a/dashboard/src/components/extension/McpServersSection.vue b/dashboard/src/components/extension/McpServersSection.vue index 95b679580..cea9be715 100644 --- a/dashboard/src/components/extension/McpServersSection.vue +++ b/dashboard/src/components/extension/McpServersSection.vue @@ -5,7 +5,7 @@
+ @click="openAddServerDialog" > {{ tm('mcpServers.buttons.add') }}
+
+ mdi-account-switch + + {{ tm('mcpServers.status.scopeSummary', { scope: getServerScopeSummary(item) }) }} + +
@@ -93,6 +99,83 @@ + +
+ + {{ tm('dialogs.addServer.fields.scopeQuickSelect') }} + + + + * + + + main + + + {{ scopeItem }} + + + {{ tm('dialogs.addServer.buttons.clearScope') }} + +
+
+ mdi-alert-circle + {{ scopeError }} +
+
+ {{ tm('dialogs.addServer.tips.noSubagentHint') }} +
+
+ {{ tm('dialogs.addServer.tips.noEnabledSubagentHint') }} +
+
{{ tm('dialogs.addServer.fields.config') }} @@ -223,6 +306,8 @@ import { useConfirmDialog } from '@/utils/confirmDialog'; +const MCP_SCOPE_ENABLED_ONLY_STORAGE_KEY = 'astrbot:mcp_scope_show_enabled_only'; + export default { name: 'McpServersSection', components: { @@ -248,12 +333,17 @@ export default { loading: false, loadingGettingServers: false, mcpServerUpdateLoaders: {}, + allSubagentOptions: [], + enabledSubagentOptions: [], + scopeShowEnabledOnly: true, isEditMode: false, serverConfigJson: '', jsonError: null, + scopeError: '', currentServer: { name: '', active: true, + agent_scope: [], tools: [] }, originalServerName: '', @@ -264,7 +354,25 @@ export default { }, computed: { isServerFormValid() { - return !!this.currentServer.name && !this.jsonError; + return !!this.currentServer.name && !this.jsonError && !this.scopeError; + }, + displaySubagentOptions() { + const source = this.scopeShowEnabledOnly + ? this.enabledSubagentOptions + : this.allSubagentOptions; + return [...(source || [])].sort((a, b) => a.localeCompare(b)); + }, + scopeSelectionItems() { + const uniq = []; + const seen = new Set(); + const baseItems = ['*', 'main', ...this.displaySubagentOptions]; + for (const item of baseItems) { + const v = String(item || '').trim(); + if (!v || seen.has(v)) continue; + seen.add(v); + uniq.push(v); + } + return uniq; }, getServerConfigSummary() { return (server) => { @@ -272,7 +380,7 @@ export default { return `${server.command} ${(server.args || []).join(' ')}`; } const configKeys = Object.keys(server).filter(key => - !['name', 'active', 'tools'].includes(key) + !['name', 'active', 'tools', 'agent_scope', 'scopes'].includes(key) ); if (configKeys.length > 0) { return this.tm('mcpServers.status.configSummary', { keys: configKeys.join(', ') }); @@ -282,7 +390,9 @@ export default { } }, mounted() { + this.scopeShowEnabledOnly = this.getStoredScopeEnabledOnlyPreference(); this.getServers(); + this.loadSubagentOptions(); this.refreshInterval = setInterval(() => { this.getServers(); }, 5000); @@ -293,6 +403,33 @@ export default { } }, methods: { + getStoredScopeEnabledOnlyPreference() { + try { + const rawValue = localStorage.getItem(MCP_SCOPE_ENABLED_ONLY_STORAGE_KEY); + if (rawValue === null) { + return true; + } + return rawValue === '1'; + } catch (error) { + return true; + } + }, + persistScopeEnabledOnlyPreference(value) { + try { + localStorage.setItem(MCP_SCOPE_ENABLED_ONLY_STORAGE_KEY, value ? '1' : '0'); + } catch (error) { + // Ignore storage errors and fallback to in-memory state. + } + }, + onScopeEnabledFilterToggle(value) { + this.scopeShowEnabledOnly = !!value; + this.persistScopeEnabledOnlyPreference(this.scopeShowEnabledOnly); + }, + openAddServerDialog() { + this.resetForm(); + this.loadSubagentOptions(); + this.showMcpServerDialog = true; + }, openurl(url) { window.open(url, '_blank'); }, @@ -313,6 +450,119 @@ export default { this.loadingGettingServers = false; }); }, + async loadSubagentOptions() { + try { + const response = await axios.get('/api/subagent/config'); + if (response.data.status !== 'ok') { + return; + } + const agents = Array.isArray(response.data.data?.agents) + ? response.data.data.agents + : []; + const allNames = []; + const enabledNames = []; + for (const agent of agents) { + const name = String(agent?.name || '').trim().toLowerCase(); + if (!name) { + continue; + } + allNames.push(name); + if (agent?.enabled !== false) { + enabledNames.push(name); + } + } + this.allSubagentOptions = [...new Set(allNames)]; + this.enabledSubagentOptions = [...new Set(enabledNames)]; + } catch (error) { + this.allSubagentOptions = []; + this.enabledSubagentOptions = []; + } + }, + normalizeAgentScope(scopeList) { + if (!Array.isArray(scopeList)) { + return []; + } + const normalized = []; + const seen = new Set(); + for (const item of scopeList) { + const rawValue = String(item || '').trim(); + const value = rawValue.toLowerCase() === 'all' ? '*' : rawValue.toLowerCase(); + if (!value || seen.has(value)) { + continue; + } + seen.add(value); + normalized.push(value); + } + if (normalized.includes('*')) { + return ['*']; + } + return normalized; + }, + validateAgentScope() { + const normalized = this.normalizeAgentScope(this.currentServer.agent_scope); + const invalidScopes = normalized.filter((scope) => { + if (scope === '*') { + return false; + } + return !/^[a-z][a-z0-9_]{0,63}$/.test(scope); + }); + if (invalidScopes.length > 0) { + this.scopeError = this.tm('dialogs.addServer.errors.agentScopeInvalid', { + value: invalidScopes.join(', ') + }); + return false; + } + this.scopeError = ''; + this.currentServer.agent_scope = normalized; + return true; + }, + onScopeInputChange(values) { + this.currentServer.agent_scope = this.normalizeAgentScope(values); + this.validateAgentScope(); + }, + isScopeSelected(scope) { + return this.normalizeAgentScope(this.currentServer.agent_scope).includes(scope); + }, + toggleScopeSelection(scope) { + const normalized = this.normalizeAgentScope(this.currentServer.agent_scope); + if (scope === '*') { + this.currentServer.agent_scope = normalized.includes('*') ? [] : ['*']; + this.validateAgentScope(); + return; + } + const next = normalized.filter((item) => item !== '*'); + if (next.includes(scope)) { + this.currentServer.agent_scope = next.filter((item) => item !== scope); + } else { + this.currentServer.agent_scope = [...next, scope]; + } + this.validateAgentScope(); + }, + clearScopeSelection() { + this.currentServer.agent_scope = []; + this.scopeError = ''; + }, + parseAgentScopeFromServer(server) { + const rawScope = server?.agent_scope ?? server?.scopes; + if (rawScope == null) { + return []; + } + if (Array.isArray(rawScope)) { + return this.normalizeAgentScope(rawScope); + } + const value = String(rawScope).trim(); + if (!value) { + return []; + } + return this.normalizeAgentScope([value]); + }, + getServerScopeSummary(server) { + const scopes = this.parseAgentScopeFromServer(server); + if (!scopes.length || scopes.includes('*')) { + return this.tm('mcpServers.status.scopeAll'); + } + return scopes.join(', '); + }, validateJson() { try { if (!this.serverConfigJson.trim()) { @@ -357,14 +607,23 @@ export default { if (!this.validateJson()) { return; } + if (!this.validateAgentScope()) { + return; + } this.loading = true; try { const configObj = JSON.parse(this.serverConfigJson); + delete configObj.scopes; + delete configObj.agent_scope; + const normalizedScope = this.normalizeAgentScope(this.currentServer.agent_scope); const serverData = { name: this.currentServer.name, active: this.currentServer.active, ...configObj }; + if (normalizedScope.length > 0) { + serverData.agent_scope = normalizedScope; + } if (this.isEditMode && this.originalServerName) { serverData.oldName = this.originalServerName; } @@ -412,11 +671,16 @@ export default { this.currentServer = { name: server.name, active: server.active, + agent_scope: this.parseAgentScopeFromServer(server), tools: server.tools || [] }; + delete configCopy.agent_scope; + delete configCopy.scopes; this.originalServerName = server.name; this.serverConfigJson = JSON.stringify(configCopy, null, 2); this.isEditMode = true; + this.loadSubagentOptions(); + this.validateAgentScope(); this.showMcpServerDialog = true; }, updateServerStatus(server) { @@ -438,6 +702,7 @@ export default { closeServerDialog() { this.showMcpServerDialog = false; this.addServerDialogMessage = ''; + this.scopeError = ''; this.resetForm(); }, testServerConnection() { @@ -469,10 +734,12 @@ export default { this.currentServer = { name: '', active: true, + agent_scope: [], tools: [] }; this.serverConfigJson = ''; this.jsonError = null; + this.scopeError = ''; this.isEditMode = false; this.originalServerName = ''; }, diff --git a/dashboard/src/i18n/locales/en-US/features/tool-use.json b/dashboard/src/i18n/locales/en-US/features/tool-use.json index 2c68b8243..af3b59481 100644 --- a/dashboard/src/i18n/locales/en-US/features/tool-use.json +++ b/dashboard/src/i18n/locales/en-US/features/tool-use.json @@ -25,7 +25,9 @@ "noTools": "No available tools", "availableTools": "Available tools", "configSummary": "Config: {keys}", - "noConfig": "No configuration set" + "noConfig": "No configuration set", + "scopeSummary": "Scope: {scope}", + "scopeAll": "All agents (default)" } }, "functionTools": { @@ -71,21 +73,29 @@ "name": "Server Name", "nameRequired": "Name is required", "enable": "Enable Server", - "config": "Server Configuration" + "config": "Server Configuration", + "agentScope": "Agent Scope", + "scopeQuickSelect": "Quick select", + "onlyEnabledSubagents": "Only enabled subagents" }, "errors": { "configEmpty": "Configuration cannot be empty", "jsonFormat": "JSON format error: {error}", - "jsonParse": "JSON parse error: {error}" + "jsonParse": "JSON parse error: {error}", + "agentScopeInvalid": "Invalid scope format: {value}. Use '*' or agent names like 'search_agent'." }, "buttons": { "cancel": "Cancel", "save": "Save", "testConnection": "Test Connection", - "sync": "Sync" + "sync": "Sync", + "clearScope": "Clear" }, "tips": { - "timeoutConfig": "Please configure tool call timeout separately in the configuration page" + "timeoutConfig": "Please configure tool call timeout separately in the configuration page", + "agentScopeHint": "Leave empty for default visibility to all agents; use * to explicitly mark all agents.", + "noSubagentHint": "No subagent names found yet. You can still type custom names manually.", + "noEnabledSubagentHint": "No enabled subagents found. Turn off the filter to show all subagents." } }, "serverDetail": { diff --git a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json index f6e6c4407..0457f87db 100644 --- a/dashboard/src/i18n/locales/zh-CN/features/tool-use.json +++ b/dashboard/src/i18n/locales/zh-CN/features/tool-use.json @@ -25,7 +25,9 @@ "noTools": "无可用工具", "availableTools": "可用工具", "configSummary": "配置: {keys}", - "noConfig": "未设置配置" + "noConfig": "未设置配置", + "scopeSummary": "作用域: {scope}", + "scopeAll": "所有 Agent(默认)" } }, "functionTools": { @@ -71,21 +73,29 @@ "name": "服务器名称", "nameRequired": "名称是必填项", "enable": "启用服务器", - "config": "服务器配置" + "config": "服务器配置", + "agentScope": "Agent 作用域", + "scopeQuickSelect": "快速选择", + "onlyEnabledSubagents": "仅显示已启用 SubAgent" }, "errors": { "configEmpty": "配置不能为空", "jsonFormat": "JSON 格式错误: {error}", - "jsonParse": "JSON 解析错误: {error}" + "jsonParse": "JSON 解析错误: {error}", + "agentScopeInvalid": "作用域格式无效: {value}。请使用 '*' 或类似 'search_agent' 的 Agent 名称。" }, "buttons": { "cancel": "取消", "save": "保存", "testConnection": "测试连接", - "sync": "同步" + "sync": "同步", + "clearScope": "清空" }, "tips": { - "timeoutConfig": "工具调用的超时时间请前往配置页面单独配置" + "timeoutConfig": "工具调用的超时时间请前往配置页面单独配置", + "agentScopeHint": "留空表示默认对所有 Agent 可见;使用 * 可显式标记所有 Agent。", + "noSubagentHint": "暂未发现 SubAgent 名称。你也可以手动输入自定义名称。", + "noEnabledSubagentHint": "未发现已启用的 SubAgent。你可以关闭过滤后查看全部 SubAgent。" } }, "serverDetail": { @@ -156,4 +166,4 @@ "toggleToolError": "工具状态切换失败: {error}", "testError": "测试连接失败: {error}" } -} \ No newline at end of file +} diff --git a/tests/test_mcp_scope.py b/tests/test_mcp_scope.py new file mode 100644 index 000000000..ea393f043 --- /dev/null +++ b/tests/test_mcp_scope.py @@ -0,0 +1,67 @@ +from astrbot.core.agent.mcp_scope import ( + get_mcp_scopes_from_config, + is_mcp_tool_visible_to_agent, + is_scope_allowed_for_agent, + normalize_mcp_scope_value, + strip_mcp_scope_fields, +) + + +class _MockTool: + def __init__(self, scopes): + self.mcp_server_scopes = scopes + + +def test_normalize_scope_none_means_unrestricted(): + assert normalize_mcp_scope_value(None) is None + + +def test_normalize_scope_string(): + assert normalize_mcp_scope_value(" main ") == ("main",) + + +def test_normalize_scope_list_with_wildcard(): + assert normalize_mcp_scope_value(["agent_a", "ALL"]) == ("*",) + + +def test_normalize_scope_empty_list_means_no_visibility(): + assert normalize_mcp_scope_value([]) == () + + +def test_get_mcp_scopes_prefers_scopes_field(): + cfg = {"agent_scope": "main", "scopes": ["agent_x"]} + assert get_mcp_scopes_from_config(cfg) == ("agent_x",) + + +def test_get_mcp_scopes_from_agent_scope_string(): + cfg = {"agent_scope": "main"} + assert get_mcp_scopes_from_config(cfg) == ("main",) + + +def test_get_mcp_scopes_returns_none_for_non_mapping_configs(): + assert get_mcp_scopes_from_config(None) is None + assert get_mcp_scopes_from_config([]) is None + + +def test_get_mcp_scopes_returns_none_when_no_scope_keys(): + assert get_mcp_scopes_from_config({"command": "python"}) is None + + +def test_strip_scope_fields(): + cfg = {"agent_scope": "main", "scopes": ["x"], "command": "python"} + strip_mcp_scope_fields(cfg) + assert cfg == {"command": "python"} + + +def test_scope_allowed_matching_rules(): + assert is_scope_allowed_for_agent(None, "main") is True + assert is_scope_allowed_for_agent((), "main") is False + assert is_scope_allowed_for_agent(("*",), "any_agent") is True + assert is_scope_allowed_for_agent(("main",), "main") is True + assert is_scope_allowed_for_agent(("agent_x",), "main") is False + + +def test_mcp_tool_visibility(): + assert is_mcp_tool_visible_to_agent(_MockTool(None), "main") is True + assert is_mcp_tool_visible_to_agent(_MockTool(("main",)), "main") is True + assert is_mcp_tool_visible_to_agent(_MockTool(("agent_x",)), "main") is False diff --git a/tests/test_plugin_tool_fix.py b/tests/test_plugin_tool_fix.py new file mode 100644 index 000000000..971bdc025 --- /dev/null +++ b/tests/test_plugin_tool_fix.py @@ -0,0 +1,365 @@ +"""Tests for _plugin_tool_fix function in astr_main_agent.py + +This test file uses isolated unit tests to avoid circular import issues. +""" + +import os +import sys + +# 将项目根目录添加到 sys.path +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) + +from dataclasses import dataclass + +# Import only the minimal dependencies needed for the test +from astrbot.core.agent.mcp_scope import ( + is_scope_allowed_for_agent, + normalize_mcp_scope_value, +) +from astrbot.core.agent.tool import FunctionTool, ToolSet + + +class MockMCPTool(FunctionTool): + """Mock MCP tool for testing - simulates MCPTool behavior.""" + + def __init__( + self, + name: str, + server_name: str = "test_server", + scopes: tuple[str, ...] | None = None, + ): + super().__init__( + name=name, + description=f"Mock MCP tool {name}", + parameters={"type": "object", "properties": {}}, + ) + self.mcp_server_name = server_name + self.mcp_server_scopes = scopes + + +class MockFunctionTool(FunctionTool): + """Mock regular function tool for testing.""" + + def __init__(self, name: str, handler_module_path: str | None = None): + super().__init__( + name=name, + description=f"Mock tool {name}", + parameters={"type": "object", "properties": {}}, + ) + self.handler_module_path = handler_module_path + + +@dataclass +class MockProviderRequest: + """Mock ProviderRequest for testing.""" + + func_tool: ToolSet | None = None + + +@dataclass +class MockPluginInfo: + """Mock plugin info for testing.""" + + name: str + reserved: bool = False + + +@dataclass +class MockEvent: + """Mock AstrMessageEvent for testing.""" + + plugins_name: list[str] | None = None + + +def plugin_tool_fix_logic( + event: MockEvent, + req: MockProviderRequest, + star_map: dict, + llm_tools_func_list: list, + inject_mcp: bool = True, + agent_name: str = "main", +) -> None: + """Implementation of _plugin_tool_fix logic for isolated testing. + + This is a copy of the actual function logic for testing purposes. + """ + + # Check if tool is MCPTool by checking for mcp_server_name attribute + def is_mcp_tool(tool): + return hasattr(tool, "mcp_server_name") + + def is_scope_allowed(tool): + return is_scope_allowed_for_agent( + normalize_mcp_scope_value(getattr(tool, "mcp_server_scopes", None)), + agent_name, + ) + + if req.func_tool: + filtered_tool_set = ToolSet() + for tool in req.func_tool.tools: + if is_mcp_tool(tool) and not is_scope_allowed(tool): + continue + filtered_tool_set.add_tool(tool) + req.func_tool = filtered_tool_set + + if event.plugins_name is not None and req.func_tool: + new_tool_set = ToolSet() + for tool in req.func_tool.tools: + if is_mcp_tool(tool): + if is_scope_allowed(tool): + # 保留 MCP 工具 + new_tool_set.add_tool(tool) + continue + mp = tool.handler_module_path + if not mp: + continue + plugin = star_map.get(mp) + if not plugin: + continue + if plugin.name in event.plugins_name or plugin.reserved: + new_tool_set.add_tool(tool) + req.func_tool = new_tool_set + elif inject_mcp: + # 仅在配置允许时注入 MCP 工具 + tool_set = req.func_tool + if not tool_set: + tool_set = ToolSet() + for tool in llm_tools_func_list: + if is_mcp_tool(tool) and is_scope_allowed(tool): + tool_set.add_tool(tool) + req.func_tool = tool_set + + +class TestPluginToolFix: + """Test suite for _plugin_tool_fix function logic.""" + + def test_inject_mcp_true_injects_tools(self): + """When inject_mcp=True, MCP tools should be injected from global pool.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = ToolSet() + + mcp_tool_1 = MockMCPTool("mcp_tool_1") + mcp_tool_2 = MockMCPTool("mcp_tool_2") + regular_tool = MockFunctionTool("regular_tool") + llm_tools_list = [mcp_tool_1, mcp_tool_2, regular_tool] + + plugin_tool_fix_logic(event, req, {}, llm_tools_list, inject_mcp=True) + + # Should have injected MCP tools only + assert len(req.func_tool.tools) == 2 + tool_names = {t.name for t in req.func_tool.tools} + assert tool_names == {"mcp_tool_1", "mcp_tool_2"} + + def test_inject_mcp_false_skips_injection(self): + """When inject_mcp=False, MCP tools should NOT be injected.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = ToolSet() + + mcp_tool_1 = MockMCPTool("mcp_tool_1") + mcp_tool_2 = MockMCPTool("mcp_tool_2") + llm_tools_list = [mcp_tool_1, mcp_tool_2] + + plugin_tool_fix_logic(event, req, {}, llm_tools_list, inject_mcp=False) + + # Should NOT have any tools + assert len(req.func_tool.tools) == 0 + + def test_default_inject_mcp_is_true(self): + """Default behavior (inject_mcp=True) should inject MCP tools.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = ToolSet() + + mcp_tool = MockMCPTool("mcp_tool") + llm_tools_list = [mcp_tool] + + # Call without inject_mcp parameter (should default to True) + plugin_tool_fix_logic(event, req, {}, llm_tools_list) + + # Should have injected MCP tool + assert len(req.func_tool.tools) == 1 + assert req.func_tool.tools[0].name == "mcp_tool" + + def test_plugins_name_set_filters_regular_tools(self): + """When plugins_name is set, only tools from those plugins should be kept.""" + event = MockEvent(plugins_name=["plugin_a"]) + req = MockProviderRequest() + + # Create tools with different plugin associations + tool_a = MockFunctionTool("tool_a", handler_module_path="plugins.plugin_a.main") + tool_b = MockFunctionTool("tool_b", handler_module_path="plugins.plugin_b.main") + mcp_tool = MockMCPTool("mcp_tool") + + req.func_tool = ToolSet([tool_a, tool_b, mcp_tool]) + + star_map = { + "plugins.plugin_a.main": MockPluginInfo("plugin_a"), + "plugins.plugin_b.main": MockPluginInfo("plugin_b"), + } + + plugin_tool_fix_logic(event, req, star_map, [], inject_mcp=True) + + # Should have tool_a (from plugin_a) and mcp_tool (always kept) + tool_names = {t.name for t in req.func_tool.tools} + assert tool_names == {"tool_a", "mcp_tool"} + + def test_reserved_plugins_always_included(self): + """Tools from reserved plugins should always be included.""" + event = MockEvent(plugins_name=["plugin_a"]) + req = MockProviderRequest() + + tool_a = MockFunctionTool("tool_a", handler_module_path="plugins.plugin_a.main") + tool_reserved = MockFunctionTool( + "tool_reserved", handler_module_path="plugins.reserved.main" + ) + + req.func_tool = ToolSet([tool_a, tool_reserved]) + + star_map = { + "plugins.plugin_a.main": MockPluginInfo("plugin_a"), + "plugins.reserved.main": MockPluginInfo("reserved", reserved=True), + } + + plugin_tool_fix_logic(event, req, star_map, [], inject_mcp=True) + + tool_names = {t.name for t in req.func_tool.tools} + assert tool_a.name in tool_names + assert tool_reserved.name in tool_names + + def test_mcp_tools_preserved_in_plugins_name_mode(self): + """MCP tools should be preserved even when plugins_name filtering is active.""" + event = MockEvent(plugins_name=["plugin_a"]) + req = MockProviderRequest() + + tool_a = MockFunctionTool("tool_a", handler_module_path="plugins.plugin_a.main") + tool_b = MockFunctionTool("tool_b", handler_module_path="plugins.plugin_b.main") + mcp_tool = MockMCPTool("mcp_tool") + + req.func_tool = ToolSet([tool_a, tool_b, mcp_tool]) + + star_map = { + "plugins.plugin_a.main": MockPluginInfo("plugin_a"), + "plugins.plugin_b.main": MockPluginInfo("plugin_b"), + } + + plugin_tool_fix_logic(event, req, star_map, [], inject_mcp=False) + + # MCP tool should be preserved, tool_b should be filtered out + tool_names = {t.name for t in req.func_tool.tools} + assert tool_names == {"tool_a", "mcp_tool"} + + def test_empty_tool_set_with_inject_mcp_false(self): + """When func_tool is None and inject_mcp=False, should remain empty.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = None + + mcp_tool = MockMCPTool("mcp_tool") + llm_tools_list = [mcp_tool] + + plugin_tool_fix_logic(event, req, {}, llm_tools_list, inject_mcp=False) + + # Should be None + assert req.func_tool is None + + def test_empty_tool_set_with_inject_mcp_true(self): + """When func_tool is None and inject_mcp=True, should create ToolSet with MCP tools.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = None + + mcp_tool = MockMCPTool("mcp_tool") + llm_tools_list = [mcp_tool] + + plugin_tool_fix_logic(event, req, {}, llm_tools_list, inject_mcp=True) + + # Should have created ToolSet with MCP tool + assert req.func_tool is not None + assert len(req.func_tool.tools) == 1 + assert req.func_tool.tools[0].name == "mcp_tool" + + def test_mixed_tools_injection(self): + """Test that only MCP tools are injected, not regular tools from global pool.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = ToolSet() + + mcp_tool_1 = MockMCPTool("mcp_tool_1") + mcp_tool_2 = MockMCPTool("mcp_tool_2") + regular_tool_1 = MockFunctionTool("regular_tool_1") + regular_tool_2 = MockFunctionTool("regular_tool_2") + + llm_tools_list = [mcp_tool_1, regular_tool_1, mcp_tool_2, regular_tool_2] + + plugin_tool_fix_logic(event, req, {}, llm_tools_list, inject_mcp=True) + + # Should only have MCP tools + assert len(req.func_tool.tools) == 2 + tool_names = {t.name for t in req.func_tool.tools} + assert tool_names == {"mcp_tool_1", "mcp_tool_2"} + + def test_scope_restricts_main_injection(self): + """Main agent should only inject MCP tools visible to main scope.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = ToolSet() + + main_tool = MockMCPTool("main_tool", scopes=("main",)) + sub_tool = MockMCPTool("sub_tool", scopes=("vrchat_agent",)) + all_tool = MockMCPTool("all_tool", scopes=("*",)) + llm_tools_list = [main_tool, sub_tool, all_tool] + + plugin_tool_fix_logic( + event, + req, + {}, + llm_tools_list, + inject_mcp=True, + agent_name="main", + ) + + tool_names = {t.name for t in req.func_tool.tools} + assert tool_names == {"main_tool", "all_tool"} + + def test_scope_filters_existing_mcp_tools(self): + """Existing MCP tools should be filtered out when scope is not visible.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest() + req.func_tool = ToolSet( + [ + MockMCPTool("visible", scopes=("main",)), + MockMCPTool("hidden", scopes=("agent_x",)), + MockFunctionTool("regular_tool", handler_module_path="plugins.a.main"), + ] + ) + + plugin_tool_fix_logic( + event, + req, + {}, + [], + inject_mcp=False, + agent_name="main", + ) + + tool_names = {t.name for t in req.func_tool.tools} + assert tool_names == {"visible", "regular_tool"} + + def test_scope_matching_is_case_insensitive(self): + """Scope matching should be case-insensitive to mirror production behavior.""" + event = MockEvent(plugins_name=None) + req = MockProviderRequest(func_tool=ToolSet()) + llm_tools_list = [MockMCPTool("mixed_case_tool", scopes=("MyAgent",))] + + plugin_tool_fix_logic( + event, + req, + {}, + llm_tools_list, + inject_mcp=True, + agent_name="myagent", + ) + + assert {t.name for t in req.func_tool.tools} == {"mixed_case_tool"}