Files
beaver_project/app-instance/frontend/app/(app)/mcp/page.tsx
steven_li cdfc222c9f feat: 添加swarms团队编排功能并优化agent委派系统
- 引入AgentTeamOrchestrator支持多agent协同任务执行
- 增加第三方swarms库依赖并配置git协议替换以改善包管理
- 扩展DelegationManager支持团队任务调度和进度跟踪
- 实现中文bigram分词算法提升中文任务检索准确性
- 调整A2AClient和DelegationManager超时时间从30秒增至600秒
- 优化AgentRunResult状态判断逻辑增加有意义摘要检测
- 修改Dockerfile配置npm仓库镜像地址和git协议映射
- 更新CLI命令行接口支持网关端口配置传递
- 调整提供者超时配置机制增强请求稳定性
- 移除过时的support_group字段简化agent描述符结构
- 增强错误处理和进度事件报告机制改进用户体验
2026-04-14 14:34:23 +08:00

607 lines
29 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client';
import React, { useCallback, useEffect, useState } from 'react';
import { AlertCircle, Loader2, Plus, RefreshCw, ServerCog, TestTube2, Trash2, Wrench } from 'lucide-react';
import { addMcpServer, deleteMcpServer, getAuthzStatus, listMcpServers, listMcpTools, testMcpServer, updateMcpServer } from '@/lib/api';
import { useChatStore } from '@/lib/store';
import { cn } from '@/lib/utils';
import type { AuthzStatus, UiMcpServerDescriptor } from '@/types';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Textarea } from '@/components/ui/textarea';
import type { AppLocale } from '@/lib/i18n/core';
import { pickAppText } from '@/lib/i18n/core';
import { useAppI18n } from '@/lib/i18n/provider';
type McpFormMode = 'remote' | 'install';
const EMPTY_FORM = {
mode: 'remote' as McpFormMode,
id: '',
command: '',
args: '',
url: '',
headers: '{}',
auth_mode: 'none',
tool_timeout: '30',
};
function createEmptyForm() {
return { ...EMPTY_FORM };
}
function resolveFormMode(server?: UiMcpServerDescriptor): McpFormMode {
if (server?.transport === 'stdio') return 'install';
if (server?.transport === 'http') return 'remote';
if (server?.url) return 'remote';
return 'install';
}
function resolveAuthAudience(serverId: string) {
const trimmed = serverId.trim();
return trimmed ? `mcp:${trimmed}` : '';
}
function resolveAuthzMcpScopes(authzStatus: AuthzStatus | null, serverId: string): { available: boolean; enabled: boolean; scopes: string[] } {
const trimmed = serverId.trim();
if (!trimmed) {
return { available: false, enabled: false, scopes: [] };
}
const permissions = authzStatus?.permissions;
if (!permissions || typeof permissions !== 'object') {
return { available: false, enabled: false, scopes: [] };
}
const mcpPermissions = (permissions as Record<string, unknown>).mcp;
if (!mcpPermissions || typeof mcpPermissions !== 'object') {
return { available: false, enabled: false, scopes: [] };
}
const serverPermission = (mcpPermissions as Record<string, unknown>)[trimmed];
if (!serverPermission || typeof serverPermission !== 'object') {
return { available: false, enabled: false, scopes: [] };
}
const enabled = Boolean((serverPermission as Record<string, unknown>).enabled);
const rawTools: unknown[] = Array.isArray((serverPermission as Record<string, unknown>).tools)
? ((serverPermission as Record<string, unknown>).tools as unknown[])
: [];
const toolScopes = rawTools
.filter((tool: unknown): tool is string => typeof tool === 'string' && tool.trim().length > 0)
.map((tool) => `tool:${tool.trim()}`);
return {
available: true,
enabled,
scopes: enabled ? ['list_tools', ...toolScopes] : [],
};
}
function serverStatusLabel(status: string | null | undefined, locale: AppLocale) {
if (status === 'connected') return pickAppText(locale, '已连接', 'Connected');
if (status === 'error') return pickAppText(locale, '异常', 'Error');
if (status === 'disconnected' || !status) return pickAppText(locale, '未连接', 'Disconnected');
return status;
}
function transportLabel(transport: string | undefined, locale: AppLocale) {
if (transport === 'stdio') return pickAppText(locale, '标准输入输出', 'Standard I/O');
if (transport === 'http') return 'HTTP';
return transport || '-';
}
export default function MCPPage() {
const { locale } = useAppI18n();
const t = (zh: string, en: string) => pickAppText(locale, zh, en);
const cachedServers = useChatStore((s) => s.mcpRegistry);
const cachedTools = useChatStore((s) => s.mcpToolRegistry);
const setCachedServers = useChatStore((s) => s.setMcpRegistry);
const setCachedTools = useChatStore((s) => s.setMcpToolRegistry);
const [servers, setServers] = useState<UiMcpServerDescriptor[]>(cachedServers);
const [tools, setTools] = useState<Array<{ server_id: string; tools: Array<Record<string, unknown>> }>>(cachedTools);
const [loading, setLoading] = useState(cachedServers.length === 0 && cachedTools.length === 0);
const [refreshing, setRefreshing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [dialogOpen, setDialogOpen] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [testingId, setTestingId] = useState<string | null>(null);
const [editingId, setEditingId] = useState<string | null>(null);
const [form, setForm] = useState(createEmptyForm());
const [authzStatus, setAuthzStatus] = useState<AuthzStatus | null>(null);
const [selectedServerId, setSelectedServerId] = useState<string | null>(null);
const load = useCallback(async (background = false) => {
if (background) {
setRefreshing(true);
} else {
setLoading(true);
}
setError(null);
try {
const [serverData, toolData, authzData] = await Promise.all([
listMcpServers(),
listMcpTools(),
getAuthzStatus().catch(() => null),
]);
const nextServers = Array.isArray(serverData) ? serverData : [];
const nextTools = Array.isArray(toolData) ? toolData : [];
setServers(nextServers);
setTools(nextTools);
setCachedServers(nextServers);
setCachedTools(nextTools);
setAuthzStatus(authzData);
setSelectedServerId((current) => (current && nextServers.some((server) => server.id === current) ? current : null));
} catch (err: any) {
setError(err.message || t('加载 MCP 服务失败', 'Failed to load MCP servers'));
} finally {
if (background) {
setRefreshing(false);
} else {
setLoading(false);
}
}
}, [setCachedServers, setCachedTools]);
useEffect(() => {
void load(cachedServers.length > 0 || cachedTools.length > 0);
}, [cachedServers.length, cachedTools.length, load]);
const resetForm = () => {
setEditingId(null);
setForm(createEmptyForm());
};
const openEdit = (server: UiMcpServerDescriptor) => {
setEditingId(server.id);
setForm({
mode: resolveFormMode(server),
id: server.id,
command: server.command || '',
args: (server.args || []).join(' '),
url: server.url || '',
headers: JSON.stringify(server.headers || {}, null, 2),
auth_mode: server.auth_mode || 'none',
tool_timeout: String(server.tool_timeout || 30),
});
setDialogOpen(true);
};
const parseObjectField = (label: string, value: string) => {
if (!value.trim()) return {};
const parsed = JSON.parse(value);
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
throw new Error(`${label} ${t('必须是 JSON 对象', 'must be a JSON object')}`);
}
return parsed as Record<string, string>;
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setSubmitting(true);
setError(null);
try {
const id = form.id.trim();
const url = form.url.trim();
const command = form.command.trim();
const toolTimeout = Number(form.tool_timeout || 30);
if (!id) {
throw new Error(t('ID 不能为空', 'ID cannot be empty'));
}
if (!Number.isFinite(toolTimeout) || toolTimeout < 1) {
throw new Error(t('工具超时必须大于 0', 'Tool timeout must be greater than 0'));
}
if (form.mode === 'remote' && !url) {
throw new Error(t('请输入 MCP Server 地址', 'Enter an MCP server URL'));
}
if (form.mode === 'install' && !command) {
throw new Error(t('请输入安装或启动命令', 'Enter an install or launch command'));
}
const authMode = form.mode === 'remote' ? (form.auth_mode || 'none') : 'none';
const authAudience = authMode === 'oauth_backend_token' ? resolveAuthAudience(id) : '';
const payload = {
id,
command: form.mode === 'install' ? command : '',
args: form.mode === 'install'
? form.args.split(/\s+/).map((item) => item.trim()).filter(Boolean)
: [],
env: {},
url: form.mode === 'remote' ? url : '',
headers: form.mode === 'remote' ? parseObjectField(t('请求头', 'Headers'), form.headers) : {},
auth_mode: authMode,
auth_audience: authAudience,
auth_scopes: [],
tool_timeout: toolTimeout,
};
if (editingId) {
await updateMcpServer(editingId, payload);
} else {
await addMcpServer(payload);
}
setDialogOpen(false);
resetForm();
await load();
} catch (err: any) {
setError(err.message || t('保存 MCP 服务失败', 'Failed to save the MCP server'));
} finally {
setSubmitting(false);
}
};
const handleDelete = async (serverId: string) => {
try {
await deleteMcpServer(serverId);
setSelectedServerId((current) => (current === serverId ? null : current));
await load();
} catch (err: any) {
setError(err.message || t('删除 MCP 服务失败', 'Failed to delete the MCP server'));
}
};
const handleTest = async (serverId: string) => {
setTestingId(serverId);
try {
await testMcpServer(serverId);
await load(true);
} catch (err: any) {
setError(err.message || t('测试 MCP 服务失败', 'Failed to test the MCP server'));
} finally {
setTestingId(null);
}
};
const authAudience = resolveAuthAudience(form.id);
const authzMcpScopes = resolveAuthzMcpScopes(authzStatus, form.id);
const showAuthzPreview = form.auth_mode === 'oauth_backend_token';
const selectedServer = selectedServerId ? servers.find((server) => server.id === selectedServerId) || null : null;
const selectedToolGroup = selectedServerId ? tools.find((group) => group.server_id === selectedServerId) || null : null;
let authzHint = t(
'无需手动填写。Audience 会按 MCP ID 自动生成Scopes 按 AuthZ 当前权限动态决定。',
'No manual input is required. The audience is generated from the MCP ID and scopes follow current AuthZ permissions.'
);
if (showAuthzPreview) {
if (!form.id.trim()) {
authzHint = t('先填写 MCP IDAudience 会自动生成为 mcp:<id>。', 'Enter the MCP ID first. The audience will become mcp:<id>.');
} else if (!authzStatus?.enabled) {
authzHint = t(
'当前 workspace 没启用 AuthZ选择 oauth_backend_token 后将无法申请访问 token。',
'AuthZ is not enabled for this workspace, so oauth_backend_token cannot request access tokens.'
);
} else if (!authzStatus.local_backend.registered) {
authzHint = t(
'当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。',
'The backend is not registered in AuthZ yet, so permissions and access tokens are unavailable.'
);
} else if (authzStatus.error) {
authzHint = t(`读取 AuthZ 权限失败:${authzStatus.error}`, `Failed to read AuthZ permissions: ${authzStatus.error}`);
} else if (!authzMcpScopes.available || !authzMcpScopes.enabled) {
authzHint = t(
`AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`,
`AuthZ does not have permissions enabled for ${authAudience || 'this MCP'} yet, so calls will return 403 after saving.`
);
} else {
authzHint = t(`已从 AuthZ 读取到 ${authAudience} 的当前权限。`, `Loaded current permissions for ${authAudience} from AuthZ.`);
}
}
if (loading) {
return (
<div className="flex items-center justify-center py-20">
<Loader2 className="w-6 h-6 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="max-w-6xl mx-auto p-6 space-y-6">
<div className="flex items-center justify-between gap-4 flex-wrap">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<ServerCog className="w-6 h-6" />
{t('MCP 服务', 'MCP servers')}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('管理 MCP 服务配置、连通性和当前已发现的工具。', 'Manage MCP server configuration, connectivity, and discovered tools.')}
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={() => void load(true)}>
<RefreshCw className={`w-4 h-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('刷新', 'Refresh')}
</Button>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
{t('新增 MCP', 'Add MCP')}
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}</DialogTitle>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="id">ID</Label>
<Input id="id" value={form.id} onChange={(e) => setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
</div>
<div className="space-y-2">
<Label htmlFor="tool_timeout">{t('工具超时', 'Tool timeout')}</Label>
<Input id="tool_timeout" type="number" min="1" value={form.tool_timeout} onChange={(e) => setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
</div>
</div>
<Tabs
value={form.mode}
onValueChange={(value) => setForm((s) => ({ ...s, mode: value as McpFormMode }))}
className="space-y-4"
>
<div className="space-y-2">
<Label>{t('接入方式', 'Connection mode')}</Label>
<TabsList className="grid h-auto w-full grid-cols-1 gap-2 bg-transparent p-0 sm:grid-cols-2">
<TabsTrigger
value="remote"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
>
<span className="text-sm font-medium">{t('连接已有 MCP Server', 'Connect to an existing MCP server')}</span>
<span className="text-xs font-normal text-muted-foreground">
{t('适合已经部署好的远程 MCP 服务,填写 URL、请求头和鉴权即可。', 'Use this for a remote MCP server that is already deployed. Provide the URL, headers, and auth settings.')}
</span>
</TabsTrigger>
<TabsTrigger
value="install"
className="h-full flex-col items-start gap-1 rounded-lg border border-border/70 bg-background/80 px-4 py-3 text-left whitespace-normal data-[state=active]:border-primary"
>
<span className="text-sm font-medium">{t('命令安装并启动', 'Install and launch with a command')}</span>
<span className="text-xs font-normal text-muted-foreground">
{t('适合本机通过 `npx`、`uvx` 或其他命令启动 MCP 进程。', 'Use this when the MCP process runs locally via `npx`, `uvx`, or another command.')}
</span>
</TabsTrigger>
</TabsList>
</div>
<TabsContent value="remote" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground">
{t('连接一个已经存在的 MCP Server前端只保存访问地址、请求头和鉴权配置。', 'Connect to an existing MCP server. The frontend only stores the address, headers, and auth settings.')}
</div>
<div className="space-y-2">
<Label htmlFor="url">{t('MCP Server 地址', 'MCP server URL')}</Label>
<Input
id="url"
value={form.url}
onChange={(e) => setForm((s) => ({ ...s, url: e.target.value }))}
placeholder="http://localhost:3001/mcp"
required={form.mode === 'remote'}
/>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="auth_mode">{t('鉴权模式', 'Auth mode')}</Label>
<select
id="auth_mode"
value={form.auth_mode}
onChange={(e) => setForm((s) => ({ ...s, auth_mode: e.target.value }))}
className="flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm"
>
<option value="none">none</option>
<option value="oauth_backend_token">oauth_backend_token</option>
</select>
</div>
<div className="space-y-2 sm:col-span-2">
<Label>{t('AuthZ 权限', 'AuthZ permissions')}</Label>
<div className="rounded-md border border-border/70 bg-muted/30 px-3 py-3 text-sm space-y-2">
<div className="flex flex-col gap-1">
<span className="text-muted-foreground">Audience</span>
<span className="font-mono text-xs break-all">
{showAuthzPreview ? (authAudience || t('填写 MCP ID 后自动生成', 'Generated after you enter the MCP ID')) : t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
</span>
</div>
<div className="flex flex-col gap-1">
<span className="text-muted-foreground">Scopes</span>
<span className="text-xs break-words">
{showAuthzPreview
? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : t('由 AuthZ 当前权限动态决定', 'Derived from current AuthZ permissions'))
: t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
</span>
</div>
<div className="text-xs text-muted-foreground">
{authzHint}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="headers">{t('请求头 JSON', 'Headers JSON')}</Label>
<Textarea
id="headers"
rows={8}
value={form.headers}
onChange={(e) => setForm((s) => ({ ...s, headers: e.target.value }))}
/>
</div>
</TabsContent>
<TabsContent value="install" className="mt-0 rounded-lg border border-border/70 p-4 space-y-4">
<div className="text-sm text-muted-foreground">
{t('通过命令安装并启动本地 MCP 进程,适合 `npx`、`uvx`、脚本或容器方式。', 'Install and launch a local MCP process with a command, such as `npx`, `uvx`, a script, or a container.')}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="command">{t('命令', 'Command')}</Label>
<Input
id="command"
value={form.command}
onChange={(e) => setForm((s) => ({ ...s, command: e.target.value }))}
placeholder="npx"
required={form.mode === 'install'}
/>
</div>
<div className="space-y-2">
<Label htmlFor="args">{t('参数', 'Arguments')}</Label>
<Input
id="args"
value={form.args}
onChange={(e) => setForm((s) => ({ ...s, args: e.target.value }))}
placeholder="-y @modelcontextprotocol/server-github"
/>
</div>
</div>
</TabsContent>
</Tabs>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" onClick={() => setDialogOpen(false)}>
{t('取消', 'Cancel')}
</Button>
<Button type="submit" disabled={submitting}>
{submitting ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <Plus className="w-4 h-4 mr-2" />}
{t('保存', 'Save')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
</div>
{error && (
<Card className="border-destructive">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-destructive text-sm">
<AlertCircle className="w-4 h-4" />
{error}
</div>
</CardContent>
</Card>
)}
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] gap-4">
<div className="space-y-4">
{servers.map((server) => (
<Card
key={server.id}
role="button"
tabIndex={0}
onClick={() => setSelectedServerId(server.id)}
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
setSelectedServerId(server.id);
}
}}
className={cn(
'cursor-pointer transition-colors',
selectedServerId === server.id && 'border-primary bg-primary/5 shadow-sm'
)}
>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-4">
<div>
<CardTitle className="text-base">{server.name}</CardTitle>
<p className="text-xs text-muted-foreground mt-1 font-mono">{server.id}</p>
</div>
<div className="flex items-center gap-2 flex-wrap justify-end">
<Badge variant="outline">{transportLabel(server.transport, locale)}</Badge>
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status, locale)}
</Badge>
</div>
</div>
</CardHeader>
<CardContent className="pt-0 space-y-3 text-sm">
{server.url && <div><span className="font-medium">URL:</span> <span className="text-muted-foreground break-all">{server.url}</span></div>}
{server.command && <div><span className="font-medium">{t('命令:', 'Command:')}</span> <span className="text-muted-foreground">{server.command} {(server.args || []).join(' ')}</span></div>}
{server.auth_mode && server.auth_mode !== 'none' && <div><span className="font-medium">{t('鉴权:', 'Auth:')}</span> <span className="text-muted-foreground">{server.auth_mode}</span></div>}
{(server.auth_audience || server.auth_mode === 'oauth_backend_token') && (
<div><span className="font-medium">Audience</span> <span className="text-muted-foreground">{server.auth_audience || resolveAuthAudience(server.id)}</span></div>
)}
{(server.auth_scopes || []).length > 0 && <div><span className="font-medium">Scopes</span> <span className="text-muted-foreground break-all">{(server.auth_scopes || []).join(', ')}</span></div>}
{server.auth_mode === 'oauth_backend_token' && (!server.auth_scopes || server.auth_scopes.length === 0) && (
<div><span className="font-medium">Scopes</span> <span className="text-muted-foreground">{t('由 AuthZ 动态决定', 'Derived from AuthZ')}</span></div>
)}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{t(`${server.tool_count || 0} 个工具`, `${server.tool_count || 0} tools`)}</span>
<span>{selectedServerId === server.id ? t('已选中', 'Selected') : t('点击查看工具', 'Click to view tools')}</span>
{server.last_error && <span className="text-rose-300">{server.last_error}</span>}
</div>
<div className="flex items-center gap-2 justify-end">
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
openEdit(server);
}}>
{t('编辑', 'Edit')}
</Button>
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleTest(server.id);
}} disabled={testingId === server.id}>
{testingId === server.id ? <Loader2 className="w-4 h-4 animate-spin mr-2" /> : <TestTube2 className="w-4 h-4 mr-2" />}
{t('测试', 'Test')}
</Button>
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleDelete(server.id);
}}>
<Trash2 className="w-4 h-4 mr-2" />
{t('删除', 'Delete')}
</Button>
</div>
</CardContent>
</Card>
))}
{servers.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{t('暂无 MCP 服务。', 'There are no MCP servers yet.')}
</CardContent>
</Card>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
{selectedServer ? t(`${selectedServer.name} 的工具`, `${selectedServer.name} tools`) : t('MCP 工具', 'MCP tools')}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!selectedServer && (
<div className="py-10 text-sm text-muted-foreground text-center">
{t('点击左侧 MCP 服务后,这里才会显示对应的已发现工具。', 'Select an MCP server on the left to show its discovered tools here.')}
</div>
)}
{selectedServer && !selectedToolGroup && (
<div className="text-sm text-muted-foreground">{t('这个 MCP 暂时还没有发现任何工具。', 'No tools have been discovered for this MCP yet.')}</div>
)}
{selectedToolGroup && (
<div className="space-y-2">
<div className="text-sm font-medium">{selectedToolGroup.server_id}</div>
<div className="space-y-2">
{selectedToolGroup.tools.map((tool) => (
<div key={String(tool.name)} className="rounded-md border border-border/70 px-3 py-2 bg-background/60">
<div className="text-sm font-medium">{String(tool.tool_name || tool.name)}</div>
<div className="text-xs text-muted-foreground mt-1 whitespace-pre-wrap break-words">
{String(tool.description || '—')}
</div>
</div>
))}
</div>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}