From e4870ed0b063c63bbe5965ca2eec1efa2ac912eb Mon Sep 17 00:00:00 2001 From: Marco Beretta <81851188+berry-13@users.noreply.github.com> Date: Sun, 28 Dec 2025 18:20:15 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=8C=20refactor:=20MCP=20UI=20with=20Im?= =?UTF-8?q?proved=20Accessibility=20and=20Reusable=20Components=20(#11118)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: enhance MCP server selection UI with new components and improved accessibility * fix(i18n): add missing com_ui_mcp_servers translation key The MCP server menu aria-label was referencing a non-existent translation key. Added the missing key for accessibility. * feat(MCP): enhance MCP components with improved accessibility and focus management * fix(i18n): remove outdated MCP server translation keys * fix(MCPServerList): improve color contrast by updating text color for no MCP servers message * refactor(MCP): Server status components and improve user action handling Updated MCPServerStatusIcon to use a unified icon system for better clarity Introduced new MCPCardActions component for standardized action buttons on server cards Created MCPServerCard component to encapsulate server display logic and actions Enhanced MCPServerList to render MCPServerCard components, improving code organization Added MCPStatusBadge for consistent status representation in dialogs Updated utility functions for status color and text retrieval to align with new design Improved localization keys for better clarity and consistency in user messages * style(MCP): update button and card background styles for improved UI consistency * feat(MCP): implement global server initialization state management using Jotai * refactor(MCP): modularize MCPServerDialog into structured component architecture - Split monolithic dialog into dedicated section components (Auth, BasicInfo, Connection, Transport, Trust) - Extract form logic into useMCPServerForm custom hook - Add utility modules for JSON import and URL handling - Introduce reusable SecretInput component in @librechat/client - Remove deprecated MCPAuth component * style(MCP): update button styles for improved layout and adjust empty state background color * refactor(Radio): enhance component mounting logic and background style updates * refactor(translation): remove unused keys and streamline localization strings --- .../src/components/Chat/Input/MCPSelect.tsx | 178 +++-- .../src/components/Chat/Input/MCPSubMenu.tsx | 73 +- client/src/components/MCP/MCPConfigDialog.tsx | 40 +- .../src/components/MCP/MCPServerMenuItem.tsx | 113 +++ .../components/MCP/MCPServerStatusIcon.tsx | 213 +++--- client/src/components/MCP/StackedMCPIcons.tsx | 101 +++ client/src/components/MCP/mcpServerUtils.ts | 196 +++++ .../components/SidePanel/Agents/MCPIcon.tsx | 6 +- .../SidePanel/MCPBuilder/MCPAuth.tsx | 505 ------------- .../SidePanel/MCPBuilder/MCPBuilderPanel.tsx | 81 +- .../SidePanel/MCPBuilder/MCPCardActions.tsx | 162 ++++ .../SidePanel/MCPBuilder/MCPServerCard.tsx | 152 ++++ .../SidePanel/MCPBuilder/MCPServerDialog.tsx | 694 ------------------ .../MCPServerDialog/MCPServerForm.tsx | 31 + .../MCPServerDialog/hooks/useMCPServerForm.ts | 311 ++++++++ .../MCPBuilder/MCPServerDialog/index.tsx | 237 ++++++ .../MCPServerDialog/sections/AuthSection.tsx | 226 ++++++ .../sections/BasicInfoSection.tsx | 77 ++ .../sections/ConnectionSection.tsx | 37 + .../sections/TransportSection.tsx | 38 + .../MCPServerDialog/sections/TrustSection.tsx | 75 ++ .../MCPServerDialog/utils/urlUtils.ts | 109 +++ .../SidePanel/MCPBuilder/MCPServerList.tsx | 99 +-- .../SidePanel/MCPBuilder/MCPStatusBadge.tsx | 179 +++++ .../components/SidePanel/MCPBuilder/index.ts | 3 + client/src/hooks/MCP/useMCPServerManager.ts | 109 ++- client/src/locales/en/translation.json | 24 +- client/src/store/mcp.ts | 37 + .../client/src/components/FilterInput.tsx | 2 +- packages/client/src/components/Radio.tsx | 23 +- .../client/src/components/SecretInput.tsx | 106 +++ packages/client/src/components/index.ts | 3 +- 32 files changed, 2594 insertions(+), 1646 deletions(-) create mode 100644 client/src/components/MCP/MCPServerMenuItem.tsx create mode 100644 client/src/components/MCP/StackedMCPIcons.tsx create mode 100644 client/src/components/MCP/mcpServerUtils.ts delete mode 100644 client/src/components/SidePanel/MCPBuilder/MCPAuth.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPCardActions.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerCard.tsx delete mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/MCPServerForm.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/hooks/useMCPServerForm.ts create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/index.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/AuthSection.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/BasicInfoSection.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/ConnectionSection.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/TransportSection.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/sections/TrustSection.tsx create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPServerDialog/utils/urlUtils.ts create mode 100644 client/src/components/SidePanel/MCPBuilder/MCPStatusBadge.tsx create mode 100644 packages/client/src/components/SecretInput.tsx diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index 90e22ce17c..278e603db0 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -1,8 +1,11 @@ -import React, { memo, useCallback } from 'react'; +import React, { memo, useMemo, useCallback, useRef } from 'react'; +import * as Ariakit from '@ariakit/react'; +import { ChevronDown } from 'lucide-react'; import { PermissionTypes, Permissions } from 'librechat-data-provider'; -import { MultiSelect, MCPIcon } from '@librechat/client'; -import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; +import { TooltipAnchor } from '@librechat/client'; +import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; +import StackedMCPIcons from '~/components/MCP/StackedMCPIcons'; import { useBadgeRowContext } from '~/Providers'; import { useHasAccess } from '~/hooks'; import { cn } from '~/utils'; @@ -13,96 +16,117 @@ function MCPSelectContent() { localize, isPinned, mcpValues, - isInitializing, placeholderText, - batchToggleServers, - getConfigDialogProps, - getServerStatusIconProps, selectableServers, + connectionStatus, + isInitializing, + getConfigDialogProps, + toggleServerSelection, + getServerStatusIconProps, } = mcpServerManager; - const renderSelectedValues = useCallback( - ( - values: string[], - placeholder?: string, - items?: (string | { label: string; value: string })[], - ) => { - if (values.length === 0) { - return placeholder || localize('com_ui_select_placeholder'); - } - if (values.length === 1) { - const selectedItem = items?.find((i) => typeof i !== 'string' && i.value == values[0]); - return selectedItem && typeof selectedItem !== 'string' ? selectedItem.label : values[0]; - } - return localize('com_ui_x_selected', { 0: values.length }); + const menuStore = Ariakit.useMenuStore({ focusLoop: true }); + const isOpen = menuStore.useState('open'); + const focusedElementRef = useRef(null); + + const selectedCount = mcpValues?.length ?? 0; + + // Wrap toggleServerSelection to preserve focus after state update + const handleToggle = useCallback( + (serverName: string) => { + // Save currently focused element + focusedElementRef.current = document.activeElement as HTMLElement; + toggleServerSelection(serverName); + // Restore focus after React re-renders + requestAnimationFrame(() => { + focusedElementRef.current?.focus(); + }); }, - [localize], + [toggleServerSelection], ); - const renderItemContent = useCallback( - (serverName: string, defaultContent: React.ReactNode) => { - const statusIconProps = getServerStatusIconProps(serverName); - const isServerInitializing = isInitializing(serverName); + const selectedServers = useMemo(() => { + if (!mcpValues || mcpValues.length === 0) { + return []; + } + return selectableServers.filter((s) => mcpValues.includes(s.serverName)); + }, [selectableServers, mcpValues]); - /** - Common wrapper for the main content (check mark + text). - Ensures Check & Text are adjacent and the group takes available space. - */ - const mainContentWrapper = ( - - ); - - const statusIcon = statusIconProps && ; - - if (statusIcon) { - return ( -
- {mainContentWrapper} -
{statusIcon}
-
- ); - } - - return mainContentWrapper; - }, - [getServerStatusIconProps, isInitializing], - ); + const displayText = useMemo(() => { + if (selectedCount === 0) { + return null; + } + if (selectedCount === 1) { + const server = selectableServers.find((s) => s.serverName === mcpValues?.[0]); + return server?.config?.title || mcpValues?.[0]; + } + return localize('com_ui_x_selected', { 0: selectedCount }); + }, [selectedCount, selectableServers, mcpValues, localize]); if (!isPinned && mcpValues?.length === 0) { return null; } const configDialogProps = getConfigDialogProps(); + return ( <> - ({ - label: s.config.title || s.serverName, - value: s.serverName, - }))} - selectedValues={mcpValues ?? []} - setSelectedValues={batchToggleServers} - renderSelectedValues={renderSelectedValues} - renderItemContent={renderItemContent} - placeholder={placeholderText} - popoverClassName="min-w-fit" - className="badge-icon min-w-fit" - selectIcon={} - selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10" - selectClassName={cn( - 'group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all', - 'md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner', - )} - /> + + + } + > + + + {displayText || placeholderText} + + + + + +
+ {selectableServers.map((server) => ( + + ))} +
+
+
{configDialogProps && ( )} diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx index 38e6167b65..66b816e934 100644 --- a/client/src/components/Chat/Input/MCPSubMenu.tsx +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -1,10 +1,11 @@ import React from 'react'; import * as Ariakit from '@ariakit/react'; import { ChevronRight } from 'lucide-react'; -import { PinIcon, MCPIcon } from '@librechat/client'; -import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; +import { MCPIcon, PinIcon } from '@librechat/client'; +import MCPServerMenuItem from '~/components/MCP/MCPServerMenuItem'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import { useBadgeRowContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; interface MCPSubMenuProps { @@ -13,14 +14,16 @@ interface MCPSubMenuProps { const MCPSubMenu = React.forwardRef( ({ placeholder, ...props }, ref) => { + const localize = useLocalize(); const { mcpServerManager } = useBadgeRowContext(); const { isPinned, mcpValues, setIsPinned, - isInitializing, placeholderText, - availableMCPServers, + selectableServers, + connectionStatus, + isInitializing, getConfigDialogProps, toggleServerSelection, getServerStatusIconProps, @@ -33,7 +36,7 @@ const MCPSubMenu = React.forwardRef( }); // Don't render if no MCP servers are configured - if (!availableMCPServers || availableMCPServers.length === 0) { + if (!selectableServers || selectableServers.length === 0) { return null; } @@ -44,6 +47,7 @@ const MCPSubMenu = React.forwardRef( ) => { @@ -55,9 +59,9 @@ const MCPSubMenu = React.forwardRef( } >
- +
+ - {availableMCPServers.map((s) => { - const statusIconProps = getServerStatusIconProps(s.serverName); - const isSelected = mcpValues?.includes(s.serverName) ?? false; - const isServerInitializing = isInitializing(s.serverName); - - const statusIcon = statusIconProps && ; - - return ( - { - event.preventDefault(); - toggleServerSelection(s.serverName); - }} - disabled={isServerInitializing} - className={cn( - 'flex items-center gap-2 rounded-lg px-2 py-1.5 text-text-primary hover:cursor-pointer', - 'scroll-m-1 outline-none transition-colors', - 'hover:bg-black/[0.075] dark:hover:bg-white/10', - 'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10', - 'w-full min-w-0 justify-between text-sm', - isServerInitializing && - 'opacity-50 hover:bg-transparent dark:hover:bg-transparent', - isSelected && 'bg-surface-active', - )} - > -
- - {s.config.title || s.serverName} -
- {statusIcon &&
{statusIcon}
} -
- ); - })} +
+ {selectableServers.map((server) => ( + + ))} +
{configDialogProps && } diff --git a/client/src/components/MCP/MCPConfigDialog.tsx b/client/src/components/MCP/MCPConfigDialog.tsx index ecd0f8dae4..a3727971e9 100644 --- a/client/src/components/MCP/MCPConfigDialog.tsx +++ b/client/src/components/MCP/MCPConfigDialog.tsx @@ -71,7 +71,14 @@ export default function MCPConfigDialog({ }); }, [serverStatus, serverName, localize]); - // Helper function to render status badge based on connection state + /** + * Render status badge with unified color system: + * - Blue: Connecting/In-progress + * - Amber: Needs action (OAuth required) + * - Gray: Disconnected (neutral/inactive) + * - Green: Connected (success) + * - Red: Error + */ const renderStatusBadge = () => { if (!serverStatus) { return null; @@ -79,46 +86,51 @@ export default function MCPConfigDialog({ const { connectionState, requiresOAuth } = serverStatus; + // Connecting: blue (in progress) if (connectionState === 'connecting') { return (
- + {localize('com_ui_connecting')}
); } + // Disconnected: check if needs action if (connectionState === 'disconnected') { if (requiresOAuth) { + // Needs OAuth: amber (requires action) return (
-
- ); - } else { - return ( -
-
); } + // Simply disconnected: gray (neutral) + return ( +
+
+ ); } + // Error: red if (connectionState === 'error') { return (
-
); } + // Connected: green if (connectionState === 'connected') { return ( -
-
+
+
{localize('com_ui_active')}
); diff --git a/client/src/components/MCP/MCPServerMenuItem.tsx b/client/src/components/MCP/MCPServerMenuItem.tsx new file mode 100644 index 0000000000..2291a5233e --- /dev/null +++ b/client/src/components/MCP/MCPServerMenuItem.tsx @@ -0,0 +1,113 @@ +import * as Ariakit from '@ariakit/react'; +import { Check } from 'lucide-react'; +import { MCPIcon } from '@librechat/client'; +import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; +import type { MCPServerStatusIconProps } from './MCPServerStatusIcon'; +import MCPServerStatusIcon from './MCPServerStatusIcon'; +import { + getStatusColor, + getStatusTextKey, + shouldShowActionButton, + type ConnectionStatusMap, +} from './mcpServerUtils'; +import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; + +interface MCPServerMenuItemProps { + server: MCPServerDefinition; + isSelected: boolean; + connectionStatus?: ConnectionStatusMap; + isInitializing?: (serverName: string) => boolean; + statusIconProps?: MCPServerStatusIconProps | null; + onToggle: (serverName: string) => void; +} + +export default function MCPServerMenuItem({ + server, + isSelected, + connectionStatus, + isInitializing, + statusIconProps, + onToggle, +}: MCPServerMenuItemProps) { + const localize = useLocalize(); + const displayName = server.config?.title || server.serverName; + const statusColor = getStatusColor(server.serverName, connectionStatus, isInitializing); + const statusTextKey = getStatusTextKey(server.serverName, connectionStatus, isInitializing); + const statusText = localize(statusTextKey as Parameters[0]); + const showActionButton = shouldShowActionButton(statusIconProps); + + // Include status in aria-label so screen readers announce it + const accessibleLabel = `${displayName}, ${statusText}`; + + return ( + onToggle(server.serverName)} + aria-label={accessibleLabel} + className={cn( + 'group flex w-full cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2', + 'outline-none transition-all duration-150', + 'hover:bg-surface-hover data-[active-item]:bg-surface-hover', + isSelected && 'bg-surface-active-alt', + )} + > + {/* Server Icon with Status Dot */} +
+ {server.config?.iconPath ? ( + {displayName} + ) : ( +
+ +
+ )} + {/* Status dot - decorative, status is announced via aria-label on MenuItem */} + + + {/* Server Info */} +
+
+ {displayName} +
+ {server.config?.description && ( +

{server.config.description}

+ )} +
+ + {/* Action Button - only show when actionable */} + {showActionButton && statusIconProps && ( +
e.stopPropagation()}> + +
+ )} + + {/* Selection Indicator - purely visual, state conveyed by aria-checked on MenuItem */} + + + ); +} diff --git a/client/src/components/MCP/MCPServerStatusIcon.tsx b/client/src/components/MCP/MCPServerStatusIcon.tsx index 36a876e23e..8ae225b2e2 100644 --- a/client/src/components/MCP/MCPServerStatusIcon.tsx +++ b/client/src/components/MCP/MCPServerStatusIcon.tsx @@ -1,8 +1,9 @@ import React from 'react'; -import { Spinner, TooltipAnchor } from '@librechat/client'; -import { SettingsIcon, AlertTriangle, KeyRound, PlugZap, X, CircleCheck } from 'lucide-react'; -import type { MCPServerStatus, TPlugin } from 'librechat-data-provider'; +import { Spinner } from '@librechat/client'; +import { PlugZap, SlidersHorizontal, X } from 'lucide-react'; +import type { MCPServerStatus } from 'librechat-data-provider'; import { useLocalize } from '~/hooks'; +import { cn } from '~/utils'; let localize: ReturnType; @@ -16,34 +17,48 @@ interface InitializingStatusProps extends StatusIconProps { canCancel: boolean; } -interface MCPServerStatusIconProps { +export interface MCPServerStatusIconProps { serverName: string; serverStatus?: MCPServerStatus; - tool?: TPlugin; onConfigClick: (e: React.MouseEvent) => void; isInitializing: boolean; canCancel: boolean; onCancel: (e: React.MouseEvent) => void; hasCustomUserVars?: boolean; + /** When true, renders as a small status dot for compact layouts */ + compact?: boolean; } /** - * Renders the appropriate status icon for an MCP server based on its state + * Renders the appropriate status icon for an MCP server based on its state. + * + * Unified icon system: + * - PlugZap: Connect/Authenticate (for disconnected servers that need connection) + * - SlidersHorizontal: Configure (for connected servers with custom vars) + * - Spinner: Loading state (during connection) + * - X: Cancel (during OAuth flow, shown on hover over spinner) */ export default function MCPServerStatusIcon({ serverName, serverStatus, - tool, onConfigClick, isInitializing, canCancel, onCancel, hasCustomUserVars = false, + compact = false, }: MCPServerStatusIconProps) { localize = useLocalize(); + + // Compact mode: render as a small status dot + if (compact) { + return ; + } + + // Loading state: show spinner (with cancel option if available) if (isInitializing) { return ( - ; + return ; } - if (connectionState === 'disconnected') { - if (requiresOAuth) { - return ; - } - return ; + // Disconnected or Error: show connect button (PlugZap icon) + if (connectionState === 'disconnected' || connectionState === 'error') { + return ; } - if (connectionState === 'error') { - return ; - } - - if (connectionState === 'connected') { - // Only show config button if there are customUserVars to configure - if (hasCustomUserVars) { - const isAuthenticated = tool?.authenticated || requiresOAuth; - return ( - - ); - } - return ( - - ); + // Connected: only show config button if there are custom vars to configure + if (connectionState === 'connected' && hasCustomUserVars) { + return ; } + // Connected without custom vars: no action needed, status shown via dot return null; } -function InitializingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) { +interface CompactStatusDotProps { + serverStatus?: MCPServerStatus; + isInitializing: boolean; +} + +function CompactStatusDot({ serverStatus, isInitializing }: CompactStatusDotProps) { + if (isInitializing) { + return ( +
+
+
+ ); + } + + if (!serverStatus) { + return
; + } + + const { connectionState, requiresOAuth } = serverStatus; + + let colorClass = 'bg-gray-400'; + if (connectionState === 'connected') { + colorClass = 'bg-green-500'; + } else if (connectionState === 'connecting') { + colorClass = 'bg-blue-500'; + } else if (connectionState === 'error') { + colorClass = 'bg-red-500'; + } else if (connectionState === 'disconnected' && requiresOAuth) { + colorClass = 'bg-amber-500'; + } + + return ( +
+ ); +} + +function LoadingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) { if (canCancel) { return ( ); } return ( -
+
); } -function ConnectingStatusIcon({ serverName }: StatusIconProps) { +function ConnectingSpinner({ serverName }: { serverName: string }) { return ( -
+
); } -function DisconnectedOAuthStatusIcon({ serverName, onConfigClick }: StatusIconProps) { +/** Connect button - shown for disconnected/error states. Uses PlugZap icon. */ +function ConnectButton({ serverName, onConfigClick }: StatusIconProps) { return ( ); } -function DisconnectedStatusIcon({ serverName, onConfigClick }: StatusIconProps) { +/** Configure button - shown for connected servers with custom vars. Uses SlidersHorizontal icon. */ +function ConfigureButton({ serverName, onConfigClick }: StatusIconProps) { return ( ); } - -function ErrorStatusIcon({ serverName, onConfigClick }: StatusIconProps) { - return ( - - ); -} - -interface AuthenticatedStatusProps extends StatusIconProps { - isAuthenticated: boolean; -} - -function AuthenticatedStatusIcon({ - serverName, - onConfigClick, - isAuthenticated, -}: AuthenticatedStatusProps) { - return ( - - ); -} - -interface ConnectedStatusProps { - serverName: string; - requiresOAuth?: boolean; - onConfigClick: (e: React.MouseEvent) => void; -} - -function ConnectedStatusIcon({ serverName, requiresOAuth, onConfigClick }: ConnectedStatusProps) { - if (requiresOAuth) { - return ( - - - - ); - } - - return ( - - - - ); -} diff --git a/client/src/components/MCP/StackedMCPIcons.tsx b/client/src/components/MCP/StackedMCPIcons.tsx new file mode 100644 index 0000000000..fa04928210 --- /dev/null +++ b/client/src/components/MCP/StackedMCPIcons.tsx @@ -0,0 +1,101 @@ +import { useMemo } from 'react'; +import { MCPIcon } from '@librechat/client'; +import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; +import { getSelectedServerIcons } from './mcpServerUtils'; +import { cn } from '~/utils'; + +interface StackedMCPIconsProps { + selectedServers: MCPServerDefinition[]; + maxIcons?: number; + iconSize?: 'sm' | 'md'; + variant?: 'default' | 'submenu'; +} + +const sizeConfig = { + sm: { + icon: 'h-[18px] w-[18px]', + container: 'h-[22px] w-[22px]', + overlap: '-ml-2.5', + }, + md: { + icon: 'h-5 w-5', + container: 'h-6 w-6', + overlap: '-ml-3', + }, +}; + +const variantConfig = { + default: { + border: 'border-border-medium', + bg: 'bg-surface-secondary', + }, + submenu: { + border: 'border-surface-primary', + bg: 'bg-surface-primary', + }, +}; + +export default function StackedMCPIcons({ + selectedServers, + maxIcons = 3, + iconSize = 'md', + variant = 'default', +}: StackedMCPIconsProps) { + const { icons, overflowCount } = useMemo( + () => getSelectedServerIcons(selectedServers, maxIcons), + [selectedServers, maxIcons], + ); + + if (icons.length === 0) { + return ( +