Files
beaver_project/app-instance/frontend/app/(app)/mcp/page.tsx
2026-03-13 16:40:08 +08:00

590 lines
26 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';
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) {
if (status === 'connected') return '已连接';
if (status === 'error') return '异常';
if (status === 'disconnected' || !status) return '未连接';
return status;
}
function transportLabel(transport?: string) {
if (transport === 'stdio') return '标准输入输出';
if (transport === 'http') return 'HTTP';
return transport || '-';
}
export default function MCPPage() {
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 || '加载 MCP 服务失败');
} 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} 必须是 JSON 对象`);
}
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('ID 不能为空');
}
if (!Number.isFinite(toolTimeout) || toolTimeout < 1) {
throw new Error('工具超时必须大于 0');
}
if (form.mode === 'remote' && !url) {
throw new Error('请输入 MCP Server 地址');
}
if (form.mode === 'install' && !command) {
throw new Error('请输入安装或启动命令');
}
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('请求头', 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 || '保存 MCP 服务失败');
} 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 || '删除 MCP 服务失败');
}
};
const handleTest = async (serverId: string) => {
setTestingId(serverId);
try {
await testMcpServer(serverId);
await load(true);
} catch (err: any) {
setError(err.message || '测试 MCP 服务失败');
} 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 = '无需手动填写。Audience 会按 MCP ID 自动生成Scopes 按 AuthZ 当前权限动态决定。';
if (showAuthzPreview) {
if (!form.id.trim()) {
authzHint = '先填写 MCP IDAudience 会自动生成为 mcp:<id>。';
} else if (!authzStatus?.enabled) {
authzHint = '当前 workspace 没启用 AuthZ选择 oauth_backend_token 后将无法申请访问 token。';
} else if (!authzStatus.local_backend.registered) {
authzHint = '当前 backend 还没有在 AuthZ 注册,暂时无法读取权限或申请 token。';
} else if (authzStatus.error) {
authzHint = `读取 AuthZ 权限失败:${authzStatus.error}`;
} else if (!authzMcpScopes.available || !authzMcpScopes.enabled) {
authzHint = `AuthZ 里还没有为 ${authAudience || '这个 MCP'} 开启权限,保存后调用会返回 403。`;
} else {
authzHint = `已从 AuthZ 读取到 ${authAudience} 的当前权限。`;
}
}
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" />
MCP
</h1>
<p className="text-sm text-muted-foreground mt-1">
MCP
</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' : ''}`} />
</Button>
<Dialog open={dialogOpen} onOpenChange={(open) => {
setDialogOpen(open);
if (!open) resetForm();
}}>
<DialogTrigger asChild>
<Button size="sm">
<Plus className="w-4 h-4 mr-2" />
MCP
</Button>
</DialogTrigger>
<DialogContent className="sm:max-w-2xl">
<DialogHeader>
<DialogTitle>{editingId ? '编辑 MCP 服务' : '新增 MCP 服务'}</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"></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></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"> MCP Server</span>
<span className="text-xs font-normal text-muted-foreground">
MCP URL
</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"></span>
<span className="text-xs font-normal text-muted-foreground">
`npx``uvx` MCP
</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">
MCP Server访
</div>
<div className="space-y-2">
<Label htmlFor="url">MCP Server </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"></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>AuthZ </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 || '填写 MCP ID 后自动生成') : '关闭鉴权时无需配置'}
</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(', ') : '由 AuthZ 当前权限动态决定')
: '关闭鉴权时无需配置'}
</span>
</div>
<div className="text-xs text-muted-foreground">
{authzHint}
</div>
</div>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="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">
MCP `npx``uvx`
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="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"></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)}>
</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" />}
</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)}</Badge>
<Badge variant={server.status === 'connected' ? 'default' : server.status === 'error' ? 'destructive' : 'secondary'}>
{serverStatusLabel(server.status)}
</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"></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"></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"> AuthZ </span></div>
)}
<div className="flex items-center gap-2 flex-wrap text-xs text-muted-foreground">
<span>{server.tool_count || 0} </span>
<span>{selectedServerId === server.id ? '已选中' : '点击查看工具'}</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);
}}>
</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" />}
</Button>
<Button variant="outline" size="sm" onClick={(event) => {
event.stopPropagation();
void handleDelete(server.id);
}}>
<Trash2 className="w-4 h-4 mr-2" />
</Button>
</div>
</CardContent>
</Card>
))}
{servers.length === 0 && (
<Card>
<CardContent className="py-12 text-center text-muted-foreground">
MCP
</CardContent>
</Card>
)}
</div>
<Card>
<CardHeader>
<CardTitle className="text-base flex items-center gap-2">
<Wrench className="w-4 h-4" />
{selectedServer ? `${selectedServer.name} 的工具` : 'MCP 工具'}
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{!selectedServer && (
<div className="py-10 text-sm text-muted-foreground text-center">
MCP
</div>
)}
{selectedServer && !selectedToolGroup && (
<div className="text-sm text-muted-foreground"> MCP </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>
);
}