'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).mcp; if (!mcpPermissions || typeof mcpPermissions !== 'object') { return { available: false, enabled: false, scopes: [] }; } const serverPermission = (mcpPermissions as Record)[trimmed]; if (!serverPermission || typeof serverPermission !== 'object') { return { available: false, enabled: false, scopes: [] }; } const enabled = Boolean((serverPermission as Record).enabled); const rawTools: unknown[] = Array.isArray((serverPermission as Record).tools) ? ((serverPermission as Record).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(cachedServers); const [tools, setTools] = useState> }>>(cachedTools); const [loading, setLoading] = useState(cachedServers.length === 0 && cachedTools.length === 0); const [refreshing, setRefreshing] = useState(false); const [error, setError] = useState(null); const [dialogOpen, setDialogOpen] = useState(false); const [submitting, setSubmitting] = useState(false); const [testingId, setTestingId] = useState(null); const [editingId, setEditingId] = useState(null); const [form, setForm] = useState(createEmptyForm()); const [authzStatus, setAuthzStatus] = useState(null); const [selectedServerId, setSelectedServerId] = useState(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; }; 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 ID,Audience 会自动生成为 mcp:。', 'Enter the MCP ID first. The audience will become mcp:.'); } 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 (
); } return (

{t('MCP 服务', 'MCP servers')}

{t('管理 MCP 服务配置、连通性和当前已发现的工具。', 'Manage MCP server configuration, connectivity, and discovered tools.')}

{ setDialogOpen(open); if (!open) resetForm(); }}> {editingId ? t('编辑 MCP 服务', 'Edit MCP server') : t('新增 MCP 服务', 'Add MCP server')}
setForm((s) => ({ ...s, id: e.target.value }))} required disabled={!!editingId} />
setForm((s) => ({ ...s, tool_timeout: e.target.value }))} />
setForm((s) => ({ ...s, mode: value as McpFormMode }))} className="space-y-4" >
{t('连接已有 MCP Server', 'Connect to an existing MCP server')} {t('适合已经部署好的远程 MCP 服务,填写 URL、请求头和鉴权即可。', 'Use this for a remote MCP server that is already deployed. Provide the URL, headers, and auth settings.')} {t('命令安装并启动', 'Install and launch with a command')} {t('适合本机通过 `npx`、`uvx` 或其他命令启动 MCP 进程。', 'Use this when the MCP process runs locally via `npx`, `uvx`, or another command.')}
{t('连接一个已经存在的 MCP Server,前端只保存访问地址、请求头和鉴权配置。', 'Connect to an existing MCP server. The frontend only stores the address, headers, and auth settings.')}
setForm((s) => ({ ...s, url: e.target.value }))} placeholder="http://localhost:3001/mcp" required={form.mode === 'remote'} />
Audience {showAuthzPreview ? (authAudience || t('填写 MCP ID 后自动生成', 'Generated after you enter the MCP ID')) : t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
Scopes {showAuthzPreview ? (authzMcpScopes.scopes.length > 0 ? authzMcpScopes.scopes.join(', ') : t('由 AuthZ 当前权限动态决定', 'Derived from current AuthZ permissions')) : t('关闭鉴权时无需配置', 'Not required when auth is disabled')}
{authzHint}