// ======================================== // MCP Server Dialog Component // ======================================== // Add/Edit dialog for MCP server configuration with dynamic template loading // Supports both STDIO and HTTP transport types import { useState, useEffect, useCallback } from 'react'; import { useIntl } from 'react-intl'; import { useMutation, useQueryClient } from '@tanstack/react-query'; import { Eye, EyeOff, Plus, Trash2, Globe, Terminal } from 'lucide-react'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/Dialog'; import { Input } from '@/components/ui/Input'; import { Button } from '@/components/ui/Button'; import { Badge } from '@/components/ui/Badge'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/Select'; import { createMcpServer, updateMcpServer, fetchMcpServers, saveMcpTemplate, type McpServer, type McpProjectConfigType, isStdioMcpServer, isHttpMcpServer, } from '@/lib/api'; import { mcpServersKeys, useMcpTemplates, useNotifications } from '@/hooks'; import { cn } from '@/lib/utils'; import { ConfigTypeToggle, type McpConfigType } from './ConfigTypeToggle'; import { useWorkflowStore, selectProjectPath } from '@/stores/workflowStore'; // ========== Types ========== export type McpTransportType = 'stdio' | 'http'; export interface HttpHeader { id: string; name: string; value: string; isEnvVar: boolean; } export interface McpServerDialogProps { mode: 'add' | 'edit'; server?: McpServer; open: boolean; onClose: () => void; onSave?: () => void; } // Re-export McpTemplate for convenience export type { McpTemplate } from '@/types/store'; interface McpServerFormData { name: string; // STDIO fields command: string; args: string[]; env: Record; // HTTP fields url: string; headers: HttpHeader[]; bearerTokenEnvVar: string; // Common fields scope: 'project' | 'global'; enabled: boolean; } interface FormErrors { name?: string; command?: string; args?: string; env?: string; url?: string; headers?: string; } // ========== Helper Component: HttpHeadersInput ========== interface HttpHeadersInputProps { headers: HttpHeader[]; onChange: (headers: HttpHeader[]) => void; disabled?: boolean; } function HttpHeadersInput({ headers, onChange, disabled }: HttpHeadersInputProps) { const { formatMessage } = useIntl(); const [revealedIds, setRevealedIds] = useState>(new Set()); const generateId = () => `header-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`; const handleAdd = () => { onChange([...headers, { id: generateId(), name: '', value: '', isEnvVar: false }]); }; const handleRemove = (id: string) => { onChange(headers.filter((h) => h.id !== id)); }; const handleUpdate = (id: string, field: keyof HttpHeader, value: string | boolean) => { onChange(headers.map((h) => (h.id === id ? { ...h, [field]: value } : h))); }; const toggleReveal = (id: string) => { setRevealedIds((prev) => { const next = new Set(prev); if (next.has(id)) { next.delete(id); } else { next.add(id); } return next; }); }; return (
{headers.map((header) => (
{/* Header Name */} handleUpdate(header.id, 'name', e.target.value)} placeholder={formatMessage({ id: 'mcp.dialog.form.http.headerName' })} className="flex-1" disabled={disabled} /> {/* Header Value */}
handleUpdate(header.id, 'value', e.target.value)} placeholder={formatMessage({ id: 'mcp.dialog.form.http.headerValue' })} className="pr-8" disabled={disabled} />
{/* Env Var Toggle */} {/* Delete Button */}
))}
); } // ========== Main Component ========== export function McpServerDialog({ mode, server, open, onClose, onSave, }: McpServerDialogProps) { const { formatMessage } = useIntl(); const queryClient = useQueryClient(); const projectPath = useWorkflowStore(selectProjectPath); const { error: showError, success: showSuccess } = useNotifications(); // Fetch templates from backend const { templates, isLoading: templatesLoading } = useMcpTemplates(); // Transport type state const [transportType, setTransportType] = useState('stdio'); // Form state const [formData, setFormData] = useState({ name: '', command: '', args: [], env: {}, url: '', headers: [], bearerTokenEnvVar: '', scope: 'project', enabled: true, }); const [selectedTemplate, setSelectedTemplate] = useState(''); const [errors, setErrors] = useState({}); const [argsInput, setArgsInput] = useState(''); const [envInput, setEnvInput] = useState(''); const [configType, setConfigType] = useState('mcp-json'); const [saveAsTemplate, setSaveAsTemplate] = useState(false); const projectConfigType: McpProjectConfigType = configType === 'claude-json' ? 'claude' : 'mcp'; // JSON import mode state const [inputMode, setInputMode] = useState<'form' | 'json'>('form'); const [jsonInput, setJsonInput] = useState(''); // Helper to detect transport type from server data const detectTransportType = useCallback((serverData: McpServer | undefined): McpTransportType => { if (!serverData) return 'stdio'; // Use type guard to check for HTTP server if (isHttpMcpServer(serverData)) return 'http'; return 'stdio'; }, []); // Parse JSON config and populate form const parseJsonConfig = useCallback(() => { try { let config = JSON.parse(jsonInput); let extractedServerName = ''; // Auto-detect mcpServers wrapper format (Claude Code config format) // Supports both: { "mcpServers": { "name": {...} } } and direct { "command": ... } if (config.mcpServers && typeof config.mcpServers === 'object' && !Array.isArray(config.mcpServers)) { const serverNames = Object.keys(config.mcpServers); if (serverNames.length > 0) { extractedServerName = serverNames[0]; const serverConfig = config.mcpServers[extractedServerName]; if (serverConfig && typeof serverConfig === 'object') { config = serverConfig; } } } // Detect transport type based on config structure if (config.url) { // HTTP transport setTransportType('http'); // Parse headers const headers: HttpHeader[] = []; if (config.headers && typeof config.headers === 'object') { Object.entries(config.headers).forEach(([name, value], idx) => { headers.push({ id: `header-${Date.now()}-${idx}`, name, value: String(value), isEnvVar: false, }); }); } setFormData(prev => ({ ...prev, name: extractedServerName || prev.name, url: config.url || '', headers, bearerTokenEnvVar: config.bearer_token_env_var || config.bearerTokenEnvVar || '', })); } else { // STDIO transport setTransportType('stdio'); const args = Array.isArray(config.args) ? config.args : []; const env = config.env && typeof config.env === 'object' ? config.env : {}; setFormData(prev => ({ ...prev, name: extractedServerName || prev.name, command: config.command || '', args, env, })); setArgsInput(args.join(', ')); setEnvInput( Object.entries(env) .map(([k, v]) => `${k}=${v}`) .join('\n') ); } // Switch to form mode to show parsed data setInputMode('form'); setErrors({}); showSuccess( formatMessage({ id: 'mcp.dialog.json.parseSuccess' }), formatMessage({ id: 'mcp.dialog.json.parseSuccessDesc' }) ); } catch (error) { setErrors({ name: formatMessage({ id: 'mcp.dialog.json.parseError' }, { error: error instanceof Error ? error.message : 'Invalid JSON' }) }); } }, [jsonInput, formatMessage, showSuccess]); // Initialize form from server prop (edit mode) useEffect(() => { if (server && mode === 'edit') { const detectedType = detectTransportType(server); setTransportType(detectedType); // Parse HTTP headers if present (for HTTP servers) const httpHeaders: HttpHeader[] = []; if (isHttpMcpServer(server)) { // HTTP server - extract headers if (server.httpHeaders) { Object.entries(server.httpHeaders).forEach(([name, value], idx) => { httpHeaders.push({ id: `header-http-${idx}`, name, value, isEnvVar: false, }); }); } if (server.envHttpHeaders) { // envHttpHeaders is an array of header names that get values from env vars server.envHttpHeaders.forEach((headerName, idx) => { httpHeaders.push({ id: `header-env-${idx}`, name: headerName, value: '', // Env var name is not stored in value isEnvVar: true, }); }); } } // Get STDIO fields safely using type guard const stdioCommand = isStdioMcpServer(server) ? server.command : ''; const stdioArgs = isStdioMcpServer(server) ? (server.args || []) : []; const stdioEnv = isStdioMcpServer(server) ? (server.env || {}) : {}; // Get HTTP fields safely using type guard const httpUrl = isHttpMcpServer(server) ? server.url : ''; const httpBearerToken = isHttpMcpServer(server) ? (server.bearerTokenEnvVar || '') : ''; setFormData({ name: server.name, command: stdioCommand, args: stdioArgs, env: stdioEnv, url: httpUrl, headers: httpHeaders, bearerTokenEnvVar: httpBearerToken, scope: server.scope, enabled: server.enabled, }); setArgsInput(stdioArgs.join(', ')); setEnvInput( Object.entries(stdioEnv) .map(([k, v]) => `${k}=${v}`) .join('\n') ); } else { // Reset form for add mode setTransportType('stdio'); setFormData({ name: '', command: '', args: [], env: {}, url: '', headers: [], bearerTokenEnvVar: '', scope: 'project', enabled: true, }); setArgsInput(''); setEnvInput(''); } setSelectedTemplate(''); setSaveAsTemplate(false); setErrors({}); }, [server, mode, open, detectTransportType]); // Mutations const createMutation = useMutation({ mutationFn: ({ server, configType }: { server: McpServer; configType?: McpProjectConfigType }) => createMcpServer(server, { projectPath: projectPath ?? undefined, configType }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: mcpServersKeys.all }); handleClose(); onSave?.(); }, }); const updateMutation = useMutation({ mutationFn: ({ serverName, config, configType }: { serverName: string; config: Partial; configType?: McpProjectConfigType }) => updateMcpServer(serverName, config, { projectPath: projectPath ?? undefined, configType }), onSuccess: () => { queryClient.invalidateQueries({ queryKey: mcpServersKeys.all }); handleClose(); onSave?.(); }, }); // Handlers const handleClose = () => { setErrors({}); onClose(); }; const handleTemplateSelect = (templateId: string) => { const template = templates.find((t) => t.name === templateId); if (template) { setFormData((prev) => ({ ...prev, command: template.serverConfig.command, args: template.serverConfig.args || [], env: template.serverConfig.env || {}, })); setArgsInput((template.serverConfig.args || []).join(', ')); setEnvInput( Object.entries(template.serverConfig.env || {}) .map(([k, v]) => `${k}=${v}`) .join('\n') ); setSelectedTemplate(templateId); } }; const handleFieldChange = ( field: keyof McpServerFormData, value: string | boolean | string[] | Record | HttpHeader[] ) => { setFormData((prev) => ({ ...prev, [field]: value })); // Clear error for this field if (errors[field as keyof FormErrors]) { setErrors((prev) => ({ ...prev, [field]: undefined })); } }; const handleArgsChange = (value: string) => { setArgsInput(value); const argsArray = value .split(',') .map((a) => a.trim()) .filter((a) => a.length > 0); setFormData((prev) => ({ ...prev, args: argsArray })); if (errors.args) { setErrors((prev) => ({ ...prev, args: undefined })); } }; const handleEnvChange = (value: string) => { setEnvInput(value); const envObj: Record = {}; const lines = value.split('\n'); for (const line of lines) { const trimmed = line.trim(); if (trimmed && trimmed.includes('=')) { const [key, ...valParts] = trimmed.split('='); const val = valParts.join('='); if (key) { envObj[key.trim()] = val.trim(); } } } setFormData((prev) => ({ ...prev, env: envObj })); if (errors.env) { setErrors((prev) => ({ ...prev, env: undefined })); } }; const handleHeadersChange = (headers: HttpHeader[]) => { setFormData((prev) => ({ ...prev, headers })); if (errors.headers) { setErrors((prev) => ({ ...prev, headers: undefined })); } }; const handleTransportTypeChange = (type: McpTransportType) => { setTransportType(type); // Clear relevant errors when switching setErrors((prev) => ({ ...prev, command: undefined, url: undefined, })); }; const validateForm = (): boolean => { const newErrors: FormErrors = {}; // Name required if (!formData.name.trim()) { newErrors.name = formatMessage({ id: 'mcp.dialog.validation.nameRequired' }); } // Transport-specific validation if (transportType === 'stdio') { // Command required for STDIO if (!formData.command.trim()) { newErrors.command = formatMessage({ id: 'mcp.dialog.validation.commandRequired' }); } } else { // URL required for HTTP if (!formData.url.trim()) { newErrors.url = formatMessage({ id: 'mcp.dialog.validation.urlRequired' }); } // Validate URL format try { new URL(formData.url); } catch { newErrors.url = formatMessage({ id: 'mcp.dialog.validation.urlInvalid' }); } } setErrors(newErrors); return Object.keys(newErrors).length === 0; }; const checkNameExists = async (name: string): Promise => { try { const data = await fetchMcpServers(projectPath ?? undefined); const allServers = [...data.project, ...data.global]; // In edit mode, exclude current server return allServers.some( (s) => s.name === name && (mode === 'edit' ? s.name !== server?.name : true) ); } catch { return false; } }; const handleSubmit = async () => { if (!validateForm()) { return; } // Check name uniqueness if (await checkNameExists(formData.name)) { setErrors({ name: formatMessage({ id: 'mcp.dialog.validation.nameExists' }) }); return; } // Build server config based on transport type using discriminated union let serverConfig: McpServer; if (transportType === 'stdio') { serverConfig = { name: formData.name, transport: 'stdio', command: formData.command, args: formData.args.length > 0 ? formData.args : undefined, env: Object.keys(formData.env).length > 0 ? formData.env : undefined, scope: formData.scope, enabled: formData.enabled, }; } else { // HTTP transport - separate headers into static and env-based const httpHeaders: Record = {}; const envHttpHeaders: string[] = []; formData.headers.forEach((h) => { if (h.name.trim()) { if (h.isEnvVar) { // For env-based headers, store the header name that will be populated from env var envHttpHeaders.push(h.name.trim()); } else { httpHeaders[h.name.trim()] = h.value.trim(); } } }); serverConfig = { name: formData.name, transport: 'http', url: formData.url, headers: Object.keys(httpHeaders).length > 0 ? httpHeaders : undefined, httpHeaders: Object.keys(httpHeaders).length > 0 ? httpHeaders : undefined, envHttpHeaders: envHttpHeaders.length > 0 ? envHttpHeaders : undefined, bearerTokenEnvVar: formData.bearerTokenEnvVar.trim() || undefined, scope: formData.scope, enabled: formData.enabled, }; } // Save as template if checked (only for STDIO) if (saveAsTemplate && transportType === 'stdio') { try { await saveMcpTemplate({ name: formData.name, category: 'custom', serverConfig: { command: formData.command, args: formData.args.length > 0 ? formData.args : undefined, env: Object.keys(formData.env).length > 0 ? formData.env : undefined, }, }); } catch (err) { showError(formatMessage({ id: 'mcp.templates.feedback.saveError' }), err instanceof Error ? err.message : String(err)); // Template save failure should not block server creation } } if (mode === 'add') { createMutation.mutate({ server: serverConfig, configType: formData.scope === 'project' ? projectConfigType : undefined, }); } else { updateMutation.mutate({ serverName: server!.name, config: serverConfig, configType: formData.scope === 'project' ? projectConfigType : undefined, }); } }; const isPending = createMutation.isPending || updateMutation.isPending; return ( {mode === 'add' ? formatMessage({ id: 'mcp.dialog.addTitle' }) : formatMessage({ id: 'mcp.dialog.editTitle' }, { name: server?.name })} {/* Input Mode Switcher - Only in add mode */} {mode === 'add' && (
)}
{/* JSON Input Mode */} {inputMode === 'json' ? (