296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
/**
|
|
* McpServerCard — grid card for a single MCP server in the catalog.
|
|
* Shows server icon from registry when available.
|
|
*/
|
|
|
|
import { useState } from 'react';
|
|
|
|
import { api } from '@renderer/api';
|
|
import { Badge } from '@renderer/components/ui/badge';
|
|
import { Button } from '@renderer/components/ui/button';
|
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
|
import { useStore } from '@renderer/store';
|
|
import { formatCompactNumber, formatRelativeTime } from '@renderer/utils/formatters';
|
|
import {
|
|
getMcpInstallationSummaryLabel,
|
|
getMcpOperationKey,
|
|
sanitizeMcpServerName,
|
|
} from '@shared/utils/extensionNormalizers';
|
|
import { Clock, Cloud, Globe, KeyRound, Lock, Monitor, Star, Tag, Wrench } from 'lucide-react';
|
|
import { Github as GithubIcon } from 'lucide-react';
|
|
|
|
import { InstallButton } from '../common/InstallButton';
|
|
import { SourceBadge } from '../common/SourceBadge';
|
|
|
|
import type {
|
|
InstalledMcpEntry,
|
|
McpCatalogItem,
|
|
McpServerDiagnostic,
|
|
} from '@shared/types/extensions';
|
|
|
|
interface McpServerCardProps {
|
|
server: McpCatalogItem;
|
|
isInstalled: boolean;
|
|
installedEntry?: InstalledMcpEntry | null;
|
|
installedEntries?: InstalledMcpEntry[];
|
|
diagnostic?: McpServerDiagnostic | null;
|
|
diagnosticsLoading?: boolean;
|
|
onClick: (serverId: string) => void;
|
|
}
|
|
|
|
export const McpServerCard = ({
|
|
server,
|
|
isInstalled,
|
|
installedEntry,
|
|
installedEntries = [],
|
|
diagnostic,
|
|
diagnosticsLoading,
|
|
onClick,
|
|
}: McpServerCardProps): React.JSX.Element => {
|
|
const operationKey = getMcpOperationKey(server.id, 'user');
|
|
const installProgress = useStore((s) => s.mcpInstallProgress[operationKey] ?? 'idle');
|
|
const installMcpServer = useStore((s) => s.installMcpServer);
|
|
const uninstallMcpServer = useStore((s) => s.uninstallMcpServer);
|
|
const installError = useStore((s) => s.installErrors[operationKey]);
|
|
const stars = useStore((s) =>
|
|
server.repositoryUrl ? s.mcpGitHubStars[server.repositoryUrl] : undefined
|
|
);
|
|
const canAutoInstall = !!server.installSpec;
|
|
const normalizedInstalledEntries = installedEntries.length
|
|
? installedEntries
|
|
: installedEntry
|
|
? [installedEntry]
|
|
: [];
|
|
const requiresConfiguration =
|
|
server.installSpec?.type === 'http' ||
|
|
server.envVars.length > 0 ||
|
|
server.requiresAuth ||
|
|
(server.authHeaders?.length ?? 0) > 0;
|
|
const defaultServerName = sanitizeMcpServerName(server.name);
|
|
const userInstallEntry =
|
|
normalizedInstalledEntries.find((entry) => entry.scope === 'user') ?? null;
|
|
const installSummaryLabel = getMcpInstallationSummaryLabel(normalizedInstalledEntries);
|
|
const supportsDirectInstalledAction =
|
|
isInstalled &&
|
|
normalizedInstalledEntries.length === 1 &&
|
|
userInstallEntry?.name === defaultServerName &&
|
|
!requiresConfiguration;
|
|
const shouldShowDirectInstallButton =
|
|
canAutoInstall && (!isInstalled ? !requiresConfiguration : supportsDirectInstalledAction);
|
|
const [imgError, setImgError] = useState(false);
|
|
const hasIcon = !!server.iconUrl && !imgError;
|
|
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';
|
|
|
|
return (
|
|
<div
|
|
role="button"
|
|
tabIndex={0}
|
|
onClick={() => onClick(server.id)}
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') {
|
|
e.preventDefault();
|
|
onClick(server.id);
|
|
}
|
|
}}
|
|
className={`relative flex w-full cursor-pointer flex-col gap-2 overflow-hidden rounded-lg border p-4 text-left transition-all duration-200 hover:border-border-emphasis hover:bg-surface-raised hover:shadow-[0_0_12px_rgba(255,255,255,0.02)] focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-[var(--color-border-emphasis)] ${
|
|
isInstalled ? 'border-l-2 border-border border-l-emerald-500/30' : 'border-border'
|
|
}`}
|
|
>
|
|
{/* Header: icon + name */}
|
|
<div className="flex items-start gap-2.5">
|
|
{/* Server icon (only when available) */}
|
|
{hasIcon && (
|
|
<div className="flex size-9 shrink-0 items-center justify-center rounded-lg border border-border bg-surface-raised">
|
|
<img
|
|
src={server.iconUrl}
|
|
alt=""
|
|
className="size-7 rounded object-contain"
|
|
onError={() => setImgError(true)}
|
|
/>
|
|
</div>
|
|
)}
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex items-start justify-between gap-2">
|
|
<div className="min-w-0">
|
|
<h3 className="truncate text-sm font-semibold text-text">{server.name}</h3>
|
|
{server.source !== 'official' && (
|
|
<div className="mt-1">
|
|
<SourceBadge source={server.source} />
|
|
</div>
|
|
)}
|
|
</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"
|
|
>
|
|
{installSummaryLabel ?? 'Installed'}
|
|
</Badge>
|
|
)}
|
|
{isInstalled && 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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Description */}
|
|
<p className="line-clamp-2 text-xs text-text-secondary">{server.description}</p>
|
|
{diagnostic?.target && (
|
|
<p className="truncate font-mono text-[10px] text-text-muted" title={diagnostic.target}>
|
|
{diagnostic.target}
|
|
</p>
|
|
)}
|
|
|
|
{/* Footer indicators + install button */}
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-x-3 gap-y-1 text-xs text-text-muted">
|
|
{server.tools.length > 0 && (
|
|
<span className="inline-flex items-center gap-1 rounded-full bg-surface-raised px-1.5 py-0.5 ring-1 ring-border">
|
|
<Wrench className="size-3" />
|
|
{server.tools.length} {server.tools.length === 1 ? 'tool' : 'tools'}
|
|
</span>
|
|
)}
|
|
{server.envVars.length > 0 && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<KeyRound className="size-3" />
|
|
{server.envVars.length} {server.envVars.length === 1 ? 'env' : 'envs'}
|
|
</span>
|
|
)}
|
|
{server.requiresAuth && (
|
|
<span className="inline-flex items-center gap-1 text-amber-400">
|
|
<Lock className="size-3" />
|
|
Auth
|
|
</span>
|
|
)}
|
|
{server.version && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Tag className="size-3" />
|
|
{server.version}
|
|
</span>
|
|
)}
|
|
{server.updatedAt && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Clock className="size-3" />
|
|
{formatRelativeTime(server.updatedAt)}
|
|
</span>
|
|
)}
|
|
{server.author && <span className="truncate">by {server.author}</span>}
|
|
{server.hostingType === 'remote' && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Cloud className="size-3" />
|
|
Remote
|
|
</span>
|
|
)}
|
|
{server.hostingType === 'local' && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Monitor className="size-3" />
|
|
Local
|
|
</span>
|
|
)}
|
|
{server.hostingType === 'both' && (
|
|
<span className="inline-flex items-center gap-1">
|
|
<Globe className="size-3" />
|
|
Both
|
|
</span>
|
|
)}
|
|
{/* External links + stars */}
|
|
{server.repositoryUrl && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
className="inline-flex items-center gap-1.5 text-text-muted transition-colors hover:text-text"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void api.openExternal(server.repositoryUrl!);
|
|
}}
|
|
>
|
|
<GithubIcon className="size-3.5" />
|
|
{stars != null && (
|
|
<span className="inline-flex items-center gap-0.5">
|
|
<Star className="size-3 fill-amber-400 text-amber-400" />
|
|
{formatCompactNumber(stars)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">Repository</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
{server.websiteUrl && (
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<button
|
|
className="inline-flex items-center text-text-muted transition-colors hover:text-text"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
void api.openExternal(server.websiteUrl!);
|
|
}}
|
|
>
|
|
<Globe className="size-3.5" />
|
|
</button>
|
|
</TooltipTrigger>
|
|
<TooltipContent side="top">Website</TooltipContent>
|
|
</Tooltip>
|
|
)}
|
|
</div>
|
|
{shouldShowDirectInstallButton && (
|
|
<div className="shrink-0">
|
|
<InstallButton
|
|
state={installProgress}
|
|
isInstalled={isInstalled}
|
|
section="mcp"
|
|
onInstall={() =>
|
|
installMcpServer({
|
|
registryId: server.id,
|
|
serverName: defaultServerName,
|
|
scope: 'user',
|
|
envValues: {},
|
|
headers: [],
|
|
})
|
|
}
|
|
onUninstall={() =>
|
|
uninstallMcpServer(server.id, userInstallEntry?.name ?? defaultServerName, 'user')
|
|
}
|
|
size="sm"
|
|
errorMessage={installError}
|
|
/>
|
|
</div>
|
|
)}
|
|
{canAutoInstall && (!shouldShowDirectInstallButton || requiresConfiguration) && (
|
|
<div className="shrink-0">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
onClick(server.id);
|
|
}}
|
|
>
|
|
{isInstalled ? 'Manage' : 'Configure'}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|