// ======================================== // MCP Manager Page // ======================================== // Manage MCP servers (Model Context Protocol) with tabbed interface // Supports Templates, Servers, and Cross-CLI tabs import { useState, useMemo } from 'react'; import { useIntl } from 'react-intl'; import { useQuery, useQueryClient } from '@tanstack/react-query'; import { Server, Plus, Search, RefreshCw, Globe, Folder, Power, PowerOff, Edit, Trash2, ChevronDown, ChevronUp, BookmarkPlus, AlertTriangle, } from 'lucide-react'; import { Card } from '@/components/ui/Card'; import { Button } from '@/components/ui/Button'; import { Input } from '@/components/ui/Input'; import { Badge } from '@/components/ui/Badge'; import { McpServerDialog } from '@/components/mcp/McpServerDialog'; import { CliModeToggle, type CliMode } from '@/components/mcp/CliModeToggle'; import { CodexMcpEditableCard } from '@/components/mcp/CodexMcpEditableCard'; import { CcwToolsMcpCard } from '@/components/mcp/CcwToolsMcpCard'; import { McpTemplatesSection, TemplateSaveDialog } from '@/components/mcp/McpTemplatesSection'; import { RecommendedMcpSection } from '@/components/mcp/RecommendedMcpSection'; import { WindowsCompatibilityWarning } from '@/components/mcp/WindowsCompatibilityWarning'; import { CrossCliSyncPanel } from '@/components/mcp/CrossCliSyncPanel'; import { AllProjectsTable } from '@/components/mcp/AllProjectsTable'; import { OtherProjectsSection } from '@/components/mcp/OtherProjectsSection'; import { TabsNavigation } from '@/components/ui/TabsNavigation'; import { useMcpServers, useMcpServerMutations, useNotifications } from '@/hooks'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; import { fetchCodexMcpServers, fetchCcwMcpConfig, fetchCcwMcpConfigForCodex, updateCcwConfig, updateCcwConfigForCodex, installCcwMcp, uninstallCcwMcpFromScope, codexRemoveServer, codexToggleServer, saveMcpTemplate, type McpServer, type McpServerConflict, type CcwMcpConfig, } from '@/lib/api'; import { cn } from '@/lib/utils'; // ========== MCP Server Card Component ========== interface McpServerCardProps { server: McpServer; isExpanded: boolean; onToggleExpand: () => void; onToggle: (serverName: string, enabled: boolean) => void; onEdit: (server: McpServer) => void; onDelete: (server: McpServer) => void; onSaveAsTemplate: (server: McpServer) => void; conflictInfo?: McpServerConflict; } function McpServerCard({ server, isExpanded, onToggleExpand, onToggle, onEdit, onDelete, onSaveAsTemplate, conflictInfo }: McpServerCardProps) { const { formatMessage } = useIntl(); return ( {/* Header */}
{server.name} {server.scope === 'global' ? ( <>{formatMessage({ id: 'mcp.scope.global' })} ) : ( <>{formatMessage({ id: 'mcp.scope.project' })} )} {conflictInfo && ( {formatMessage({ id: 'mcp.conflict.badge' })} )} {server.enabled && ( {formatMessage({ id: 'mcp.status.enabled' })} )}

{server.command} {server.args?.join(' ') || ''}

{isExpanded ? ( ) : ( )}
{/* Expanded Content */} {isExpanded && (
{/* Command details */}

{formatMessage({ id: 'mcp.command' })}

{server.command}
{/* Args */} {server.args && server.args.length > 0 && (

{formatMessage({ id: 'mcp.args' })}

{server.args.map((arg, idx) => ( {arg} ))}
)} {/* Environment variables */} {server.env && Object.keys(server.env).length > 0 && (

{formatMessage({ id: 'mcp.env' })}

{Object.entries(server.env).map(([key, value]) => (
{key} = {value as string}
))}
)} {/* Conflict warning panel */} {conflictInfo && (
{formatMessage({ id: 'mcp.conflict.title' })}

{formatMessage({ id: 'mcp.conflict.description' }, { scope: formatMessage({ id: `mcp.scope.${conflictInfo.effectiveScope}` }) })}

{formatMessage({ id: 'mcp.conflict.resolution' })}

)}
)}
); } // ========== Main Page Component ========== export function McpManagerPage() { const { formatMessage } = useIntl(); const [activeTab, setActiveTab] = useState<'templates' | 'servers' | 'cross-cli'>('servers'); const [searchQuery, setSearchQuery] = useState(''); const [scopeFilter, setScopeFilter] = useState<'all' | 'project' | 'global'>('all'); const [expandedServers, setExpandedServers] = useState>(new Set()); const [dialogOpen, setDialogOpen] = useState(false); const [editingServer, setEditingServer] = useState(undefined); const [cliMode, setCliMode] = useState('claude'); const [codexExpandedServers, setCodexExpandedServers] = useState>(new Set()); const [saveTemplateDialogOpen, setSaveTemplateDialogOpen] = useState(false); const [serverToSaveAsTemplate, setServerToSaveAsTemplate] = useState(undefined); const queryClient = useQueryClient(); const notifications = useNotifications(); const { servers, projectServers, globalServers, conflicts, totalCount, enabledCount, isLoading, isFetching, refetch, } = useMcpServers({ scope: scopeFilter === 'all' ? undefined : scopeFilter, }); // Fetch Codex MCP servers when in codex mode const codexQuery = useQuery({ queryKey: ['codexMcpServers'], queryFn: fetchCodexMcpServers, enabled: cliMode === 'codex', staleTime: 2 * 60 * 1000, // 2 minutes }); const projectPath = useWorkflowStore(selectProjectPath); // Fetch CCW Tools MCP configuration (Claude mode only) const ccwMcpQuery = useQuery({ queryKey: ['ccwMcpConfig', projectPath], queryFn: () => fetchCcwMcpConfig(projectPath ?? undefined), enabled: cliMode === 'claude', staleTime: 5 * 60 * 1000, // 5 minutes }); // Fetch CCW Tools MCP configuration (Codex mode only) const ccwMcpCodexQuery = useQuery({ queryKey: ['ccwMcpConfigCodex'], queryFn: fetchCcwMcpConfigForCodex, enabled: cliMode === 'codex', staleTime: 5 * 60 * 1000, // 5 minutes }); const { toggleServer, deleteServer, } = useMcpServerMutations(); const toggleExpand = (serverName: string) => { setExpandedServers((prev) => { const next = new Set(prev); if (next.has(serverName)) { next.delete(serverName); } else { next.add(serverName); } return next; }); }; const toggleCodexExpand = (serverName: string) => { setCodexExpandedServers((prev) => { const next = new Set(prev); if (next.has(serverName)) { next.delete(serverName); } else { next.add(serverName); } return next; }); }; const handleToggle = (serverName: string, enabled: boolean) => { toggleServer(serverName, enabled); }; const handleDelete = async (server: McpServer) => { if (confirm(formatMessage({ id: 'mcp.deleteConfirm' }, { name: server.name }))) { try { await deleteServer(server.name, server.scope); notifications.success( formatMessage({ id: 'mcp.actions.delete' }), server.name ); refetch(); } catch (error) { console.error('Failed to delete MCP server:', error); notifications.error( formatMessage({ id: 'mcp.actions.delete' }), error instanceof Error ? error.message : String(error) ); } } }; const handleEdit = (server: McpServer) => { setEditingServer(server); setDialogOpen(true); }; const handleAddClick = () => { setEditingServer(undefined); setDialogOpen(true); }; const handleDialogClose = () => { setDialogOpen(false); setEditingServer(undefined); }; const handleDialogSave = () => { setDialogOpen(false); setEditingServer(undefined); refetch(); }; const handleModeChange = (mode: CliMode) => { setCliMode(mode); }; // CCW MCP handlers const ccwConfig = ccwMcpQuery.data ?? { isInstalled: false, enabledTools: [], projectRoot: undefined, allowedDirs: undefined, enableSandbox: undefined, installedScopes: [] as ('global' | 'project')[], }; const ccwMcpQueryKey = ['ccwMcpConfig', projectPath]; const handleToggleCcwTool = async (tool: string, enabled: boolean) => { // Read latest from cache to avoid stale closures const currentConfig = queryClient.getQueryData(ccwMcpQueryKey) ?? ccwConfig; const currentTools = currentConfig.enabledTools; const previousConfig = queryClient.getQueryData(ccwMcpQueryKey); const updatedTools = enabled ? (currentTools.includes(tool) ? currentTools : [...currentTools, tool]) : currentTools.filter((t) => t !== tool); // Optimistic cache update for immediate UI response queryClient.setQueryData(ccwMcpQueryKey, (old: CcwMcpConfig | undefined) => { if (!old) return old; return { ...old, enabledTools: updatedTools }; }); try { await updateCcwConfig({ ...currentConfig, enabledTools: updatedTools }); } catch (error) { console.error('Failed to toggle CCW tool:', error); queryClient.setQueryData(ccwMcpQueryKey, previousConfig); } ccwMcpQuery.refetch(); }; const handleUpdateCcwConfig = async (config: Partial) => { // Read BEFORE optimistic update to capture previous state for rollback const previousConfig = queryClient.getQueryData(ccwMcpQueryKey); // Optimistic cache update for immediate UI response queryClient.setQueryData(ccwMcpQueryKey, (old: CcwMcpConfig | undefined) => { if (!old) return old; return { ...old, ...config }; }); // Read AFTER optimistic update to get the latest merged state const currentConfig = queryClient.getQueryData(ccwMcpQueryKey) ?? ccwConfig; try { // Only pass the fields that updateCcwConfig expects await updateCcwConfig({ enabledTools: currentConfig.enabledTools, projectRoot: currentConfig.projectRoot, allowedDirs: currentConfig.allowedDirs, enableSandbox: currentConfig.enableSandbox, }); } catch (error) { console.error('Failed to update CCW config:', error); queryClient.setQueryData(ccwMcpQueryKey, previousConfig); } ccwMcpQuery.refetch(); }; const handleCcwInstall = () => { ccwMcpQuery.refetch(); }; // Build conflict map for quick lookup const conflictMap = useMemo(() => { const map = new Map(); for (const c of conflicts) map.set(c.name, c); return map; }, [conflicts]); // CCW scope-specific handlers const handleCcwInstallToScope = async (scope: 'global' | 'project') => { try { await installCcwMcp(scope, scope === 'project' ? projectPath ?? undefined : undefined); ccwMcpQuery.refetch(); } catch (error) { console.error('Failed to install CCW MCP to scope:', error); } }; const handleCcwUninstallFromScope = async (scope: 'global' | 'project') => { try { await uninstallCcwMcpFromScope(scope, scope === 'project' ? projectPath ?? undefined : undefined); ccwMcpQuery.refetch(); queryClient.invalidateQueries({ queryKey: ['mcpServers'] }); } catch (error) { console.error('Failed to uninstall CCW MCP from scope:', error); } }; // CCW MCP handlers for Codex mode const ccwCodexConfig = ccwMcpCodexQuery.data ?? { isInstalled: false, enabledTools: [] as string[], projectRoot: undefined, allowedDirs: undefined, enableSandbox: undefined, installedScopes: [] as ('global' | 'project')[], }; const handleToggleCcwToolCodex = async (tool: string, enabled: boolean) => { const currentConfig = queryClient.getQueryData(['ccwMcpConfigCodex']) ?? ccwCodexConfig; const currentTools = currentConfig.enabledTools; const previousConfig = queryClient.getQueryData(['ccwMcpConfigCodex']); const updatedTools = enabled ? (currentTools.includes(tool) ? currentTools : [...currentTools, tool]) : currentTools.filter((t) => t !== tool); queryClient.setQueryData(['ccwMcpConfigCodex'], (old: CcwMcpConfig | undefined) => { if (!old) return old; return { ...old, enabledTools: updatedTools }; }); try { await updateCcwConfigForCodex({ ...currentConfig, enabledTools: updatedTools }); } catch (error) { console.error('Failed to toggle CCW tool (Codex):', error); queryClient.setQueryData(['ccwMcpConfigCodex'], previousConfig); } ccwMcpCodexQuery.refetch(); }; const handleUpdateCcwConfigCodex = async (config: Partial) => { const currentConfig = queryClient.getQueryData(['ccwMcpConfigCodex']) ?? ccwCodexConfig; const previousConfig = queryClient.getQueryData(['ccwMcpConfigCodex']); queryClient.setQueryData(['ccwMcpConfigCodex'], (old: CcwMcpConfig | undefined) => { if (!old) return old; return { ...old, ...config }; }); try { await updateCcwConfigForCodex({ ...currentConfig, ...config }); } catch (error) { console.error('Failed to update CCW config (Codex):', error); queryClient.setQueryData(['ccwMcpConfigCodex'], previousConfig); } ccwMcpCodexQuery.refetch(); }; const handleCcwInstallCodex = () => { ccwMcpCodexQuery.refetch(); }; // Template handlers const handleInstallTemplate = (template: any) => { setEditingServer({ name: template.name, command: template.serverConfig.command, args: template.serverConfig.args || [], env: template.serverConfig.env, scope: 'project', enabled: true, }); setDialogOpen(true); }; const handleSaveServerAsTemplate = (server: McpServer) => { setServerToSaveAsTemplate(server); setSaveTemplateDialogOpen(true); }; const handleSaveAsTemplate = async ( name: string, category: string, description: string, serverConfig: { command: string; args: string[]; env: Record }, ) => { try { const result = await saveMcpTemplate({ name, description: description || undefined, category: category || 'custom', serverConfig: { command: serverConfig.command, args: serverConfig.args.length > 0 ? serverConfig.args : undefined, env: Object.keys(serverConfig.env).length > 0 ? serverConfig.env : undefined, }, }); if (result.success) { notifications.success( formatMessage({ id: 'mcp.templates.feedback.saveSuccess' }), name ); setSaveTemplateDialogOpen(false); setServerToSaveAsTemplate(undefined); } else { notifications.error( formatMessage({ id: 'mcp.templates.feedback.saveError' }), result.error || '' ); } } catch (error) { notifications.error( formatMessage({ id: 'mcp.templates.feedback.saveError' }), error instanceof Error ? error.message : String(error) ); } }; // Codex MCP handlers const handleCodexRemove = async (serverName: string) => { try { await codexRemoveServer(serverName); notifications.success( formatMessage({ id: 'mcp.actions.delete' }), serverName ); codexQuery.refetch(); } catch (error) { console.error('Failed to remove Codex MCP server:', error); notifications.error( formatMessage({ id: 'mcp.actions.delete' }), error instanceof Error ? error.message : String(error) ); } }; const handleCodexToggle = async (serverName: string, enabled: boolean) => { try { await codexToggleServer(serverName, enabled); codexQuery.refetch(); } catch (error) { console.error('Failed to toggle Codex MCP server:', error); } }; // Filter servers by search query const filteredServers = servers.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()) || s.command.toLowerCase().includes(searchQuery.toLowerCase()) ); // Filter Codex servers by search query const codexServers = codexQuery.data?.servers ?? []; const codexConfigPath = codexQuery.data?.configPath ?? ''; const filteredCodexServers = codexServers.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()) || s.command.toLowerCase().includes(searchQuery.toLowerCase()) ); const currentServers = cliMode === 'codex' ? filteredCodexServers : filteredServers; const currentExpanded = cliMode === 'codex' ? codexExpandedServers : expandedServers; const currentToggleExpand = cliMode === 'codex' ? toggleCodexExpand : toggleExpand; const currentIsLoading = cliMode === 'codex' ? codexQuery.isLoading : isLoading; const currentIsFetching = cliMode === 'codex' ? codexQuery.isFetching : isFetching; const currentRefetch = cliMode === 'codex' ? (() => codexQuery.refetch()) : refetch; return (
{/* Page Header */}

{formatMessage({ id: 'mcp.title' })}

{formatMessage({ id: 'mcp.description' })}

{/* CLI Mode Badge Switcher */}
{cliMode === 'claude' && ( )}
{/* Tabbed Interface */} setActiveTab(value as 'templates' | 'servers' | 'cross-cli')} tabs={[ { value: 'templates', label: formatMessage({ id: 'mcp.tabs.templates' }) }, { value: 'servers', label: formatMessage({ id: 'mcp.tabs.servers' }) }, { value: 'cross-cli', label: formatMessage({ id: 'mcp.tabs.crossCli' }) }, ]} /> {/* Tab Content: Templates */} {activeTab === 'templates' && (
{/* Recommended MCP Servers */} refetch()} /> {/* Templates Section */}
)} {/* Tab Content: Servers */} {activeTab === 'servers' && (
{/* Windows Compatibility Warning */} {/* Stats Cards - Claude mode only */} {cliMode === 'claude' && (
{totalCount}

{formatMessage({ id: 'mcp.stats.total' })}

{enabledCount}

{formatMessage({ id: 'mcp.stats.enabled' })}

{globalServers.length}

{formatMessage({ id: 'mcp.stats.global' })}

{projectServers.length}

{formatMessage({ id: 'mcp.stats.project' })}

)} {/* Filters and Search - Claude mode only */} {cliMode === 'claude' && (
setSearchQuery(e.target.value)} className="pl-9" />
)} {/* Codex mode search only */} {cliMode === 'codex' && (
setSearchQuery(e.target.value)} className="pl-9" />
)} {/* CCW Tools MCP Card */} {cliMode === 'claude' && ( )} {cliMode === 'codex' && ( )} {/* Servers List */} {currentIsLoading ? (
{[1, 2, 3, 4].map((i) => (
))}
) : currentServers.length === 0 ? (

{formatMessage({ id: 'mcp.emptyState.title' })}

{formatMessage({ id: 'mcp.emptyState.message' })}

) : (
{currentServers.map((server) => ( cliMode === 'codex' ? ( currentToggleExpand(server.name)} isEditable={true} onRemove={handleCodexRemove} onToggle={handleCodexToggle} /> ) : ( currentToggleExpand(`${server.name}-${server.scope}`)} onToggle={handleToggle} onEdit={handleEdit} onDelete={handleDelete} onSaveAsTemplate={handleSaveServerAsTemplate} conflictInfo={conflictMap.get(server.name)} /> ) ))}
)}
)} {/* Tab Content: Cross-CLI */} {activeTab === 'cross-cli' && (
{/* Section 1: Claude ↔ Codex 同步 */}

{formatMessage({ id: 'mcp.sync.title' })}

refetch()} />
{/* Section 2: 项目概览 */}

{formatMessage({ id: 'mcp.projects.title' })}

{formatMessage({ id: 'mcp.projects.description' })}

console.log('Open project:', path)} onOpenNewWindow={(path) => window.open(`/?project=${encodeURIComponent(path)}`, '_blank')} />
{/* Section 3: 跨项目导入 */}

{formatMessage({ id: 'mcp.crossProject.title' })}

{formatMessage({ id: 'mcp.crossProject.description' })}

{ console.log('Imported server:', serverName, 'from:', sourceProject); refetch(); }} />
)} {/* Add/Edit Dialog - Claude mode only (shared across tabs) */} {cliMode === 'claude' && ( )} {/* Save as Template Dialog */} { setSaveTemplateDialogOpen(false); setServerToSaveAsTemplate(undefined); }} onSave={handleSaveAsTemplate} defaultName={serverToSaveAsTemplate?.name} defaultCommand={serverToSaveAsTemplate?.command} defaultArgs={serverToSaveAsTemplate?.args} defaultEnv={serverToSaveAsTemplate?.env as Record} />
); } export default McpManagerPage;