From 9e8abf5f26acf2dfbf7db48bfb1405e6262fe16f Mon Sep 17 00:00:00 2001 From: Jason Date: Fri, 14 Nov 2025 15:24:48 +0800 Subject: [PATCH] feat(frontend): implement unified MCP panel for v3.7.0 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete Phase 3 (P0) frontend implementation for unified MCP management: **New Files:** - src/hooks/useMcp.ts: React Query hooks for unified MCP operations - src/components/mcp/UnifiedMcpPanel.tsx: Unified MCP management panel - src/components/ui/checkbox.tsx: Checkbox component from shadcn/ui **Features:** - Unified panel with three-column layout: server info + app checkboxes + actions - Multi-app control: Claude/Codex/Gemini checkboxes for each server - Real-time stats: Show enabled server counts per app - Full CRUD operations: Add, edit, delete, sync all servers **Integration:** - Replace old app-specific McpPanel with UnifiedMcpPanel in App.tsx - Update McpFormModal to support unified mode with apps field - Add i18n support: mcp.unifiedPanel namespace (zh/en) **Type Safety:** - Ensure McpServer.apps field always initialized - Fix all test files to include apps field - TypeScript type check passes ✅ **Architecture:** - Single source of truth: mcp.servers manages all MCP configs - Per-server app control: apps.claude/codex/gemini boolean flags - Backward compatible: McpFormModal supports both unified and legacy modes Next: P1 tasks (import dialogs, sub-components, tests) --- src/App.tsx | 5 +- src/components/mcp/McpFormModal.tsx | 46 ++- src/components/mcp/UnifiedMcpPanel.tsx | 397 +++++++++++++++++++++++++ src/components/ui/checkbox.tsx | 28 ++ src/hooks/useMcp.ts | 70 +++++ src/i18n/locales/en.json | 16 + src/i18n/locales/zh.json | 16 + tests/hooks/useMcpActions.test.tsx | 1 + tests/integration/McpPanel.test.tsx | 1 + tests/msw/state.ts | 4 + 10 files changed, 569 insertions(+), 15 deletions(-) create mode 100644 src/components/mcp/UnifiedMcpPanel.tsx create mode 100644 src/components/ui/checkbox.tsx create mode 100644 src/hooks/useMcp.ts diff --git a/src/App.tsx b/src/App.tsx index eca4c9b..6ea23c7 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -20,7 +20,7 @@ import { ConfirmDialog } from "@/components/ConfirmDialog"; import { SettingsDialog } from "@/components/settings/SettingsDialog"; import { UpdateBadge } from "@/components/UpdateBadge"; import UsageScriptModal from "@/components/UsageScriptModal"; -import McpPanel from "@/components/mcp/McpPanel"; +import UnifiedMcpPanel from "@/components/mcp/UnifiedMcpPanel"; import PromptPanel from "@/components/prompts/PromptPanel"; import { Button } from "@/components/ui/button"; @@ -302,10 +302,9 @@ function App() { appId={activeApp} /> - ); diff --git a/src/components/mcp/McpFormModal.tsx b/src/components/mcp/McpFormModal.tsx index 12a5305..746147c 100644 --- a/src/components/mcp/McpFormModal.tsx +++ b/src/components/mcp/McpFormModal.tsx @@ -34,6 +34,7 @@ import { } from "@/utils/tomlUtils"; import { normalizeTomlText } from "@/utils/textNormalization"; import { useMcpValidation } from "./useMcpValidation"; +import { useUpsertMcpServer } from "@/hooks/useMcp"; interface McpFormModalProps { appId: AppId; @@ -46,6 +47,7 @@ interface McpFormModalProps { ) => Promise; onClose: () => void; existingIds?: string[]; + unified?: boolean; // 统一模式:使用 useUpsertMcpServer 而非传统回调 } /** @@ -60,11 +62,15 @@ const McpFormModal: React.FC = ({ onSave, onClose, existingIds = [], + unified = false, }) => { const { t } = useTranslation(); const { formatTomlError, validateTomlConfig, validateJsonConfig } = useMcpValidation(); + // 统一模式下使用 mutation + const upsertMutation = useUpsertMcpServer(); + const [formId, setFormId] = useState( () => editingId || initialData?.id || "", ); @@ -361,24 +367,32 @@ const McpFormModal: React.FC = ({ setSaving(true); try { + // 先处理 name 字段(必填) + const nameTrimmed = (formName || trimmedId).trim(); + const finalName = nameTrimmed || trimmedId; + const entry: McpServer = { ...(initialData ? { ...initialData } : {}), id: trimmedId, + name: finalName, server: serverSpec, + // 确保 apps 字段始终存在(v3.7.0 新架构必需) + apps: initialData?.apps || { claude: false, codex: false, gemini: false }, }; - // 修复:新增 MCP 时默认启用(enabled=true) - // 编辑模式下保留原有的 enabled 状态 - if (initialData?.enabled !== undefined) { - entry.enabled = initialData.enabled; - } else { - // 新增模式:默认启用 - entry.enabled = true; + // 统一模式下无需再初始化 apps(上面已经处理) + // 传统模式需要设置 enabled 字段 + if (!unified) { + // 传统模式:新增 MCP 时默认启用(enabled=true) + // 编辑模式下保留原有的 enabled 状态 + if (initialData?.enabled !== undefined) { + entry.enabled = initialData.enabled; + } else { + // 新增模式:默认启用 + entry.enabled = true; + } } - const nameTrimmed = (formName || trimmedId).trim(); - entry.name = nameTrimmed || trimmedId; - const descriptionTrimmed = formDescription.trim(); if (descriptionTrimmed) { entry.description = descriptionTrimmed; @@ -410,8 +424,16 @@ const McpFormModal: React.FC = ({ delete entry.tags; } - // 显式等待父组件保存流程 - await onSave(trimmedId, entry, { syncOtherSide }); + // 显式等待保存流程 + if (unified) { + // 统一模式:调用 useUpsertMcpServer mutation + await upsertMutation.mutateAsync(entry); + toast.success(t("common.success")); + onClose(); + } else { + // 传统模式:调用父组件回调 + await onSave(trimmedId, entry, { syncOtherSide }); + } } catch (error: any) { const detail = extractErrorMessage(error); const mapped = translateMcpBackendError(detail, t); diff --git a/src/components/mcp/UnifiedMcpPanel.tsx b/src/components/mcp/UnifiedMcpPanel.tsx new file mode 100644 index 0000000..fed80ed --- /dev/null +++ b/src/components/mcp/UnifiedMcpPanel.tsx @@ -0,0 +1,397 @@ +import React, { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Plus, Server, Check, RefreshCw } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Checkbox } from "@/components/ui/checkbox"; +import { useAllMcpServers, useToggleMcpApp, useSyncAllMcpServers } from "@/hooks/useMcp"; +import type { McpServer } from "@/types"; +import type { AppId } from "@/lib/api/types"; +import McpFormModal from "./McpFormModal"; +import { ConfirmDialog } from "../ConfirmDialog"; +import { useDeleteMcpServer } from "@/hooks/useMcp"; +import { Edit3, Trash2 } from "lucide-react"; +import { settingsApi } from "@/lib/api"; +import { mcpPresets } from "@/config/mcpPresets"; +import { toast } from "sonner"; + +interface UnifiedMcpPanelProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +/** + * 统一 MCP 管理面板 + * v3.7.0 新架构:所有 MCP 服务器统一管理,每个服务器通过复选框控制应用到哪些客户端 + */ +const UnifiedMcpPanel: React.FC = ({ + open, + onOpenChange, +}) => { + const { t } = useTranslation(); + const [isFormOpen, setIsFormOpen] = useState(false); + const [editingId, setEditingId] = useState(null); + const [confirmDialog, setConfirmDialog] = useState<{ + isOpen: boolean; + title: string; + message: string; + onConfirm: () => void; + } | null>(null); + + // Queries and Mutations + const { data: serversMap, isLoading } = useAllMcpServers(); + const toggleAppMutation = useToggleMcpApp(); + const deleteServerMutation = useDeleteMcpServer(); + const syncAllMutation = useSyncAllMcpServers(); + + // Convert serversMap to array for easier rendering + const serverEntries = useMemo((): Array<[string, McpServer]> => { + if (!serversMap) return []; + return Object.entries(serversMap); + }, [serversMap]); + + // Count enabled servers per app + const enabledCounts = useMemo(() => { + const counts = { claude: 0, codex: 0, gemini: 0 }; + serverEntries.forEach(([_, server]) => { + if (server.apps.claude) counts.claude++; + if (server.apps.codex) counts.codex++; + if (server.apps.gemini) counts.gemini++; + }); + return counts; + }, [serverEntries]); + + const handleToggleApp = async ( + serverId: string, + app: AppId, + enabled: boolean, + ) => { + try { + await toggleAppMutation.mutateAsync({ serverId, app, enabled }); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + const handleEdit = (id: string) => { + setEditingId(id); + setIsFormOpen(true); + }; + + const handleAdd = () => { + setEditingId(null); + setIsFormOpen(true); + }; + + const handleDelete = (id: string) => { + setConfirmDialog({ + isOpen: true, + title: t("mcp.unifiedPanel.deleteServer"), + message: t("mcp.unifiedPanel.deleteConfirm", { id }), + onConfirm: async () => { + try { + await deleteServerMutation.mutateAsync(id); + setConfirmDialog(null); + toast.success(t("common.success")); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }, + }); + }; + + const handleSyncAll = async () => { + try { + await syncAllMutation.mutateAsync(); + toast.success(t("mcp.unifiedPanel.syncAllSuccess")); + } catch (error) { + toast.error(t("common.error"), { + description: String(error), + }); + } + }; + + const handleCloseForm = () => { + setIsFormOpen(false); + setEditingId(null); + }; + + return ( + <> + + + +
+ {t("mcp.unifiedPanel.title")} +
+ + +
+
+
+ + {/* Info Section */} +
+
+ {t("mcp.serverCount", { count: serverEntries.length })} ·{" "} + {t("mcp.unifiedPanel.apps.claude")}: {enabledCounts.claude} ·{" "} + {t("mcp.unifiedPanel.apps.codex")}: {enabledCounts.codex} ·{" "} + {t("mcp.unifiedPanel.apps.gemini")}: {enabledCounts.gemini} +
+
+ + {/* Content - Scrollable */} +
+ {isLoading ? ( +
+ {t("mcp.loading")} +
+ ) : serverEntries.length === 0 ? ( +
+
+ +
+

+ {t("mcp.unifiedPanel.noServers")} +

+

+ {t("mcp.emptyDescription")} +

+
+ ) : ( +
+ {serverEntries.map(([id, server]) => ( + + ))} +
+ )} +
+ + + + +
+
+ + {/* Form Modal */} + {isFormOpen && ( + { + setIsFormOpen(false); + setEditingId(null); + }} + onClose={handleCloseForm} + unified + /> + )} + + {/* Confirm Dialog */} + {confirmDialog && ( + setConfirmDialog(null)} + /> + )} + + ); +}; + +/** + * 统一 MCP 列表项组件 + * 展示服务器名称、描述,以及三个应用的复选框 + */ +interface UnifiedMcpListItemProps { + id: string; + server: McpServer; + onToggleApp: (serverId: string, app: AppId, enabled: boolean) => void; + onEdit: (id: string) => void; + onDelete: (id: string) => void; +} + +const UnifiedMcpListItem: React.FC = ({ + id, + server, + onToggleApp, + onEdit, + onDelete, +}) => { + const { t } = useTranslation(); + const name = server.name || id; + const description = server.description || ""; + + // 匹配预设元信息 + const meta = mcpPresets.find((p) => p.id === id); + const docsUrl = server.docs || meta?.docs; + const homepageUrl = server.homepage || meta?.homepage; + const tags = server.tags || meta?.tags; + + const openDocs = async () => { + const url = docsUrl || homepageUrl; + if (!url) return; + try { + await settingsApi.openExternal(url); + } catch { + // ignore + } + }; + + return ( +
+
+ {/* 左侧:服务器信息 */} +
+

+ {name} +

+ {description && ( +

+ {description} +

+ )} + {!description && tags && tags.length > 0 && ( +

+ {tags.join(", ")} +

+ )} +
+ + {/* 中间:应用复选框 */} +
+
+ + onToggleApp(id, "claude", checked === true) + } + /> + +
+ +
+ + onToggleApp(id, "codex", checked === true) + } + /> + +
+ +
+ + onToggleApp(id, "gemini", checked === true) + } + /> + +
+
+ + {/* 右侧:操作按钮 */} +
+ {docsUrl && ( + + )} + + + +
+
+
+ ); +}; + +export default UnifiedMcpPanel; diff --git a/src/components/ui/checkbox.tsx b/src/components/ui/checkbox.tsx new file mode 100644 index 0000000..e8f5fd8 --- /dev/null +++ b/src/components/ui/checkbox.tsx @@ -0,0 +1,28 @@ +import * as React from "react" +import * as CheckboxPrimitive from "@radix-ui/react-checkbox" +import { Check } from "lucide-react" + +import { cn } from "@/lib/utils" + +const Checkbox = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + + + + + +)) +Checkbox.displayName = CheckboxPrimitive.Root.displayName + +export { Checkbox } diff --git a/src/hooks/useMcp.ts b/src/hooks/useMcp.ts new file mode 100644 index 0000000..781bad1 --- /dev/null +++ b/src/hooks/useMcp.ts @@ -0,0 +1,70 @@ +import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; +import { mcpApi } from '@/lib/api/mcp'; +import type { McpServer } from '@/types'; +import type { AppId } from '@/lib/api/types'; + +/** + * 查询所有 MCP 服务器(统一管理) + */ +export function useAllMcpServers() { + return useQuery({ + queryKey: ['mcp', 'all'], + queryFn: () => mcpApi.getAllServers(), + }); +} + +/** + * 添加或更新 MCP 服务器 + */ +export function useUpsertMcpServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (server: McpServer) => mcpApi.upsertUnifiedServer(server), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] }); + }, + }); +} + +/** + * 切换 MCP 服务器在特定应用的启用状态 + */ +export function useToggleMcpApp() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: ({ + serverId, + app, + enabled, + }: { + serverId: string; + app: AppId; + enabled: boolean; + }) => mcpApi.toggleApp(serverId, app, enabled), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] }); + }, + }); +} + +/** + * 删除 MCP 服务器 + */ +export function useDeleteMcpServer() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (id: string) => mcpApi.deleteUnifiedServer(id), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['mcp', 'all'] }); + }, + }); +} + +/** + * 同步所有启用的 MCP 服务器到各应用的 live 配置 + */ +export function useSyncAllMcpServers() { + return useMutation({ + mutationFn: () => mcpApi.syncAllServers(), + }); +} diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index de3c893..2d45987 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -441,6 +441,22 @@ "claudeTitle": "Claude Code MCP Management", "codexTitle": "Codex MCP Management", "geminiTitle": "Gemini MCP Management", + "unifiedPanel": { + "title": "MCP Server Management", + "addServer": "Add Server", + "editServer": "Edit Server", + "deleteServer": "Delete Server", + "deleteConfirm": "Are you sure you want to delete server \"{{id}}\"? This action cannot be undone.", + "syncAll": "Sync All", + "syncAllSuccess": "All enabled servers have been synced to application configs", + "noServers": "No servers yet", + "enabledApps": "Enabled Apps", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } + }, "userLevelPath": "User-level MCP path", "serverList": "Servers", "loading": "Loading...", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index e7279a8..6156020 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -441,6 +441,22 @@ "claudeTitle": "Claude Code MCP 管理", "codexTitle": "Codex MCP 管理", "geminiTitle": "Gemini MCP 管理", + "unifiedPanel": { + "title": "MCP 服务器管理", + "addServer": "添加服务器", + "editServer": "编辑服务器", + "deleteServer": "删除服务器", + "deleteConfirm": "确定要删除服务器 \"{{id}}\" 吗?此操作无法撤销。", + "syncAll": "同步全部", + "syncAllSuccess": "已同步所有启用的服务器到各应用配置", + "noServers": "暂无服务器", + "enabledApps": "启用的应用", + "apps": { + "claude": "Claude", + "codex": "Codex", + "gemini": "Gemini" + } + }, "userLevelPath": "用户级 MCP 配置路径", "serverList": "服务器列表", "loading": "加载中...", diff --git a/tests/hooks/useMcpActions.test.tsx b/tests/hooks/useMcpActions.test.tsx index 49645cf..a76b86f 100644 --- a/tests/hooks/useMcpActions.test.tsx +++ b/tests/hooks/useMcpActions.test.tsx @@ -36,6 +36,7 @@ const createServer = (overrides: Partial = {}): McpServer => ({ name: "Test Server", description: "desc", enabled: false, + apps: { claude: false, codex: false, gemini: false }, server: { type: "stdio", command: "run.sh", diff --git a/tests/integration/McpPanel.test.tsx b/tests/integration/McpPanel.test.tsx index a4a41ea..afc4473 100644 --- a/tests/integration/McpPanel.test.tsx +++ b/tests/integration/McpPanel.test.tsx @@ -36,6 +36,7 @@ const baseServers: Record = { id: "sample", name: "Sample Claude Server", enabled: true, + apps: { claude: true, codex: false, gemini: false }, server: { type: "stdio", command: "claude-server", diff --git a/tests/msw/state.ts b/tests/msw/state.ts index afc327c..18cdea1 100644 --- a/tests/msw/state.ts +++ b/tests/msw/state.ts @@ -82,6 +82,7 @@ let mcpConfigs: McpConfigState = { id: "sample", name: "Sample Claude Server", enabled: true, + apps: { claude: true, codex: false, gemini: false }, server: { type: "stdio", command: "claude-server", @@ -93,6 +94,7 @@ let mcpConfigs: McpConfigState = { id: "httpServer", name: "HTTP Codex Server", enabled: false, + apps: { claude: false, codex: true, gemini: false }, server: { type: "http", url: "http://localhost:3000", @@ -123,6 +125,7 @@ export const resetProviderState = () => { id: "sample", name: "Sample Claude Server", enabled: true, + apps: { claude: true, codex: false, gemini: false }, server: { type: "stdio", command: "claude-server", @@ -134,6 +137,7 @@ export const resetProviderState = () => { id: "httpServer", name: "HTTP Codex Server", enabled: false, + apps: { claude: false, codex: true, gemini: false }, server: { type: "http", url: "http://localhost:3000",