Files
beaver_project/app-instance/frontend/app/(app)/mcp/page.tsx
2026-06-03 12:06:34 +08:00

635 lines
30 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 || '-';
}
function discoveredToolCount(
serverId: string,
tools: Array<{ server_id: string; tools: Array<Record<string, unknown>> }>,
fallback?: number,
) {
const group = tools.find((item) => item.server_id === serverId);
if (group) return group.tools.length;
return fallback || 0;
}
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 [toolTab, setToolTab] = useState<'local' | 'online'>('local');
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;
const visibleServers = servers.filter((server) => (server.kind || (server.transport === 'stdio' ? 'local' : 'online')) === toolTab);
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('工具', 'Tools')}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{t('本地工具和在线工具都通过 MCP Server 暴露;本地工具按类别由真实 stdio MCP 子进程承载。', 'Local and online tools are both exposed through MCP servers. Local tool categories run as real stdio MCP subprocesses.')}
</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('新增工具服务', 'Add tool server')}
</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>
)}
<Tabs value={toolTab} onValueChange={(value) => {
setToolTab(value as 'local' | 'online');
setSelectedServerId(null);
}} className="space-y-4">
<TabsList>
<TabsTrigger value="local">{t('本地工具', 'Local tools')}</TabsTrigger>
<TabsTrigger value="online">{t('在线工具', 'Online tools')}</TabsTrigger>
</TabsList>
</Tabs>
<div className="grid grid-cols-1 xl:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] gap-4">
<div className="space-y-4">
{visibleServers.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="secondary">{server.category || (server.kind === 'local' ? 'local' : 'online')}</Badge>
{server.managed && <Badge variant="outline">{t('内置', 'Built-in')}</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(`${discoveredToolCount(server.id, tools, server.tool_count)} 个工具`, `${discoveredToolCount(server.id, tools, server.tool_count)} 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">
{!server.managed && (
<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>
{!server.managed && (
<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>
))}
{visibleServers.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
{toolTab === 'local' ? t('暂无本地工具服务。', 'There are no local tool servers yet.') : t('暂无在线工具服务。', 'There are no online tool 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('工具详情', 'Tool details')}
</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>
);
}