/** * CustomMcpServerDialog — add a custom MCP server by providing install spec directly. * Supports stdio (npm package) and HTTP/SSE transports. */ import { useEffect, useState } from 'react'; import { api } from '@renderer/api'; import { Button } from '@renderer/components/ui/button'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, } from '@renderer/components/ui/dialog'; import { Input } from '@renderer/components/ui/input'; import { Label } from '@renderer/components/ui/label'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@renderer/components/ui/select'; import { useStore } from '@renderer/store'; import { Plus, Server, Trash2 } from 'lucide-react'; import type { McpCustomInstallRequest, McpHeaderDef, McpInstallSpec, } from '@shared/types/extensions'; const SERVER_NAME_RE = /^[\w.-]{1,100}$/; interface CustomMcpServerDialogProps { open: boolean; onClose: () => void; projectPath: string | null; } type TransportMode = 'stdio' | 'http'; type HttpTransport = 'streamable-http' | 'sse' | 'http'; type Scope = 'local' | 'user' | 'project'; const SCOPE_OPTIONS: { value: Scope; label: string }[] = [ { value: 'user', label: 'User (global)' }, { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; const HTTP_TRANSPORT_OPTIONS: { value: HttpTransport; label: string }[] = [ { value: 'streamable-http', label: 'Streamable HTTP' }, { value: 'sse', label: 'SSE' }, { value: 'http', label: 'HTTP' }, ]; interface EnvEntry { key: string; value: string; } export const CustomMcpServerDialog = ({ open, onClose, projectPath, }: CustomMcpServerDialogProps): React.JSX.Element => { const installCustomMcpServer = useStore((s) => s.installCustomMcpServer); // Form state const [serverName, setServerName] = useState(''); const [transportMode, setTransportMode] = useState('stdio'); const [scope, setScope] = useState('user'); // Stdio fields const [npmPackage, setNpmPackage] = useState(''); const [npmVersion, setNpmVersion] = useState(''); // HTTP fields const [httpUrl, setHttpUrl] = useState(''); const [httpTransport, setHttpTransport] = useState('streamable-http'); const [headers, setHeaders] = useState([]); // Shared const [envVars, setEnvVars] = useState([]); const [error, setError] = useState(null); const [installing, setInstalling] = useState(false); // Reset on open useEffect(() => { if (open) { setServerName(''); setTransportMode('stdio'); setScope('user'); setNpmPackage(''); setNpmVersion(''); setHttpUrl(''); setHttpTransport('streamable-http'); setHeaders([]); setEnvVars([]); setError(null); setInstalling(false); } }, [open]); useEffect(() => { if (open && scope !== 'user' && !projectPath) { setScope('user'); } }, [open, projectPath, scope]); // Auto-fill env vars from saved API keys useEffect(() => { if (!open || envVars.length === 0 || !api.apiKeys) return; const envVarNames = envVars.map((e) => e.key).filter(Boolean); if (envVarNames.length === 0) return; void api.apiKeys.lookup(envVarNames).then( (results) => { if (results.length === 0) return; const lookup = new Map(results.map((r) => [r.envVarName, r.value])); setEnvVars((prev) => prev.map((e) => (lookup.has(e.key) && !e.value ? { ...e, value: lookup.get(e.key)! } : e)) ); }, () => { // Silently fail } ); }, [open, envVars.length]); // eslint-disable-line react-hooks/exhaustive-deps const handleInstall = async () => { setError(null); if (!serverName.trim()) { setError('Server name is required'); return; } if (!SERVER_NAME_RE.test(serverName)) { setError('Invalid server name. Use alphanumeric characters, dashes, underscores, dots.'); return; } let installSpec: McpInstallSpec; if (transportMode === 'stdio') { if (!npmPackage.trim()) { setError('npm package name is required'); return; } installSpec = { type: 'stdio', npmPackage: npmPackage.trim(), npmVersion: npmVersion.trim() || undefined, }; } else { if (!httpUrl.trim()) { setError('Server URL is required'); return; } installSpec = { type: 'http', url: httpUrl.trim(), transportType: httpTransport, }; } const envValues: Record = {}; for (const entry of envVars) { if (entry.key.trim() && entry.value) { envValues[entry.key.trim()] = entry.value; } } const request: McpCustomInstallRequest = { serverName, scope, projectPath: scope !== 'user' ? (projectPath ?? undefined) : undefined, installSpec, envValues, headers: headers.filter((h) => h.key.trim() && h.value.trim()), }; setInstalling(true); try { await installCustomMcpServer(request); onClose(); } catch (err) { setError(err instanceof Error ? err.message : 'Install failed'); } finally { setInstalling(false); } }; const addEnvVar = () => setEnvVars((prev) => [...prev, { key: '', value: '' }]); const removeEnvVar = (i: number) => setEnvVars((prev) => prev.filter((_, idx) => idx !== i)); const updateEnvVar = (i: number, field: 'key' | 'value', val: string) => setEnvVars((prev) => prev.map((e, idx) => (idx === i ? { ...e, [field]: val } : e))); const addHeader = () => setHeaders((prev) => [...prev, { key: '', value: '' }]); const removeHeader = (i: number) => setHeaders((prev) => prev.filter((_, idx) => idx !== i)); const updateHeader = (i: number, field: 'key' | 'value', val: string) => setHeaders((prev) => prev.map((h, idx) => (idx === i ? { ...h, [field]: val } : h))); const canSubmit = serverName.trim() && (transportMode === 'stdio' ? npmPackage.trim() : httpUrl.trim()) && !(scope !== 'user' && !projectPath) && !installing; return ( !o && onClose()}>
Add Custom MCP Server Add a server manually without the catalog.
{/* Server name */}
setServerName(e.target.value)} placeholder="my-server" className="h-8 text-sm" autoFocus />
{/* Transport toggle */}
{/* Stdio fields */} {transportMode === 'stdio' && (
setNpmPackage(e.target.value)} placeholder="@example/mcp-server" className="h-8 font-mono text-sm" />
setNpmVersion(e.target.value)} placeholder="latest" className="h-8 text-sm" />
)} {/* HTTP fields */} {transportMode === 'http' && (
setHttpUrl(e.target.value)} placeholder="https://api.example.com/mcp" className="h-8 text-sm" />
{/* Headers */}
{headers.length > 0 && (
{headers.map((header, i) => (
updateHeader(i, 'key', e.target.value)} className="h-7 w-32 text-xs" placeholder="Header-Name" /> updateHeader(i, 'value', e.target.value)} className="h-7 flex-1 text-xs" placeholder="value" />
))}
)}
)} {/* Scope */}
{/* Environment variables */}
{envVars.length > 0 && (
{envVars.map((entry, i) => (
updateEnvVar(i, 'key', e.target.value)} className="h-7 w-40 font-mono text-xs" placeholder="ENV_VAR_NAME" /> updateEnvVar(i, 'value', e.target.value)} className="h-7 flex-1 text-xs" placeholder="value" />
))}
)}
{/* Error */} {error && (
{error}
)} {/* Actions */}
); };