LibreChat/client/src/components/Chat/Input/MCPSelect.tsx
Marco Beretta e4870ed0b0
🔌 refactor: MCP UI with Improved Accessibility and Reusable Components (#11118)
* 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
2025-12-28 12:20:15 -05:00

152 lines
5.1 KiB
TypeScript

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 { 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';
function MCPSelectContent() {
const { conversationId, mcpServerManager } = useBadgeRowContext();
const {
localize,
isPinned,
mcpValues,
placeholderText,
selectableServers,
connectionStatus,
isInitializing,
getConfigDialogProps,
toggleServerSelection,
getServerStatusIconProps,
} = mcpServerManager;
const menuStore = Ariakit.useMenuStore({ focusLoop: true });
const isOpen = menuStore.useState('open');
const focusedElementRef = useRef<HTMLElement | null>(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();
});
},
[toggleServerSelection],
);
const selectedServers = useMemo(() => {
if (!mcpValues || mcpValues.length === 0) {
return [];
}
return selectableServers.filter((s) => mcpValues.includes(s.serverName));
}, [selectableServers, mcpValues]);
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 (
<>
<Ariakit.MenuProvider store={menuStore}>
<TooltipAnchor
description={placeholderText}
disabled={isOpen}
render={
<Ariakit.MenuButton
className={cn(
'group relative inline-flex items-center justify-center gap-1.5',
'border border-border-medium text-sm font-medium transition-all',
'h-9 min-w-9 rounded-full bg-transparent px-2.5 shadow-sm',
'hover:bg-surface-hover hover:shadow-md active:shadow-inner',
'md:w-fit md:justify-start md:px-3',
isOpen && 'bg-surface-hover',
)}
/>
}
>
<StackedMCPIcons selectedServers={selectedServers} maxIcons={3} iconSize="sm" />
<span className="hidden truncate text-text-primary md:block">
{displayText || placeholderText}
</span>
<ChevronDown
className={cn(
'hidden h-3 w-3 text-text-secondary transition-transform md:block',
isOpen && 'rotate-180',
)}
/>
</TooltipAnchor>
<Ariakit.Menu
portal={true}
gutter={8}
aria-label={localize('com_ui_mcp_servers')}
className={cn(
'z-50 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
'border border-border-light bg-presentation p-1.5 shadow-lg',
'origin-top opacity-0 transition-[opacity,transform] duration-200 ease-out',
'data-[enter]:scale-100 data-[enter]:opacity-100',
'scale-95 data-[leave]:scale-95 data-[leave]:opacity-0',
)}
>
<div className="flex max-h-[320px] flex-col gap-1 overflow-y-auto">
{selectableServers.map((server) => (
<MCPServerMenuItem
key={server.serverName}
server={server}
isSelected={mcpValues?.includes(server.serverName) ?? false}
connectionStatus={connectionStatus}
isInitializing={isInitializing}
statusIconProps={getServerStatusIconProps(server.serverName)}
onToggle={handleToggle}
/>
))}
</div>
</Ariakit.Menu>
</Ariakit.MenuProvider>
{configDialogProps && (
<MCPConfigDialog {...configDialogProps} conversationId={conversationId} />
)}
</>
);
}
function MCPSelect() {
const { mcpServerManager } = useBadgeRowContext();
const { selectableServers } = mcpServerManager;
const canUseMcp = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.USE,
});
if (!canUseMcp || !selectableServers || selectableServers.length === 0) {
return null;
}
return <MCPSelectContent />;
}
export default memo(MCPSelect);