diff --git a/astrbot/core/agent/mcp_client.py b/astrbot/core/agent/mcp_client.py index c149792dcd..328eceb456 100644 --- a/astrbot/core/agent/mcp_client.py +++ b/astrbot/core/agent/mcp_client.py @@ -7,6 +7,7 @@ from datetime import timedelta from pathlib import Path, PureWindowsPath from typing import Generic +from urllib.parse import quote from tenacity import ( before_sleep_log, @@ -599,20 +600,30 @@ class MCPTool(FunctionTool, Generic[TContext]): def __init__( self, mcp_tool: mcp.Tool, mcp_client: MCPClient, mcp_server_name: str, **kwargs ) -> None: + # Add namespace prefix to avoid conflicts with plugin tools + # URL-encode the server name to create a safe and unique identifier part + normalized_server_name = quote(mcp_server_name, safe="") + # Format: mcp___ + namespaced_name = f"mcp_{normalized_server_name}__{mcp_tool.name}" + super().__init__( - name=mcp_tool.name, + name=namespaced_name, description=mcp_tool.description or "", parameters=mcp_tool.inputSchema, ) self.mcp_tool = mcp_tool self.mcp_client = mcp_client self.mcp_server_name = mcp_server_name + self.original_tool_name = ( + mcp_tool.name + ) # Store original name for display and calling async def call( self, context: ContextWrapper[TContext], **kwargs ) -> mcp.types.CallToolResult: + # Use original tool name when calling MCP server return await self.mcp_client.call_tool_with_reconnect( - tool_name=self.mcp_tool.name, + tool_name=self.original_tool_name, arguments=kwargs, read_timeout_seconds=timedelta(seconds=context.tool_call_timeout), ) diff --git a/astrbot/core/provider/func_tool_manager.py b/astrbot/core/provider/func_tool_manager.py index bf16a3ec96..9867df115c 100644 --- a/astrbot/core/provider/func_tool_manager.py +++ b/astrbot/core/provider/func_tool_manager.py @@ -320,6 +320,29 @@ def remove_func(self, name: str) -> None: self.func_list.pop(i) break + def _find_mcp_tool_by_original_name(self, name: str) -> MCPTool | None: + """按原始(命名空间前)名查找 MCP 工具,active 优先。""" + matches = [ + f + for f in self.func_list + if isinstance(f, MCPTool) and f.original_tool_name == name + ] + + if not matches: + return None + + active_matches = [f for f in matches if getattr(f, "active", True)] + candidates = active_matches or matches + + if len(candidates) > 1: + candidates.sort(key=lambda f: f.name) + logger.warning( + f"Multiple MCP tools found with original name '{name}': " + f"{[f.name for f in candidates]}. Using {candidates[0].name}" + ) + + return candidates[0] + def get_func(self, name) -> FuncTool | None: # 优先返回已激活的工具(后加载的覆盖前面的,与 ToolSet.add_tool 保持一致) # 使用 getattr(..., True) 与 ToolSet.add_tool 保持一致:没有 active 属性的工具视为已激活 @@ -330,7 +353,13 @@ def get_func(self, name) -> FuncTool | None: for f in reversed(self.func_list): if f.name == name: return f + if isinstance(name, str): + # 老 persona 配置按原始 MCP 名回查 + mcp_tool = self._find_mcp_tool_by_original_name(name) + if mcp_tool is not None: + return mcp_tool + try: builtin_tool = self.get_builtin_tool(name) except KeyError: @@ -573,6 +602,13 @@ async def lifecycle() -> None: lifecycle_task = asyncio.create_task(lifecycle(), name=f"mcp-client:{name}") async with self._runtime_lock: + # After successful initialization mcp_client is logically non-None; + # raise explicitly so this invariant still holds under `python -O` + # (which strips `assert`). + if mcp_client is None: + raise RuntimeError( + f"MCP client {name} unexpectedly None after successful initialization" + ) self._mcp_server_runtime[name] = _MCPServerRuntime( name=name, client=mcp_client, diff --git a/astrbot/dashboard/routes/tools.py b/astrbot/dashboard/routes/tools.py index 157b4d75bf..797e228cdf 100644 --- a/astrbot/dashboard/routes/tools.py +++ b/astrbot/dashboard/routes/tools.py @@ -83,6 +83,14 @@ async def get_mcp_servers(self): ) mcp_servers = {} + # 按 server 名预分组 MCP 工具,避免每台服务器都扫一遍 func_list + mcp_tools_by_server: dict[str, list[MCPTool]] = {} + for f in self.tool_mgr.func_list: + if isinstance(f, MCPTool): + mcp_tools_by_server.setdefault(f.mcp_server_name, []).append(f) + + runtime_view = self.tool_mgr.mcp_server_runtime_view + # 获取所有服务器并添加它们的工具列表 for name, server_config in mcp_servers.items(): if not isinstance(server_config, dict): @@ -101,15 +109,19 @@ async def get_mcp_servers(self): if key != "active": # active 已经处理 server_info[key] = value - # 如果MCP客户端已初始化,从客户端获取工具名称 - for name_key, runtime in self.tool_mgr.mcp_server_runtime_view.items(): - if name_key == name: - mcp_client = runtime.client - server_info["tools"] = [tool.name for tool in mcp_client.tools] - server_info["errlogs"] = mcp_client.server_errlogs - break + # tools 为 namespaced 名(与 personaForm.tools 匹配), + # original_tool_names 为原始名(给 UI 显示) + runtime = runtime_view.get(name) + if runtime is not None: + mcp_tools = mcp_tools_by_server.get(name, []) + server_info["tools"] = [f.name for f in mcp_tools] + server_info["original_tool_names"] = [ + f.original_tool_name for f in mcp_tools + ] + server_info["errlogs"] = runtime.client.server_errlogs else: server_info["tools"] = [] + server_info["original_tool_names"] = [] servers.append(server_info) @@ -470,6 +482,7 @@ async def get_tool_list(self): if self.tool_mgr.is_builtin_tool(tool.name): origin = "builtin" origin_name = "AstrBot Core" + display_name = tool.name readonly = True builtin_config_statuses = get_builtin_tool_config_statuses( tool.name, @@ -483,18 +496,25 @@ async def get_tool_list(self): elif isinstance(tool, MCPTool): origin = "mcp" origin_name = tool.mcp_server_name + # Format: _ for MCP tools + # Normalize server name for display + normalized_server = tool.mcp_server_name.replace(" ", "") + display_name = f"{normalized_server}_{tool.original_tool_name}" 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 + display_name = tool.name else: origin = "unknown" origin_name = "unknown" + display_name = tool.name tool_info = { - "name": tool.name, + "name": tool.name, # Keep namespaced name for internal use + "display_name": display_name, # Friendly name for display "description": tool.description, "parameters": tool.parameters, "active": tool.active, diff --git a/dashboard/src/components/extension/McpServersSection.vue b/dashboard/src/components/extension/McpServersSection.vue index 8784e8511b..5f8bec4556 100644 --- a/dashboard/src/components/extension/McpServersSection.vue +++ b/dashboard/src/components/extension/McpServersSection.vue @@ -52,7 +52,7 @@
    -
  • {{ tool }}
  • +
  • {{ tool }}
diff --git a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue index 9615e5ea8c..c03c2d3407 100644 --- a/dashboard/src/components/extension/componentPanel/components/ToolTable.vue +++ b/dashboard/src/components/extension/componentPanel/components/ToolTable.vue @@ -85,7 +85,10 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => { - {{ item.name }} + {{ item.display_name || item.name }} @@ -191,7 +191,7 @@ :closable="!isBuiltinToolName(toolName)" @click:close="removeTool(toolName)" > - {{ toolName }} + {{ getToolDisplayName(toolName) }} {{ tm('form.builtinToolDisabledHint') }} @@ -365,6 +365,7 @@ import { askForConfirmation as askForConfirmationDialog, useConfirmDialog } from '@/utils/confirmDialog'; +import { matchesToolSearch, resolveToolDisplayName } from '@/utils/toolDisplayName'; export default { name: 'PersonaForm', @@ -441,12 +442,7 @@ export default { if (!this.toolSearch) { return this.availableTools; } - const search = this.toolSearch.toLowerCase(); - return this.availableTools.filter(tool => - tool.name.toLowerCase().includes(search) || - (tool.description && tool.description.toLowerCase().includes(search)) || - (tool.mcp_server_name && tool.mcp_server_name.toLowerCase().includes(search)) - ); + return this.availableTools.filter(tool => matchesToolSearch(tool, this.toolSearch)); }, filteredSkills() { if (!this.skillSearch) { @@ -868,6 +864,10 @@ export default { // 检查服务器的所有工具是否都已选中 return Array.isArray(this.personaForm.tools) && server.tools.every(toolName => this.personaForm.tools.includes(toolName)); + }, + + getToolDisplayName(toolName) { + return resolveToolDisplayName(toolName, this.availableTools); } } } diff --git a/dashboard/src/components/shared/PersonaQuickPreview.vue b/dashboard/src/components/shared/PersonaQuickPreview.vue index a15ff84ecb..dc2cb1d0a7 100644 --- a/dashboard/src/components/shared/PersonaQuickPreview.vue +++ b/dashboard/src/components/shared/PersonaQuickPreview.vue @@ -125,7 +125,7 @@ const resolvedTools = computed(() => normalizedTools.value.map((toolName) => { const meta = toolMetaMap.value[toolName] || {} return { - name: toolName, + name: meta.display_name || toolName, // Use display_name for showing origin: meta.origin || '', origin_name: meta.origin_name || '', active: meta.active @@ -144,6 +144,7 @@ async function loadToolsMeta() { continue } nextMap[tool.name] = { + display_name: tool.display_name || tool.name, origin: tool.origin || '', origin_name: tool.origin_name || '', active: tool.active diff --git a/dashboard/src/utils/toolDisplayName.ts b/dashboard/src/utils/toolDisplayName.ts new file mode 100644 index 0000000000..14c6453e75 --- /dev/null +++ b/dashboard/src/utils/toolDisplayName.ts @@ -0,0 +1,27 @@ +import type { ToolItem } from '@/components/extension/componentPanel/types'; + +type ToolDisplayNameSource = Pick; +type ToolSearchSource = Pick; + +export function resolveToolDisplayName( + toolName: string, + availableTools: ToolDisplayNameSource[] = [] +): string { + const tool = availableTools.find(item => item.name === toolName); + return tool?.display_name || toolName; +} + +export function matchesToolSearch(tool: ToolSearchSource, rawQuery: string): boolean { + const query = rawQuery.trim().toLowerCase(); + if (!query) { + return true; + } + + return [ + tool.display_name, + tool.name, + tool.description, + tool.origin, + tool.origin_name, + ].some(value => value?.toLowerCase().includes(query)); +} \ No newline at end of file diff --git a/dashboard/src/views/persona/PersonaManager.vue b/dashboard/src/views/persona/PersonaManager.vue index 93bcb69523..16a55dfffe 100644 --- a/dashboard/src/views/persona/PersonaManager.vue +++ b/dashboard/src/views/persona/PersonaManager.vue @@ -164,7 +164,7 @@ class="d-flex flex-wrap ga-1"> - {{ toolName }} + {{ getToolDisplayName(toolName) }}
@@ -265,6 +265,7 @@