第一次提交

This commit is contained in:
2026-03-13 16:40:08 +08:00
commit 0a49bcfb2d
277 changed files with 61890 additions and 0 deletions

View File

@ -0,0 +1,589 @@
'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>
);
}