Skip to content
15 changes: 13 additions & 2 deletions astrbot/core/agent/mcp_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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_<normalized_server_name>__<tool_name>
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),
)
36 changes: 36 additions & 0 deletions astrbot/core/provider/func_tool_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 属性的工具视为已激活
Expand All @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
36 changes: 28 additions & 8 deletions astrbot/dashboard/routes/tools.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand All @@ -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)

Expand Down Expand Up @@ -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,
Expand All @@ -483,18 +496,25 @@ async def get_tool_list(self):
elif isinstance(tool, MCPTool):
origin = "mcp"
origin_name = tool.mcp_server_name
# Format: <ServerName>_<ToolName> 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,
Expand Down
2 changes: 1 addition & 1 deletion dashboard/src/components/extension/McpServersSection.vue
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@
</v-card-title>
<v-card-text>
<ul>
<li v-for="(tool, idx) in item.tools" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
<li v-for="(tool, idx) in (item.original_tool_names || item.tools)" :key="idx" style="margin: 8px 0px;">{{ tool }}</li>
</ul>
</v-card-text>
<v-card-actions class="d-flex justify-end">
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,10 @@ const enabledConfigTags = (tool: ToolItem): BuiltinToolConfigTag[] => {
<template #item.name="{ item }">
<div class="py-2">
<div class="d-flex flex-wrap align-center ga-1">
<div class="tool-name text-body-2 font-weight-medium">{{ item.name }}</div>
<v-icon color="primary" class="mr-1" size="18">
{{ item.origin === 'mcp' ? 'mdi-server-network' : 'mdi-function-variant' }}
</v-icon>
<div class="tool-name text-body-2 font-weight-medium">{{ item.display_name || item.name }}</div>
<v-tooltip
v-for="tag in enabledConfigTags(item)"
:key="`${item.name}-${tag.conf_id}`"
Expand Down
12 changes: 4 additions & 8 deletions dashboard/src/components/extension/componentPanel/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { computed, onActivated, onMounted, ref, watch} from 'vue';
import axios from 'axios';
import { useModuleI18n } from '@/i18n/composables';
import { normalizeTextInput } from '@/utils/inputValue';
import { matchesToolSearch } from '@/utils/toolDisplayName';

// Composables
import { useComponentData } from './composables/useComponentData';
Expand Down Expand Up @@ -83,14 +84,9 @@ const {
openDetailsDialog
} = useCommandActions(toast, () => fetchCommands(tm('messages.loadFailed')));

const filteredTools = computed(() => {
const query = normalizeTextInput(toolSearch.value).trim().toLowerCase();
if (!query) return tools.value;
return tools.value.filter(tool =>
tool.name?.toLowerCase().includes(query) ||
tool.description?.toLowerCase().includes(query)
);
});
const filteredTools = computed(() =>
tools.value.filter(tool => matchesToolSearch(tool, toolSearch.value))
);

// 处理切换指令状态
const handleToggleCommand = async (cmd: CommandItem) => {
Expand Down
1 change: 1 addition & 0 deletions dashboard/src/components/extension/componentPanel/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export interface BuiltinToolConfigTag {
/** MCP/函数工具对象 */
export interface ToolItem {
name: string;
display_name?: string;
description: string;
active: boolean;
readonly?: boolean;
Expand Down
16 changes: 8 additions & 8 deletions dashboard/src/components/shared/PersonaForm.vue
Original file line number Diff line number Diff line change
Expand Up @@ -115,7 +115,7 @@
</template>

<v-list-item-title>
{{ item.name }}
{{ item.display_name || item.name }}

<v-chip v-if="item.origin" size="x-small" color="info" class="mr-2"
variant="tonal">
Expand Down Expand Up @@ -191,7 +191,7 @@
:closable="!isBuiltinToolName(toolName)"
@click:close="removeTool(toolName)"
>
{{ toolName }}
{{ getToolDisplayName(toolName) }}
</v-chip>
</template>
<span>{{ tm('form.builtinToolDisabledHint') }}</span>
Expand Down Expand Up @@ -365,6 +365,7 @@ import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
import { matchesToolSearch, resolveToolDisplayName } from '@/utils/toolDisplayName';

export default {
name: 'PersonaForm',
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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);
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion dashboard/src/components/shared/PersonaQuickPreview.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
27 changes: 27 additions & 0 deletions dashboard/src/utils/toolDisplayName.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import type { ToolItem } from '@/components/extension/componentPanel/types';

type ToolDisplayNameSource = Pick<ToolItem, 'name' | 'display_name'>;
type ToolSearchSource = Pick<ToolItem, 'name' | 'display_name' | 'description' | 'origin' | 'origin_name'>;

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));
}
28 changes: 25 additions & 3 deletions dashboard/src/views/persona/PersonaManager.vue
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@
class="d-flex flex-wrap ga-1">
<v-chip v-for="toolName in viewingPersona.tools" :key="toolName" size="small"
color="primary" variant="tonal">
{{ toolName }}
{{ getToolDisplayName(toolName) }}
</v-chip>
</div>
<div v-else class="text-body-2 text-medium-emphasis">
Expand Down Expand Up @@ -265,6 +265,7 @@

<script lang="ts">
import { defineComponent } from 'vue';
import axios from 'axios';
import { useI18n, useModuleI18n } from '@/i18n/composables';
import { usePersonaStore } from '@/stores/personaStore';
import { mapState, mapActions } from 'pinia';
Expand All @@ -280,7 +281,9 @@ import {
askForConfirmation as askForConfirmationDialog,
useConfirmDialog
} from '@/utils/confirmDialog';
import { resolveToolDisplayName } from '@/utils/toolDisplayName';

import type { ToolItem } from '@/components/extension/componentPanel/types';
import type { Folder, FolderTreeNode } from '@/components/folder/types';

interface Persona {
Expand Down Expand Up @@ -347,7 +350,10 @@ export default defineComponent({

// 骨架屏延迟显示控制
showSkeleton: false,
skeletonTimer: null as ReturnType<typeof setTimeout> | null
skeletonTimer: null as ReturnType<typeof setTimeout> | null,

// 工具列表用于显示名称
availableTools: [] as ToolItem[]
};
},
computed: {
Expand Down Expand Up @@ -411,10 +417,26 @@ export default defineComponent({
async initialize() {
await Promise.all([
this.loadFolderTree(),
this.navigateToFolder(null)
this.navigateToFolder(null),
this.loadTools()
]);
},

async loadTools() {
try {
const response = await axios.get('/api/tools/list');
if (response.data.status === 'ok') {
this.availableTools = response.data.data || [];
}
} catch (error) {
console.error('Failed to load tools:', error);
}
},

getToolDisplayName(toolName: string): string {
return resolveToolDisplayName(toolName, this.availableTools);
},

// Persona 操作
openCreatePersonaDialog() {
this.editingPersona = null;
Expand Down
Loading