/** * CustomMcpServerDialog — add a custom MCP server by providing install spec directly. * Supports stdio (npm package) and HTTP/SSE transports. */ import { useEffect, useRef, 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 { getDefaultMcpSharedScope, getMcpScopeLabel, isProjectScopedMcpScope, } from '@shared/utils/mcpScopes'; 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' | 'global'; 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); const cliStatus = useStore((s) => s.cliStatus); const defaultSharedScope = getDefaultMcpSharedScope(cliStatus?.flavor); const scopeOptions: { value: Scope; label: string }[] = [ { value: defaultSharedScope, label: getMcpScopeLabel(defaultSharedScope, cliStatus?.flavor) }, { value: 'project', label: 'Project' }, { value: 'local', label: 'Local' }, ]; // Form state const [serverName, setServerName] = useState(''); const [transportMode, setTransportMode] = useState('stdio'); const [scope, setScope] = useState(defaultSharedScope); // 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); const autoFilledValuesRef = useRef>({}); const envVarLookupNames = envVars .map((entry) => entry.key.trim()) .filter(Boolean) .sort() .join('\0'); const apiKeyLookupProjectPath = isProjectScopedMcpScope(scope) ? (projectPath ?? undefined) : undefined; // Reset on open useEffect(() => { if (open) { setServerName(''); setTransportMode('stdio'); setScope(defaultSharedScope); setNpmPackage(''); setNpmVersion(''); setHttpUrl(''); setHttpTransport('streamable-http'); setHeaders([]); setEnvVars([]); setError(null); setInstalling(false); autoFilledValuesRef.current = {}; } }, [defaultSharedScope, open]); useEffect(() => { if (open && isProjectScopedMcpScope(scope) && !projectPath) { setScope(defaultSharedScope); } }, [defaultSharedScope, 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.trim()).filter(Boolean); if (envVarNames.length === 0) return; void api.apiKeys.lookup(envVarNames, apiKeyLookupProjectPath).then( (results) => { const previousAutoFilledValues = autoFilledValuesRef.current; const nextAutoFilledValues = Object.fromEntries( results.map((result) => [result.envVarName, result.value]) ); setEnvVars((prev) => { let changed = false; const next = prev.map((entry) => { const envVarName = entry.key.trim(); if (!envVarName) { return entry; } const previousValue = previousAutoFilledValues[envVarName]; const nextValue = nextAutoFilledValues[envVarName]; if (!nextValue) { if (previousValue && entry.value === previousValue) { changed = true; return { ...entry, value: '' }; } return entry; } if (!entry.value || entry.value === previousValue) { if (entry.value !== nextValue) { changed = true; return { ...entry, value: nextValue }; } } return entry; }); return changed ? next : prev; }); autoFilledValuesRef.current = nextAutoFilledValues; }, () => { // Silently fail } ); }, [apiKeyLookupProjectPath, envVarLookupNames, open]); // 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: isProjectScopedMcpScope(scope) ? (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()) && !(isProjectScopedMcpScope(scope) && !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 */}
); };