agent-ecosystem/src/renderer/components/extensions/mcp/CustomMcpServerDialog.tsx

505 lines
17 KiB
TypeScript

/**
* 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<TransportMode>('stdio');
const [scope, setScope] = useState<Scope>(defaultSharedScope);
// Stdio fields
const [npmPackage, setNpmPackage] = useState('');
const [npmVersion, setNpmVersion] = useState('');
// HTTP fields
const [httpUrl, setHttpUrl] = useState('');
const [httpTransport, setHttpTransport] = useState<HttpTransport>('streamable-http');
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
// Shared
const [envVars, setEnvVars] = useState<EnvEntry[]>([]);
const [error, setError] = useState<string | null>(null);
const [installing, setInstalling] = useState(false);
const autoFilledValuesRef = useRef<Record<string, string>>({});
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<string, string> = {};
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 (
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
<DialogContent className="max-h-[85vh] max-w-lg overflow-y-auto">
<DialogHeader>
<div className="flex items-center gap-2">
<div className="flex size-8 items-center justify-center rounded-lg border border-border bg-surface-raised">
<Server className="size-4 text-text-muted" />
</div>
<div>
<DialogTitle>Add Custom MCP Server</DialogTitle>
<DialogDescription>Add a server manually without the catalog.</DialogDescription>
</div>
</div>
</DialogHeader>
<div className="space-y-4">
{/* Server name */}
<div className="space-y-1.5">
<Label htmlFor="custom-name" className="text-xs">
Server Name
</Label>
<Input
id="custom-name"
value={serverName}
onChange={(e) => setServerName(e.target.value)}
placeholder="my-server"
className="h-8 text-sm"
autoFocus
/>
</div>
{/* Transport toggle */}
<div className="space-y-1.5">
<Label className="text-xs">Transport</Label>
<div className="flex gap-2">
<Button
type="button"
variant={transportMode === 'stdio' ? 'default' : 'outline'}
size="sm"
onClick={() => setTransportMode('stdio')}
>
Stdio (npm)
</Button>
<Button
type="button"
variant={transportMode === 'http' ? 'default' : 'outline'}
size="sm"
onClick={() => setTransportMode('http')}
>
HTTP / SSE
</Button>
</div>
</div>
{/* Stdio fields */}
{transportMode === 'stdio' && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-npm" className="text-xs">
npm Package
</Label>
<Input
id="custom-npm"
value={npmPackage}
onChange={(e) => setNpmPackage(e.target.value)}
placeholder="@example/mcp-server"
className="h-8 font-mono text-sm"
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="custom-version" className="text-xs">
Version (optional)
</Label>
<Input
id="custom-version"
value={npmVersion}
onChange={(e) => setNpmVersion(e.target.value)}
placeholder="latest"
className="h-8 text-sm"
/>
</div>
</div>
)}
{/* HTTP fields */}
{transportMode === 'http' && (
<div className="space-y-3">
<div className="space-y-1.5">
<Label htmlFor="custom-url" className="text-xs">
Server URL
</Label>
<Input
id="custom-url"
value={httpUrl}
onChange={(e) => setHttpUrl(e.target.value)}
placeholder="https://api.example.com/mcp"
className="h-8 text-sm"
/>
</div>
<div className="space-y-1.5">
<Label className="text-xs">Transport Type</Label>
<Select
value={httpTransport}
onValueChange={(v) => setHttpTransport(v as HttpTransport)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{HTTP_TRANSPORT_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Headers */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Headers</Label>
<Button
variant="ghost"
size="sm"
onClick={addHeader}
className="h-6 px-1.5 text-xs"
>
<Plus className="mr-1 size-3" />
Add
</Button>
</div>
{headers.length > 0 && (
<div className="space-y-2">
{headers.map((header, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={header.key}
onChange={(e) => updateHeader(i, 'key', e.target.value)}
className="h-7 w-32 text-xs"
placeholder="Header-Name"
/>
<Input
value={header.value}
onChange={(e) => updateHeader(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
placeholder="value"
/>
<Button
variant="ghost"
size="icon"
className="size-7 text-red-400 hover:bg-red-500/10"
onClick={() => removeHeader(i)}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
</div>
</div>
)}
{/* Scope */}
<div className="space-y-1.5">
<Label className="text-xs">Scope</Label>
<Select value={scope} onValueChange={(v) => setScope(v as Scope)}>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{scopeOptions.map((opt) => (
<SelectItem
key={opt.value}
value={opt.value}
disabled={isProjectScopedMcpScope(opt.value) && !projectPath}
>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* Environment variables */}
<div className="space-y-1.5">
<div className="flex items-center justify-between">
<Label className="text-xs">Environment Variables</Label>
<Button variant="ghost" size="sm" onClick={addEnvVar} className="h-6 px-1.5 text-xs">
<Plus className="mr-1 size-3" />
Add
</Button>
</div>
{envVars.length > 0 && (
<div className="space-y-2">
{envVars.map((entry, i) => (
<div key={i} className="flex items-center gap-2">
<Input
value={entry.key}
onChange={(e) => updateEnvVar(i, 'key', e.target.value)}
className="h-7 w-40 font-mono text-xs"
placeholder="ENV_VAR_NAME"
/>
<Input
type="password"
value={entry.value}
onChange={(e) => updateEnvVar(i, 'value', e.target.value)}
className="h-7 flex-1 text-xs"
placeholder="value"
/>
<Button
variant="ghost"
size="icon"
className="size-7 text-red-400 hover:bg-red-500/10"
onClick={() => removeEnvVar(i)}
>
<Trash2 className="size-3" />
</Button>
</div>
))}
</div>
)}
</div>
{/* Error */}
{error && (
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
{error}
</div>
)}
{/* Actions */}
<div className="flex justify-end gap-2 pt-2">
<Button variant="ghost" size="sm" onClick={onClose}>
Cancel
</Button>
<Button size="sm" disabled={!canSubmit} onClick={() => void handleInstall()}>
{installing ? 'Installing...' : 'Install'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
};