583 lines
21 KiB
TypeScript
583 lines
21 KiB
TypeScript
/**
|
|
* McpServerDetailDialog — full detail view for a single MCP server with install controls.
|
|
* Uses Radix UI Kit for all form elements.
|
|
*/
|
|
|
|
import { useEffect, useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { Badge } from '@renderer/components/ui/badge';
|
|
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 { sanitizeMcpServerName } from '@shared/utils/extensionNormalizers';
|
|
import { ExternalLink, Lock, Plus, Star, Trash2, Wrench } from 'lucide-react';
|
|
|
|
import { InstallButton } from '../common/InstallButton';
|
|
import { SourceBadge } from '../common/SourceBadge';
|
|
|
|
import type {
|
|
InstalledMcpEntry,
|
|
McpCatalogItem,
|
|
McpHeaderDef,
|
|
McpServerDiagnostic,
|
|
} from '@shared/types/extensions';
|
|
|
|
interface McpServerDetailDialogProps {
|
|
server: McpCatalogItem | null;
|
|
isInstalled: boolean;
|
|
installedEntry?: InstalledMcpEntry | null;
|
|
diagnostic?: McpServerDiagnostic | null;
|
|
diagnosticsLoading?: boolean;
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
type Scope = 'local' | 'user';
|
|
|
|
const SCOPE_OPTIONS: { value: Scope; label: string }[] = [
|
|
{ value: 'user', label: 'User (global)' },
|
|
{ value: 'local', label: 'Local' },
|
|
];
|
|
|
|
export const McpServerDetailDialog = ({
|
|
server,
|
|
isInstalled,
|
|
installedEntry,
|
|
diagnostic,
|
|
diagnosticsLoading,
|
|
open,
|
|
onClose,
|
|
}: McpServerDetailDialogProps): React.JSX.Element => {
|
|
const installProgress = useStore(
|
|
(s) => (server ? s.mcpInstallProgress[server.id] : undefined) ?? 'idle'
|
|
);
|
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
|
const installError = useStore((s) => (server ? s.installErrors[server.id] : undefined));
|
|
const stars = useStore((s) =>
|
|
server?.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
|
);
|
|
|
|
const [scope, setScope] = useState<Scope>('user');
|
|
const [serverName, setServerName] = useState('');
|
|
const [envValues, setEnvValues] = useState<Record<string, string>>({});
|
|
const [headers, setHeaders] = useState<McpHeaderDef[]>([]);
|
|
const [imgError, setImgError] = useState(false);
|
|
const [autoFilledFields, setAutoFilledFields] = useState<Set<string>>(new Set());
|
|
|
|
// Initialize form when dialog opens or server changes
|
|
useEffect(() => {
|
|
if (!server || !open) {
|
|
return;
|
|
}
|
|
|
|
setEnvValues(Object.fromEntries(server.envVars.map((env) => [env.name, ''])));
|
|
setHeaders(
|
|
(server.authHeaders ?? []).map((header) => ({
|
|
key: header.key,
|
|
value: '',
|
|
secret: header.isSecret,
|
|
description: header.description,
|
|
isRequired: header.isRequired,
|
|
valueTemplate: header.valueTemplate,
|
|
locked: true,
|
|
}))
|
|
);
|
|
setServerName(installedEntry?.name ?? sanitizeMcpServerName(server.name));
|
|
setScope(installedEntry?.scope === 'local' ? 'local' : 'user');
|
|
setImgError(false);
|
|
setAutoFilledFields(new Set());
|
|
}, [installedEntry?.name, installedEntry?.scope, open, server?.id]);
|
|
|
|
// Auto-fill env values from saved API keys
|
|
useEffect(() => {
|
|
if (!server || !open || server.envVars.length === 0 || !api.apiKeys) return;
|
|
|
|
const envVarNames = server.envVars.map((e) => e.name);
|
|
void api.apiKeys.lookup(envVarNames).then(
|
|
(results) => {
|
|
if (results.length === 0) return;
|
|
const filled = new Set<string>();
|
|
const values: Record<string, string> = {};
|
|
for (const r of results) {
|
|
values[r.envVarName] = r.value;
|
|
filled.add(r.envVarName);
|
|
}
|
|
setEnvValues((prev) => ({ ...prev, ...values }));
|
|
setAutoFilledFields(filled);
|
|
},
|
|
() => {
|
|
// Silently fail — auto-fill is supplementary
|
|
}
|
|
);
|
|
}, [server?.id, open]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
// Auto-fill env vars from saved API keys
|
|
useEffect(() => {
|
|
if (!server || server.envVars.length === 0 || !api.apiKeys) return;
|
|
|
|
const envVarNames = server.envVars.map((env) => env.name);
|
|
void api.apiKeys.lookup(envVarNames).then(
|
|
(results) => {
|
|
if (results.length === 0) return;
|
|
const filled = new Set<string>();
|
|
const updates: Record<string, string> = {};
|
|
for (const r of results) {
|
|
updates[r.envVarName] = r.value;
|
|
filled.add(r.envVarName);
|
|
}
|
|
setEnvValues((prev) => {
|
|
const next = { ...prev };
|
|
for (const [k, v] of Object.entries(updates)) {
|
|
// Only auto-fill if the field is empty
|
|
if (!next[k]) {
|
|
next[k] = v;
|
|
}
|
|
}
|
|
return next;
|
|
});
|
|
setAutoFilledFields(filled);
|
|
},
|
|
() => {
|
|
// Silently ignore lookup failures
|
|
}
|
|
);
|
|
}, [server?.id]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
if (!server) return <></>;
|
|
|
|
const canAutoInstall = !!server.installSpec;
|
|
const isHttp = server.installSpec?.type === 'http';
|
|
const hasIcon = !!server.iconUrl && !imgError;
|
|
const npmPackageUrl =
|
|
server.installSpec?.type === 'stdio'
|
|
? `https://www.npmjs.com/package/${server.installSpec.npmPackage}`
|
|
: null;
|
|
const hasSuggestedHeaders = headers.some((header) => header.locked);
|
|
const missingRequiredEnvVars = server.envVars.some(
|
|
(env) => env.isRequired && !envValues[env.name]?.trim()
|
|
);
|
|
const missingRequiredHeaders = headers.some(
|
|
(header) => header.isRequired && !header.value.trim()
|
|
);
|
|
const installDisabled = !serverName.trim() || missingRequiredEnvVars || missingRequiredHeaders;
|
|
const uninstallServerName = installedEntry?.name ?? serverName;
|
|
const uninstallScope = installedEntry?.scope ?? scope;
|
|
const diagnosticBadgeClass =
|
|
diagnostic?.status === 'connected'
|
|
? 'border-emerald-500/30 bg-emerald-500/10 text-emerald-400'
|
|
: diagnostic?.status === 'needs-authentication'
|
|
? 'border-amber-500/30 bg-amber-500/10 text-amber-400'
|
|
: diagnostic?.status === 'failed'
|
|
? 'border-red-500/30 bg-red-500/10 text-red-400'
|
|
: 'border-border bg-surface-raised text-text-muted';
|
|
|
|
const handleInstall = () => {
|
|
installMcpServer({
|
|
registryId: server.id,
|
|
serverName,
|
|
scope,
|
|
envValues,
|
|
headers,
|
|
});
|
|
};
|
|
|
|
const handleUninstall = () => {
|
|
uninstallMcpServer(server.id, uninstallServerName, uninstallScope);
|
|
};
|
|
|
|
const addHeader = () => {
|
|
setHeaders((prev) => [...prev, { key: '', value: '' }]);
|
|
};
|
|
|
|
const removeHeader = (index: number) => {
|
|
setHeaders((prev) => prev.filter((_, i) => i !== index));
|
|
};
|
|
|
|
const updateHeader = (index: number, field: 'key' | 'value', value: string) => {
|
|
setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, [field]: value } : h)));
|
|
};
|
|
|
|
return (
|
|
<Dialog open={open} onOpenChange={(o) => !o && onClose()}>
|
|
<DialogContent className="max-h-[85vh] max-w-2xl overflow-y-auto">
|
|
<DialogHeader>
|
|
<div className="flex items-start gap-3">
|
|
{/* Server icon (only when available) */}
|
|
{hasIcon && (
|
|
<div className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
|
<img
|
|
src={server.iconUrl}
|
|
alt=""
|
|
className="size-8 rounded object-contain"
|
|
onError={() => setImgError(true)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="min-w-0">
|
|
<DialogTitle className="truncate">{server.name}</DialogTitle>
|
|
<DialogDescription className="mt-1">{server.description}</DialogDescription>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1.5">
|
|
{isInstalled && (
|
|
<Badge
|
|
className="border-emerald-500/30 bg-emerald-500/10 text-emerald-400"
|
|
variant="outline"
|
|
>
|
|
Installed
|
|
</Badge>
|
|
)}
|
|
{server.source !== 'official' && <SourceBadge source={server.source} />}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</DialogHeader>
|
|
|
|
{/* Metadata grid */}
|
|
<div className="grid grid-cols-1 gap-3 text-sm sm:grid-cols-2">
|
|
<div>
|
|
<span className="text-text-muted">Source</span>
|
|
<p className="capitalize text-text">{server.source}</p>
|
|
</div>
|
|
{stars != null && (
|
|
<div>
|
|
<span className="text-text-muted">GitHub Stars</span>
|
|
<p className="flex items-center gap-1 text-text">
|
|
<Star className="size-3.5 fill-amber-400 text-amber-400" />
|
|
{stars.toLocaleString()}
|
|
</p>
|
|
</div>
|
|
)}
|
|
{server.version && (
|
|
<div>
|
|
<span className="text-text-muted">Version</span>
|
|
<p className="text-text">{server.version}</p>
|
|
</div>
|
|
)}
|
|
{server.license && (
|
|
<div>
|
|
<span className="text-text-muted">License</span>
|
|
<p className="text-text">{server.license}</p>
|
|
</div>
|
|
)}
|
|
<div>
|
|
<span className="text-text-muted">Install Type</span>
|
|
{server.installSpec?.type === 'stdio' ? (
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0 text-sm text-blue-400"
|
|
onClick={() => void api.openExternal(npmPackageUrl!)}
|
|
>
|
|
npm: {server.installSpec.npmPackage}
|
|
</Button>
|
|
) : (
|
|
<p className="text-text">
|
|
{server.installSpec
|
|
? `HTTP: ${server.installSpec.transportType}`
|
|
: 'Manual setup required'}
|
|
</p>
|
|
)}
|
|
</div>
|
|
{server.author && (
|
|
<div>
|
|
<span className="text-text-muted">Author</span>
|
|
<p className="text-text">{server.author}</p>
|
|
</div>
|
|
)}
|
|
{server.hostingType && (
|
|
<div>
|
|
<span className="text-text-muted">Hosting</span>
|
|
<p className="capitalize text-text">{server.hostingType}</p>
|
|
</div>
|
|
)}
|
|
{server.publishedAt && (
|
|
<div>
|
|
<span className="text-text-muted">Published</span>
|
|
<p className="text-text">{new Date(server.publishedAt).toLocaleDateString()}</p>
|
|
</div>
|
|
)}
|
|
{server.updatedAt && (
|
|
<div>
|
|
<span className="text-text-muted">Updated</span>
|
|
<p className="text-text">{new Date(server.updatedAt).toLocaleDateString()}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* Auth indicator */}
|
|
{server.requiresAuth && (
|
|
<div className="flex items-center gap-2 rounded-md border border-amber-500/30 bg-amber-500/5 px-3 py-2 text-sm text-amber-400">
|
|
<Lock className="size-4" />
|
|
This server requires authentication
|
|
</div>
|
|
)}
|
|
{isHttp && !server.requiresAuth && (server.authHeaders?.length ?? 0) === 0 && (
|
|
<div className="rounded-md border border-blue-500/30 bg-blue-500/5 px-3 py-2 text-sm text-blue-400">
|
|
Remote MCP servers may still require custom headers or API keys even when the registry
|
|
does not describe them. If connection fails after install, check the provider docs.
|
|
</div>
|
|
)}
|
|
{(isInstalled || diagnosticsLoading) && (
|
|
<div className="space-y-2 rounded-md border border-border bg-surface-raised px-4 py-3">
|
|
<div className="flex items-center justify-between gap-3">
|
|
<span className="text-sm font-medium text-text">Claude Status</span>
|
|
{diagnosticsLoading && !diagnostic ? (
|
|
<Badge
|
|
className="border-border bg-surface-raised text-text-muted"
|
|
variant="outline"
|
|
>
|
|
Checking...
|
|
</Badge>
|
|
) : diagnostic ? (
|
|
<Badge className={diagnosticBadgeClass} variant="outline">
|
|
{diagnostic.statusLabel}
|
|
</Badge>
|
|
) : (
|
|
<Badge
|
|
className="border-border bg-surface-raised text-text-muted"
|
|
variant="outline"
|
|
>
|
|
Not checked
|
|
</Badge>
|
|
)}
|
|
</div>
|
|
{diagnostic?.target && (
|
|
<div>
|
|
<p className="mb-1 text-xs text-text-muted">Launch Target</p>
|
|
<code className="block overflow-x-auto rounded bg-surface px-2 py-1 text-xs text-text">
|
|
{diagnostic.target}
|
|
</code>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Install form */}
|
|
{canAutoInstall && (
|
|
<div className="space-y-3 rounded-md border border-border bg-surface-raised p-4">
|
|
<h4 className="text-sm font-medium text-text">
|
|
{isInstalled ? 'Manage Installation' : 'Install Server'}
|
|
</h4>
|
|
|
|
{/* Server name */}
|
|
<div className="space-y-1.5">
|
|
<Label htmlFor="server-name" className="text-xs">
|
|
Server Name
|
|
</Label>
|
|
<Input
|
|
id="server-name"
|
|
value={serverName}
|
|
onChange={(e) => setServerName(e.target.value)}
|
|
placeholder="my-server"
|
|
className="h-8 text-sm"
|
|
disabled={isInstalled}
|
|
/>
|
|
</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>
|
|
{SCOPE_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={opt.value}>
|
|
{opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{/* Environment variables */}
|
|
{server.envVars.length > 0 && (
|
|
<div className="space-y-1.5">
|
|
<Label className="text-xs">Environment Variables</Label>
|
|
<div className="space-y-2">
|
|
{server.envVars.map((env) => (
|
|
<div key={env.name} className="flex items-center gap-2">
|
|
<code className="w-40 shrink-0 truncate text-xs text-blue-400">
|
|
{env.name}
|
|
</code>
|
|
<Input
|
|
type={env.isSecret ? 'password' : 'text'}
|
|
value={envValues[env.name] ?? ''}
|
|
onChange={(e) =>
|
|
setEnvValues((prev) => ({ ...prev, [env.name]: e.target.value }))
|
|
}
|
|
className="h-7 flex-1 text-xs"
|
|
placeholder={env.description ?? env.name}
|
|
/>
|
|
{autoFilledFields.has(env.name) && envValues[env.name] && (
|
|
<span className="shrink-0 text-[10px] text-emerald-400">Auto-filled</span>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Headers (for HTTP/SSE servers) */}
|
|
{isHttp && (
|
|
<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" />
|
|
{hasSuggestedHeaders ? 'Add custom' : 'Add'}
|
|
</Button>
|
|
</div>
|
|
{headers.length > 0 && (
|
|
<div className="space-y-2">
|
|
{headers.map((header, index) => (
|
|
<div key={index} className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
{header.locked ? (
|
|
<code className="w-32 shrink-0 truncate text-xs text-blue-400">
|
|
{header.key}
|
|
</code>
|
|
) : (
|
|
<Input
|
|
value={header.key}
|
|
onChange={(e) => updateHeader(index, 'key', e.target.value)}
|
|
className="h-7 w-32 text-xs"
|
|
placeholder="Header-Name"
|
|
/>
|
|
)}
|
|
<Input
|
|
type={header.secret ? 'password' : 'text'}
|
|
value={header.value}
|
|
onChange={(e) => updateHeader(index, 'value', e.target.value)}
|
|
className="h-7 flex-1 text-xs"
|
|
placeholder={header.valueTemplate ?? header.description ?? 'value'}
|
|
/>
|
|
<Button
|
|
variant="ghost"
|
|
size="icon"
|
|
className="size-7 text-red-400 hover:bg-red-500/10"
|
|
onClick={() => removeHeader(index)}
|
|
disabled={header.locked && header.isRequired}
|
|
>
|
|
<Trash2 className="size-3" />
|
|
</Button>
|
|
</div>
|
|
{(header.description || header.valueTemplate || header.isRequired) && (
|
|
<p className="text-[10px] text-text-muted">
|
|
{[
|
|
header.isRequired ? 'Required' : null,
|
|
header.description,
|
|
header.valueTemplate,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' • ')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* Install/Uninstall button */}
|
|
<div className="flex justify-end pt-1">
|
|
<InstallButton
|
|
state={installProgress}
|
|
isInstalled={isInstalled}
|
|
onInstall={handleInstall}
|
|
onUninstall={handleUninstall}
|
|
disabled={installDisabled}
|
|
size="default"
|
|
errorMessage={installError}
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{!canAutoInstall && (
|
|
<div className="rounded-md border border-border bg-surface-raised px-4 py-3 text-sm text-text-muted">
|
|
This server requires manual setup. Check the repository for installation instructions.
|
|
</div>
|
|
)}
|
|
|
|
{/* Tools */}
|
|
{server.tools.length > 0 && (
|
|
<div>
|
|
<h4 className="mb-2 flex items-center gap-1.5 text-sm font-medium text-text">
|
|
<Wrench className="size-4" />
|
|
Tools ({server.tools.length})
|
|
</h4>
|
|
<div className="max-h-48 space-y-1 overflow-y-auto">
|
|
{server.tools.map((tool) => (
|
|
<div key={tool.name} className="rounded-md bg-surface-raised p-2 text-xs">
|
|
<code className="font-mono text-text">{tool.name}</code>
|
|
{tool.description && <p className="mt-0.5 text-text-muted">{tool.description}</p>}
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Links */}
|
|
<div className="flex items-center gap-4">
|
|
{server.repositoryUrl && (
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0 text-sm text-blue-400"
|
|
onClick={() => void api.openExternal(server.repositoryUrl!)}
|
|
>
|
|
<ExternalLink className="mr-1 size-3.5" />
|
|
Repository
|
|
</Button>
|
|
)}
|
|
{server.glamaUrl && (
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0 text-sm text-blue-400"
|
|
onClick={() => void api.openExternal(server.glamaUrl!)}
|
|
>
|
|
<ExternalLink className="mr-1 size-3.5" />
|
|
Glama
|
|
</Button>
|
|
)}
|
|
{server.websiteUrl && (
|
|
<Button
|
|
variant="link"
|
|
className="h-auto p-0 text-sm text-blue-400"
|
|
onClick={() => void api.openExternal(server.websiteUrl!)}
|
|
>
|
|
<ExternalLink className="mr-1 size-3.5" />
|
|
Website
|
|
</Button>
|
|
)}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
);
|
|
};
|