🔌 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
This commit is contained in:
Marco Beretta 2025-12-28 18:20:15 +01:00 committed by GitHub
parent 0b8e0fcede
commit e4870ed0b0
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 2594 additions and 1646 deletions

View file

@ -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<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();
});
},
[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 = (
<button
type="button"
className={`flex flex-grow items-center rounded bg-transparent p-0 text-left transition-colors focus:outline-none ${
isServerInitializing ? 'opacity-50' : ''
}`}
tabIndex={0}
disabled={isServerInitializing}
>
{defaultContent}
</button>
);
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
if (statusIcon) {
return (
<div className="flex w-full items-center justify-between">
{mainContentWrapper}
<div className="ml-2 flex items-center">{statusIcon}</div>
</div>
);
}
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 (
<>
<MultiSelect
items={selectableServers.map((s) => ({
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={<MCPIcon className="icon-md text-text-primary" />}
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',
)}
/>
<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} />
)}

View file

@ -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<HTMLDivElement, MCPSubMenuProps>(
({ 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<HTMLDivElement, MCPSubMenuProps>(
});
// 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<HTMLDivElement, MCPSubMenuProps>(
<Ariakit.MenuProvider store={menuStore}>
<Ariakit.MenuItem
{...props}
hideOnClick={false}
render={
<Ariakit.MenuButton
onClick={(e: React.MouseEvent<HTMLButtonElement>) => {
@ -55,9 +59,9 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
}
>
<div className="flex items-center gap-2">
<MCPIcon className="icon-md" />
<MCPIcon className="h-5 w-5 flex-shrink-0 text-text-primary" aria-hidden="true" />
<span>{placeholder || placeholderText}</span>
<ChevronRight className="ml-auto h-3 w-3" />
<ChevronRight className="h-3 w-3 flex-shrink-0" aria-hidden="true" />
</div>
<button
type="button"
@ -70,55 +74,36 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
'hover:bg-surface-tertiary hover:shadow-sm',
!isPinned && 'text-text-secondary hover:text-text-primary',
)}
aria-label={isPinned ? 'Unpin' : 'Pin'}
aria-label={isPinned ? localize('com_ui_unpin') : localize('com_ui_pin')}
>
<div className="h-4 w-4">
<PinIcon unpin={isPinned} />
</div>
</button>
</Ariakit.MenuItem>
<Ariakit.Menu
portal={true}
unmountOnHide={true}
aria-label={localize('com_ui_mcp_servers')}
className={cn(
'animate-popover-left z-50 ml-3 flex min-w-[200px] flex-col rounded-xl',
'border border-border-light bg-surface-secondary p-1 shadow-lg',
'animate-popover-left z-50 ml-3 flex min-w-[260px] max-w-[320px] flex-col rounded-xl',
'border border-border-light bg-presentation p-1.5 shadow-lg',
)}
>
{availableMCPServers.map((s) => {
const statusIconProps = getServerStatusIconProps(s.serverName);
const isSelected = mcpValues?.includes(s.serverName) ?? false;
const isServerInitializing = isInitializing(s.serverName);
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
return (
<Ariakit.MenuItem
key={s.serverName}
onClick={(event) => {
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',
)}
>
<div className="flex flex-grow items-center gap-2">
<Ariakit.MenuItemCheck checked={isSelected} />
<span>{s.config.title || s.serverName}</span>
</div>
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
</Ariakit.MenuItem>
);
})}
<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={toggleServerSelection}
/>
))}
</div>
</Ariakit.Menu>
</Ariakit.MenuProvider>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}

View file

@ -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 (
<div className="flex items-center gap-2 rounded-full bg-blue-50 px-2 py-0.5 text-xs font-medium text-blue-600 dark:bg-blue-950 dark:text-blue-400">
<Spinner className="h-3 w-3" />
<Spinner className="size-3" />
<span>{localize('com_ui_connecting')}</span>
</div>
);
}
// Disconnected: check if needs action
if (connectionState === 'disconnected') {
if (requiresOAuth) {
// Needs OAuth: amber (requires action)
return (
<div className="flex items-center gap-2 rounded-full bg-amber-50 px-2 py-0.5 text-xs font-medium text-amber-600 dark:bg-amber-950 dark:text-amber-400">
<KeyRound className="h-3 w-3" aria-hidden="true" />
<span>{localize('com_ui_oauth')}</span>
</div>
);
} else {
return (
<div className="flex items-center gap-2 rounded-full bg-orange-50 px-2 py-0.5 text-xs font-medium text-orange-600 dark:bg-orange-950 dark:text-orange-400">
<PlugZap className="h-3 w-3" aria-hidden="true" />
<span>{localize('com_ui_offline')}</span>
<KeyRound className="size-3" aria-hidden="true" />
<span>{localize('com_nav_mcp_status_needs_auth')}</span>
</div>
);
}
// Simply disconnected: gray (neutral)
return (
<div className="flex items-center gap-2 rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
<PlugZap className="size-3" aria-hidden="true" />
<span>{localize('com_nav_mcp_status_disconnected')}</span>
</div>
);
}
// Error: red
if (connectionState === 'error') {
return (
<div className="flex items-center gap-2 rounded-full bg-red-50 px-2 py-0.5 text-xs font-medium text-red-600 dark:bg-red-950 dark:text-red-400">
<AlertTriangle className="h-3 w-3" aria-hidden="true" />
<AlertTriangle className="size-3" aria-hidden="true" />
<span>{localize('com_ui_error')}</span>
</div>
);
}
// Connected: green
if (connectionState === 'connected') {
return (
<div className="flex items-center gap-2 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900 dark:text-green-300">
<div className="h-1.5 w-1.5 rounded-full bg-green-500" />
<div className="flex items-center gap-2 rounded-full bg-green-50 px-2 py-0.5 text-xs font-medium text-green-600 dark:bg-green-950 dark:text-green-400">
<div className="size-1.5 rounded-full bg-green-500" />
<span>{localize('com_ui_active')}</span>
</div>
);

View file

@ -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<typeof localize>[0]);
const showActionButton = shouldShowActionButton(statusIconProps);
// Include status in aria-label so screen readers announce it
const accessibleLabel = `${displayName}, ${statusText}`;
return (
<Ariakit.MenuItemCheckbox
hideOnClick={false}
name="mcp-servers"
value={server.serverName}
checked={isSelected}
setValueOnChange={false}
onChange={() => 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 */}
<div className="relative flex-shrink-0">
{server.config?.iconPath ? (
<img
src={server.config.iconPath}
className="h-8 w-8 rounded-lg object-cover"
alt={displayName}
/>
) : (
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-surface-tertiary">
<MCPIcon className="h-5 w-5 text-text-secondary" />
</div>
)}
{/* Status dot - decorative, status is announced via aria-label on MenuItem */}
<div
aria-hidden="true"
className={cn(
'absolute -bottom-0.5 -right-0.5 h-3 w-3 rounded-full border-2 border-surface-secondary',
statusColor,
)}
/>
</div>
{/* Server Info */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
</div>
{server.config?.description && (
<p className="truncate text-xs text-text-secondary">{server.config.description}</p>
)}
</div>
{/* Action Button - only show when actionable */}
{showActionButton && statusIconProps && (
<div className="flex-shrink-0" onClick={(e) => e.stopPropagation()}>
<MCPServerStatusIcon {...statusIconProps} />
</div>
)}
{/* Selection Indicator - purely visual, state conveyed by aria-checked on MenuItem */}
<span
aria-hidden="true"
className={cn(
'flex h-4 w-4 flex-shrink-0 items-center justify-center rounded-sm border',
isSelected
? 'border-primary bg-primary text-primary-foreground'
: 'border-border-xheavy bg-transparent',
)}
>
{isSelected && <Check className="h-4 w-4" />}
</span>
</Ariakit.MenuItemCheckbox>
);
}

View file

@ -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<typeof useLocalize>;
@ -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 <CompactStatusDot serverStatus={serverStatus} isInitializing={isInitializing} />;
}
// Loading state: show spinner (with cancel option if available)
if (isInitializing) {
return (
<InitializingStatusIcon
<LoadingStatusIcon
serverName={serverName}
onConfigClick={onConfigClick}
onCancel={onCancel}
@ -56,178 +71,126 @@ export default function MCPServerStatusIcon({
return null;
}
const { connectionState, requiresOAuth } = serverStatus;
const { connectionState } = serverStatus;
// Connecting: show spinner only (no action available)
if (connectionState === 'connecting') {
return <ConnectingStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
return <ConnectingSpinner serverName={serverName} />;
}
if (connectionState === 'disconnected') {
if (requiresOAuth) {
return <DisconnectedOAuthStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
}
return <DisconnectedStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
// Disconnected or Error: show connect button (PlugZap icon)
if (connectionState === 'disconnected' || connectionState === 'error') {
return <ConnectButton serverName={serverName} onConfigClick={onConfigClick} />;
}
if (connectionState === 'error') {
return <ErrorStatusIcon serverName={serverName} onConfigClick={onConfigClick} />;
}
if (connectionState === 'connected') {
// Only show config button if there are customUserVars to configure
if (hasCustomUserVars) {
const isAuthenticated = tool?.authenticated || requiresOAuth;
return (
<AuthenticatedStatusIcon
serverName={serverName}
onConfigClick={onConfigClick}
isAuthenticated={isAuthenticated}
/>
);
}
return (
<ConnectedStatusIcon
serverName={serverName}
requiresOAuth={requiresOAuth}
onConfigClick={onConfigClick}
/>
);
// Connected: only show config button if there are custom vars to configure
if (connectionState === 'connected' && hasCustomUserVars) {
return <ConfigureButton serverName={serverName} onConfigClick={onConfigClick} />;
}
// 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 (
<div className="flex size-3.5 items-center justify-center rounded-full border-2 border-surface-secondary bg-blue-500">
<div className="size-1.5 animate-pulse rounded-full bg-white" />
</div>
);
}
if (!serverStatus) {
return <div className="size-3 rounded-full border-2 border-surface-secondary bg-gray-400" />;
}
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 (
<div className={cn('size-3 rounded-full border-2 border-surface-secondary', colorClass)} />
);
}
function LoadingStatusIcon({ serverName, onCancel, canCancel }: InitializingStatusProps) {
if (canCancel) {
return (
<button
type="button"
onClick={onCancel}
className="group flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
className="group flex size-6 items-center justify-center rounded p-1 hover:bg-red-100 dark:hover:bg-red-900/20"
aria-label={localize('com_ui_cancel')}
title={localize('com_ui_cancel')}
>
<div className="relative h-4 w-4">
<Spinner className="h-4 w-4 group-hover:opacity-0" />
<X className="absolute inset-0 h-4 w-4 text-red-500 opacity-0 group-hover:opacity-100" />
<div className="relative size-4">
<Spinner className="size-4 text-text-primary group-hover:opacity-0" />
<X className="absolute inset-0 size-4 text-red-500 opacity-0 group-hover:opacity-100" />
</div>
</button>
);
}
return (
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<div className="flex size-6 items-center justify-center rounded p-1">
<Spinner
className="h-4 w-4"
className="size-4 text-text-primary"
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
/>
</div>
);
}
function ConnectingStatusIcon({ serverName }: StatusIconProps) {
function ConnectingSpinner({ serverName }: { serverName: string }) {
return (
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<div className="flex size-6 items-center justify-center rounded p-1">
<Spinner
className="h-4 w-4"
className="size-4 text-text-primary"
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
/>
</div>
);
}
function DisconnectedOAuthStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
/** Connect button - shown for disconnected/error states. Uses PlugZap icon. */
function ConnectButton({ serverName, onConfigClick }: StatusIconProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_connect_server', { 0: serverName })}
>
<KeyRound className="h-4 w-4 text-amber-500" aria-hidden="true" />
<PlugZap className="size-4 text-text-secondary" aria-hidden="true" />
</button>
);
}
function DisconnectedStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
/** Configure button - shown for connected servers with custom vars. Uses SlidersHorizontal icon. */
function ConfigureButton({ serverName, onConfigClick }: StatusIconProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
className="flex size-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
>
<PlugZap className="h-4 w-4 text-orange-500" aria-hidden="true" />
<SlidersHorizontal className="size-4 text-text-secondary" aria-hidden="true" />
</button>
);
}
function ErrorStatusIcon({ serverName, onConfigClick }: StatusIconProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
>
<AlertTriangle className="h-4 w-4 text-red-500" aria-hidden="true" />
</button>
);
}
interface AuthenticatedStatusProps extends StatusIconProps {
isAuthenticated: boolean;
}
function AuthenticatedStatusIcon({
serverName,
onConfigClick,
isAuthenticated,
}: AuthenticatedStatusProps) {
return (
<button
type="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
>
<SettingsIcon
className={`h-4 w-4 ${isAuthenticated ? 'text-green-500' : 'text-gray-400'}`}
aria-hidden="true"
/>
</button>
);
}
interface ConnectedStatusProps {
serverName: string;
requiresOAuth?: boolean;
onConfigClick: (e: React.MouseEvent) => void;
}
function ConnectedStatusIcon({ serverName, requiresOAuth, onConfigClick }: ConnectedStatusProps) {
if (requiresOAuth) {
return (
<TooltipAnchor
role="button"
onClick={onConfigClick}
className="flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-surface-secondary"
aria-label={localize('com_nav_mcp_configure_server', { 0: serverName })}
description={localize('com_nav_mcp_status_connected')}
side="top"
>
<CircleCheck className="h-4 w-4 text-green-500" />
</TooltipAnchor>
);
}
return (
<TooltipAnchor
className="flex h-6 w-6 items-center justify-center rounded p-1"
description={localize('com_nav_mcp_status_connected')}
side="top"
>
<CircleCheck className="h-4 w-4 text-green-500" />
</TooltipAnchor>
);
}

View file

@ -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 (
<MCPIcon
aria-hidden="true"
className={cn('flex-shrink-0 text-text-primary', sizeConfig.md.icon)}
/>
);
}
const sizes = sizeConfig[iconSize];
const colors = variantConfig[variant];
return (
<div className="flex items-center" aria-hidden="true">
{icons.map((icon, index) => (
<div
key={icon.key}
title={icon.displayName}
className={cn(
'relative flex items-center justify-center rounded-full border',
colors.border,
colors.bg,
sizes.container,
index > 0 && sizes.overlap,
)}
style={{ zIndex: icons.length - index }}
>
{icon.iconPath ? (
<img
src={icon.iconPath}
alt={icon.displayName}
className={cn('rounded-full object-cover', sizes.icon)}
/>
) : (
<MCPIcon className={cn('text-text-primary', sizes.icon)} />
)}
</div>
))}
{overflowCount > 0 && (
<div
className={cn(
'relative flex items-center justify-center rounded-full border border-surface-primary bg-surface-tertiary text-xs font-medium text-text-secondary',
sizes.container,
sizes.overlap,
)}
style={{ zIndex: 0 }}
>
+{overflowCount}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,196 @@
import type { MCPServerStatus } from 'librechat-data-provider';
import type { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
import type { MCPServerStatusIconProps } from './MCPServerStatusIcon';
export type { MCPServerStatus };
export interface SelectedIconInfo {
key: string;
serverName: string;
iconPath: string | null;
displayName: string;
}
export type ConnectionStatusMap = Record<string, MCPServerStatus>;
/**
* Generates a list of icons to display for selected MCP servers.
* - Custom icons are shown individually
* - Multiple default icons are consolidated into one
* - Limited to maxIcons with overflow count
*/
export function getSelectedServerIcons(
selectedServers: MCPServerDefinition[],
maxIcons: number = 3,
): { icons: SelectedIconInfo[]; overflowCount: number; defaultServerNames: string[] } {
const customIcons: SelectedIconInfo[] = [];
const defaultServerNames: string[] = [];
for (const server of selectedServers) {
const displayName = server.config?.title || server.serverName;
if (server.config?.iconPath) {
customIcons.push({
key: server.serverName,
serverName: server.serverName,
iconPath: server.config.iconPath,
displayName,
});
} else {
defaultServerNames.push(server.serverName);
}
}
// Add one default icon entry if any server uses default icon
// Custom icons are prioritized first, default icon comes last
const allIcons: SelectedIconInfo[] =
defaultServerNames.length > 0
? [
...customIcons,
{
key: '_default_',
serverName: defaultServerNames[0],
iconPath: null,
displayName: 'MCP',
},
]
: customIcons;
const visibleIcons = allIcons.slice(0, maxIcons);
const overflowCount = Math.max(0, allIcons.length - maxIcons);
return { icons: visibleIcons, overflowCount, defaultServerNames };
}
/**
* Unified status color system following UX best practices:
* - Green: Connected/Active (success)
* - Blue: Connecting/In-progress (processing)
* - Amber: Needs user action (OAuth required, config missing)
* - Gray: Disconnected/Inactive (neutral - server is simply off)
* - Red: Error (failed, needs retry)
*
* Key insight: "Disconnected" is neutral (gray), not a warning.
* Amber is reserved for states requiring user intervention.
*/
export function getStatusColor(
serverName: string,
connectionStatus?: ConnectionStatusMap,
isInitializing?: (serverName: string) => boolean,
): string {
// In-progress states: blue
if (isInitializing?.(serverName)) {
return 'bg-blue-500';
}
const status = connectionStatus?.[serverName];
if (!status) {
return 'bg-gray-400';
}
const { connectionState, requiresOAuth } = status;
// Connecting: blue (in progress)
if (connectionState === 'connecting') {
return 'bg-blue-500';
}
// Connected: green (success)
if (connectionState === 'connected') {
return 'bg-green-500';
}
// Error: red
if (connectionState === 'error') {
return 'bg-red-500';
}
// Disconnected: check if needs action or just inactive
if (connectionState === 'disconnected') {
// Needs OAuth = amber (requires user action)
if (requiresOAuth) {
return 'bg-amber-500';
}
// Simply disconnected = gray (neutral/inactive)
return 'bg-gray-400';
}
return 'bg-gray-400';
}
export function getStatusTextKey(
serverName: string,
connectionStatus?: ConnectionStatusMap,
isInitializing?: (serverName: string) => boolean,
): string {
if (isInitializing?.(serverName)) {
return 'com_nav_mcp_status_initializing';
}
const status = connectionStatus?.[serverName];
if (!status) {
return 'com_nav_mcp_status_unknown';
}
const { connectionState, requiresOAuth } = status;
// Special case: disconnected but needs OAuth shows different text
if (connectionState === 'disconnected' && requiresOAuth) {
return 'com_nav_mcp_status_needs_auth';
}
const keyMap: Record<string, string> = {
connected: 'com_nav_mcp_status_connected',
connecting: 'com_nav_mcp_status_connecting',
disconnected: 'com_nav_mcp_status_disconnected',
error: 'com_nav_mcp_status_error',
};
return keyMap[connectionState] || 'com_nav_mcp_status_unknown';
}
/**
* Determines if a server requires user action to connect.
* Used to show action buttons and amber status color.
*/
export function serverNeedsAction(
serverStatus?: MCPServerStatus,
_hasCustomUserVars?: boolean,
): boolean {
if (!serverStatus) return false;
const { connectionState, requiresOAuth } = serverStatus;
// Needs OAuth authentication
if (connectionState === 'disconnected' && requiresOAuth) return true;
// Has error - needs retry
if (connectionState === 'error') return true;
return false;
}
/**
* Determines if an action button should be shown for a server status.
* Returns true only when the button would be actionable (not just informational).
*/
export function shouldShowActionButton(statusIconProps?: MCPServerStatusIconProps | null): boolean {
if (!statusIconProps) return false;
const { serverStatus, canCancel, hasCustomUserVars, isInitializing } = statusIconProps;
// Show cancel button during OAuth flow
if (isInitializing && canCancel) return true;
// Don't show spinner-only state (no action available)
if (isInitializing) return false;
if (!serverStatus) return false;
const { connectionState, requiresOAuth } = serverStatus;
// Show for disconnected/error (can reconnect/configure)
if (connectionState === 'disconnected' || connectionState === 'error') return true;
// Don't show connecting spinner (no action)
if (connectionState === 'connecting') return false;
// Connected: only show if there's config to manage
if (connectionState === 'connected') return hasCustomUserVars || requiresOAuth;
return false;
}

View file

@ -31,12 +31,12 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
<div className="flex items-center gap-4">
<div
onClick={handleClick}
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-[1.5rem] border-2 border-dashed"
className="bg-token-surface-secondary dark:bg-token-surface-tertiary border-token-border-medium flex h-16 w-16 shrink-0 cursor-pointer items-center justify-center rounded-xl border-2 border-dashed"
>
{previewUrl ? (
<img
src={previewUrl}
className="h-full w-full rounded-[1.5rem] object-cover"
className="h-full w-full rounded-xl object-cover"
alt="MCP Icon"
width="64"
height="64"
@ -49,7 +49,7 @@ export default function MCPIcon({ icon, onIconChange }: MCPIconProps) {
<span className="token-text-secondary text-sm">
{localize('com_ui_icon')} {localize('com_ui_optional')}
</span>
<span className="token-text-tertiary text-xs">{localize('com_agents_mcp_icon_size')}</span>
<span className="text-xs text-text-secondary">{localize('com_agents_mcp_icon_size')}</span>
</div>
<input
accept="image/png,.png,image/jpeg,.jpg,.jpeg,image/gif,.gif,image/webp,.webp"

View file

@ -1,505 +0,0 @@
import { useState } from 'react';
import { FormProvider, useForm, useFormContext } from 'react-hook-form';
import * as RadioGroup from '@radix-ui/react-radio-group';
import { Copy, CopyCheck } from 'lucide-react';
import {
OGDialog,
OGDialogTrigger,
OGDialogTemplate,
Button,
useToastContext,
} from '@librechat/client';
import { TranslationKeys, useLocalize, useCopyToClipboard } from '~/hooks';
import { cn } from '~/utils';
enum AuthTypeEnum {
None = 'none',
ServiceHttp = 'service_http',
OAuth = 'oauth',
}
enum AuthorizationTypeEnum {
Basic = 'basic',
Bearer = 'bearer',
Custom = 'custom',
}
// Auth configuration type
export interface AuthConfig {
auth_type?: AuthTypeEnum;
api_key?: string;
api_key_source?: 'admin' | 'user'; // Whether admin provides key for all or each user provides their own
api_key_authorization_type?: AuthorizationTypeEnum;
api_key_custom_header?: string;
oauth_client_id?: string;
oauth_client_secret?: string;
oauth_authorization_url?: string;
oauth_token_url?: string;
oauth_scope?: string;
server_id?: string; // For edit mode redirect URI
}
// Export enums for parent components
export { AuthTypeEnum, AuthorizationTypeEnum };
/**
* Returns the appropriate localization key for authentication type
*/
function getAuthLocalizationKey(type: AuthTypeEnum): TranslationKeys {
switch (type) {
case AuthTypeEnum.ServiceHttp:
return 'com_ui_api_key';
case AuthTypeEnum.OAuth:
return 'com_ui_manual_oauth';
default:
return 'com_ui_auto_detect';
}
}
/**
* OAuth and API Key authentication dialog for MCP Server Builder
* Self-contained controlled component with its own form state
* Only updates parent on Save, discards changes on Cancel
*/
export default function MCPAuth({
value,
onChange,
}: {
value: AuthConfig;
onChange: (config: AuthConfig) => void;
}) {
const localize = useLocalize();
const [openAuthDialog, setOpenAuthDialog] = useState(false);
// Create local form with current value as default
const methods = useForm<AuthConfig>({
defaultValues: value,
});
const { handleSubmit, watch, reset } = methods;
const authType = watch('auth_type') || AuthTypeEnum.None;
const inputClasses = cn(
'mb-2 h-9 w-full resize-none overflow-y-auto rounded-lg border px-3 py-2 text-sm',
'border-border-medium bg-surface-primary outline-none',
'focus:ring-2 focus:ring-ring',
);
// Reset form when dialog opens with latest value from parent
const handleDialogOpen = (open: boolean) => {
if (open) {
reset(value);
}
setOpenAuthDialog(open);
};
// Save: update parent and close
const handleSave = handleSubmit((formData) => {
onChange(formData);
setOpenAuthDialog(false);
});
return (
<OGDialog open={openAuthDialog} onOpenChange={handleDialogOpen}>
<OGDialogTrigger asChild>
<div className="relative mb-4">
<div className="mb-1.5 flex items-center">
<label className="text-token-text-primary block font-medium">
{localize('com_ui_authentication')}
</label>
</div>
<div className="border-token-border-medium flex rounded-lg border text-sm hover:cursor-pointer">
<div className="h-9 grow px-3 py-2">{localize(getAuthLocalizationKey(authType))}</div>
<div className="bg-token-border-medium w-px"></div>
<button type="button" color="neutral" className="flex items-center gap-2 px-3">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
xmlns="http://www.w3.org/2000/svg"
className="icon-sm"
>
<path
d="M11.6439 3C10.9352 3 10.2794 3.37508 9.92002 3.98596L9.49644 4.70605C8.96184 5.61487 7.98938 6.17632 6.93501 6.18489L6.09967 6.19168C5.39096 6.19744 4.73823 6.57783 4.38386 7.19161L4.02776 7.80841C3.67339 8.42219 3.67032 9.17767 4.01969 9.7943L4.43151 10.5212C4.95127 11.4386 4.95127 12.5615 4.43151 13.4788L4.01969 14.2057C3.67032 14.8224 3.67339 15.5778 4.02776 16.1916L4.38386 16.8084C4.73823 17.4222 5.39096 17.8026 6.09966 17.8083L6.93502 17.8151C7.98939 17.8237 8.96185 18.3851 9.49645 19.294L9.92002 20.014C10.2794 20.6249 10.9352 21 11.6439 21H12.3561C13.0648 21 13.7206 20.6249 14.08 20.014L14.5035 19.294C15.0381 18.3851 16.0106 17.8237 17.065 17.8151L17.9004 17.8083C18.6091 17.8026 19.2618 17.4222 19.6162 16.8084L19.9723 16.1916C20.3267 15.5778 20.3298 14.8224 19.9804 14.2057L19.5686 13.4788C19.0488 12.5615 19.0488 11.4386 19.5686 10.5212L19.9804 9.7943C20.3298 9.17767 20.3267 8.42219 19.9723 7.80841L19.6162 7.19161C19.2618 6.57783 18.6091 6.19744 17.9004 6.19168L17.065 6.18489C16.0106 6.17632 15.0382 5.61487 14.5036 4.70605L14.08 3.98596C13.7206 3.37508 13.0648 3 12.3561 3H11.6439Z"
stroke="currentColor"
strokeWidth="2"
strokeLinejoin="round"
/>
<circle cx="12" cy="12" r="2.5" stroke="currentColor" strokeWidth="2" />
</svg>
</button>
</div>
</div>
</OGDialogTrigger>
<FormProvider {...methods}>
<OGDialogTemplate
title={localize('com_ui_authentication')}
showCloseButton={false}
className="w-full max-w-md"
main={
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_authentication_type')}
</label>
<RadioGroup.Root
defaultValue={AuthTypeEnum.None}
onValueChange={(value) =>
methods.setValue('auth_type', value as AuthConfig['auth_type'])
}
value={authType}
role="radiogroup"
aria-required="false"
dir="ltr"
className="flex gap-4"
style={{ outline: 'none' }}
>
<div className="flex items-center gap-2">
<label
htmlFor="auth-auto-detect"
className="flex cursor-pointer items-center gap-1"
>
<RadioGroup.Item
type="button"
role="radio"
value={AuthTypeEnum.None}
id="auth-auto-detect"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_auto_detect')}
</label>
</div>
<div className="flex items-center gap-2">
<label htmlFor="auth-apikey" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
type="button"
role="radio"
value={AuthTypeEnum.ServiceHttp}
id="auth-apikey"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_api_key')}
</label>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="auth-manual-oauth"
className="flex cursor-pointer items-center gap-1"
>
<RadioGroup.Item
type="button"
role="radio"
value={AuthTypeEnum.OAuth}
id="auth-manual-oauth"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_manual_oauth')}
</label>
</div>
</RadioGroup.Root>
</div>
{authType === AuthTypeEnum.None && (
<div className="rounded-lg border border-border-medium bg-surface-secondary p-3">
<p className="text-sm text-text-secondary">
{localize('com_ui_auto_detect_description')}
</p>
</div>
)}
{authType === AuthTypeEnum.ServiceHttp && <ApiKey inputClasses={inputClasses} />}
{authType === AuthTypeEnum.OAuth && <OAuth inputClasses={inputClasses} />}
</div>
}
buttons={
<Button type="button" variant="submit" onClick={handleSave} className="text-white">
{localize('com_ui_save')}
</Button>
}
/>
</FormProvider>
</OGDialog>
);
}
const ApiKey = ({ inputClasses }: { inputClasses: string }) => {
const localize = useLocalize();
const { register, watch, setValue } = useFormContext();
const api_key_source = watch('api_key_source') || 'admin';
const authorization_type = watch('api_key_authorization_type') || AuthorizationTypeEnum.Bearer;
return (
<>
{/* API Key Source selection */}
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key_source')}</label>
<RadioGroup.Root
defaultValue="admin"
onValueChange={(value) => setValue('api_key_source', value)}
value={api_key_source}
role="radiogroup"
aria-required="true"
dir="ltr"
className="mb-3 flex flex-col gap-2"
style={{ outline: 'none' }}
>
<div className="flex items-center gap-2">
<label htmlFor="source-admin" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
type="button"
role="radio"
value="admin"
id="source-admin"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_admin_provides_key')}
</label>
</div>
<div className="flex items-center gap-2">
<label htmlFor="source-user" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
type="button"
role="radio"
value="user"
id="source-user"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_user_provides_key')}
</label>
</div>
</RadioGroup.Root>
{/* API Key input - only show for admin-provided mode */}
{api_key_source === 'admin' && (
<>
<label className="mb-1 block text-sm font-medium">{localize('com_ui_api_key')}</label>
<input
placeholder="<HIDDEN>"
type="password"
autoComplete="new-password"
className={inputClasses}
{...register('api_key')}
/>
</>
)}
{/* User-provided mode info */}
{api_key_source === 'user' && (
<div className="mb-3 rounded-lg border border-border-medium bg-surface-secondary p-3">
<p className="text-sm text-text-secondary">{localize('com_ui_user_provides_key_note')}</p>
</div>
)}
{/* Header Format selection - shown for both modes */}
<label className="mb-1 block text-sm font-medium">{localize('com_ui_header_format')}</label>
<RadioGroup.Root
defaultValue={AuthorizationTypeEnum.Bearer}
onValueChange={(value) => setValue('api_key_authorization_type', value)}
value={authorization_type}
role="radiogroup"
aria-required="true"
dir="ltr"
className="mb-2 flex gap-6 overflow-hidden rounded-lg"
style={{ outline: 'none' }}
>
<div className="flex items-center gap-2">
<label htmlFor="auth-bearer" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
type="button"
role="radio"
value={AuthorizationTypeEnum.Bearer}
id="auth-bearer"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_bearer')}
</label>
</div>
<div className="flex items-center gap-2">
<label htmlFor="auth-basic" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
type="button"
role="radio"
value={AuthorizationTypeEnum.Basic}
id="auth-basic"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_basic')}
</label>
</div>
<div className="flex items-center gap-2">
<label htmlFor="auth-custom" className="flex cursor-pointer items-center gap-1">
<RadioGroup.Item
type="button"
role="radio"
value={AuthorizationTypeEnum.Custom}
id="auth-custom"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_custom')}
</label>
</div>
</RadioGroup.Root>
{authorization_type === AuthorizationTypeEnum.Custom && (
<div className="mt-2">
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_custom_header_name')}
</label>
<input
className={inputClasses}
placeholder="X-Api-Key"
{...register('api_key_custom_header')}
/>
</div>
)}
</>
);
};
const OAuth = ({ inputClasses }: { inputClasses: string }) => {
const localize = useLocalize();
const { showToast } = useToastContext();
const { register, watch, formState } = useFormContext();
const [isCopying, setIsCopying] = useState(false);
const { errors } = formState;
// Check if we're in edit mode (server exists with ID)
const serverId = watch('server_id');
const isEditMode = !!serverId;
// Calculate redirect URI for edit mode
const redirectUri = isEditMode
? `${window.location.origin}/api/mcp/${serverId}/oauth/callback`
: '';
const copyLink = useCopyToClipboard({ text: redirectUri });
return (
<>
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_client_id')} {!isEditMode && <span className="text-red-500">*</span>}
</label>
<input
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
autoComplete="off"
className={inputClasses}
{...register('oauth_client_id', { required: !isEditMode })}
/>
{errors.oauth_client_id && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_client_secret')} {!isEditMode && <span className="text-red-500">*</span>}
</label>
<input
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
type="password"
autoComplete="new-password"
className={inputClasses}
{...register('oauth_client_secret', { required: !isEditMode })}
/>
{errors.oauth_client_secret && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_auth_url')} <span className="text-red-500">*</span>
</label>
<input
className={inputClasses}
{...register('oauth_authorization_url', { required: true })}
/>
{errors.oauth_authorization_url && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
<label className="mb-1 block text-sm font-medium">
{localize('com_ui_token_url')} <span className="text-red-500">*</span>
</label>
<input className={inputClasses} {...register('oauth_token_url', { required: true })} />
{errors.oauth_token_url && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
{/* Redirect URI - read-only in edit mode, info message in create mode */}
<label className="mb-1 block text-sm font-medium">{localize('com_ui_redirect_uri')}</label>
{isEditMode ? (
<div className="relative mb-2 flex items-center">
<div className="border-token-border-medium bg-token-surface-primary hover:border-token-border-hover flex h-10 w-full rounded-lg border">
<div className="flex-1 overflow-hidden">
<div className="relative w-full">
<input
type="text"
readOnly
value={redirectUri}
className="w-full border-0 bg-transparent px-3 py-2 pr-12 text-sm text-text-secondary-alt focus:outline-none"
style={{ direction: 'rtl' }}
/>
</div>
</div>
<div className="absolute right-0 flex h-full items-center pr-1">
<Button
size="sm"
variant="ghost"
type="button"
onClick={() => {
if (isCopying) {
return;
}
showToast({ message: localize('com_ui_copied_to_clipboard') });
copyLink(setIsCopying);
}}
className={cn('h-8 rounded-md px-2', isCopying ? 'cursor-default' : '')}
aria-label={localize('com_ui_copy_link')}
>
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
</Button>
</div>
</div>
</div>
) : (
<div className="mb-2 rounded-lg border border-border-medium bg-surface-secondary p-2">
<p className="text-xs text-text-secondary">{localize('com_ui_redirect_uri_info')}</p>
</div>
)}
<label className="mb-1 block text-sm font-medium">{localize('com_ui_scope')}</label>
<input className={inputClasses} {...register('oauth_scope')} />
</>
);
};

View file

@ -1,12 +1,12 @@
import { useState, useRef, useMemo } from 'react';
import { Plus } from 'lucide-react';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import { Button, Spinner, FilterInput, OGDialogTrigger } from '@librechat/client';
import { Button, Spinner, FilterInput, OGDialogTrigger, TooltipAnchor } from '@librechat/client';
import { useLocalize, useMCPServerManager, useHasAccess } from '~/hooks';
import MCPServerList from './MCPServerList';
import MCPServerDialog from './MCPServerDialog';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import MCPAdminSettings from './MCPAdminSettings';
import MCPServerDialog from './MCPServerDialog';
import MCPServerList from './MCPServerList';
export default function MCPBuilderPanel() {
const localize = useLocalize();
@ -37,40 +37,48 @@ export default function MCPBuilderPanel() {
return (
<div className="flex h-full w-full flex-col overflow-visible">
<div role="region" aria-label="MCP Builder" className="mt-2 space-y-2">
{/* Admin Settings Button */}
<MCPAdminSettings />
<div role="region" aria-label={localize('com_ui_mcp_servers')} className="mt-2 space-y-2">
{/* Toolbar: Search + Add Button */}
<div className="flex items-center gap-2">
<FilterInput
inputId="mcp-filter"
label={localize('com_ui_filter_mcp_servers')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
containerClassName="flex-1"
/>
{hasCreateAccess && (
<MCPServerDialog
open={showDialog}
onOpenChange={setShowDialog}
triggerRef={addButtonRef}
>
<OGDialogTrigger asChild>
<TooltipAnchor
description={localize('com_ui_add_mcp')}
side="bottom"
render={
<Button
ref={addButtonRef}
variant="outline"
size="icon"
className="shrink-0 bg-transparent"
onClick={() => setShowDialog(true)}
aria-label={localize('com_ui_add_mcp')}
>
<Plus className="size-4" aria-hidden="true" />
</Button>
}
/>
</OGDialogTrigger>
</MCPServerDialog>
)}
</div>
{/* Search Input */}
<FilterInput
inputId="mcp-filter"
label={localize('com_ui_filter_mcp_servers')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
/>
{hasCreateAccess && (
<MCPServerDialog open={showDialog} onOpenChange={setShowDialog} triggerRef={addButtonRef}>
<OGDialogTrigger asChild>
<div className="flex w-full justify-end">
<Button
ref={addButtonRef}
variant="outline"
className="w-full bg-transparent"
onClick={() => setShowDialog(true)}
>
<Plus className="size-4" aria-hidden />
{localize('com_ui_add_mcp')}
</Button>
</div>
</OGDialogTrigger>
</MCPServerDialog>
)}
{/* Server List */}
{/* Server Cards List */}
{isLoading ? (
<div className="flex items-center justify-center p-8">
<Spinner className="h-6 w-6" />
<Spinner className="size-6" aria-label={localize('com_ui_loading')} />
</div>
) : (
<MCPServerList
@ -79,7 +87,12 @@ export default function MCPBuilderPanel() {
isFiltered={searchQuery.trim().length > 0}
/>
)}
{/* Config Dialog for custom user vars */}
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
{/* Admin Settings Section */}
<MCPAdminSettings />
</div>
</div>
);

View file

@ -0,0 +1,162 @@
import { Pencil, PlugZap, SlidersHorizontal, RefreshCw, X } from 'lucide-react';
import { Spinner, TooltipAnchor } from '@librechat/client';
import type { MCPServerStatus } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPCardActionsProps {
serverName: string;
serverStatus?: MCPServerStatus;
isInitializing: boolean;
canCancel: boolean;
hasCustomUserVars: boolean;
canEdit: boolean;
onEditClick: (e: React.MouseEvent) => void;
onConfigClick: (e: React.MouseEvent) => void;
onInitialize: () => void;
onCancel: (e: React.MouseEvent) => void;
}
/**
* Standardized action buttons for MCP server cards.
*
* Unified icon system (each icon has ONE meaning):
* - Pencil: Edit server definition (Settings panel only)
* - PlugZap: Connect/Authenticate (for disconnected/error servers)
* - SlidersHorizontal: Configure custom variables (for connected servers with vars)
* - RefreshCw: Reconnect/Refresh (for connected servers)
* - Spinner: Loading state (with X on hover for cancel)
*/
export default function MCPCardActions({
serverName,
serverStatus,
isInitializing,
canCancel,
hasCustomUserVars,
canEdit,
onEditClick,
onConfigClick,
onInitialize,
onCancel,
}: MCPCardActionsProps) {
const localize = useLocalize();
const connectionState = serverStatus?.connectionState;
const isConnected = connectionState === 'connected';
const isConnecting = connectionState === 'connecting';
const isDisconnected = connectionState === 'disconnected';
const isError = connectionState === 'error';
const buttonBaseClass = cn(
'flex size-7 items-center justify-center rounded-md',
'transition-colors duration-150',
'text-text-secondary hover:text-text-primary',
'hover:bg-surface-tertiary',
'focus:outline-none focus-visible:ring-2 focus-visible:ring-border-heavy',
);
// Loading state - show spinner (with cancel option)
if (isInitializing || isConnecting) {
return (
<div className="flex items-center gap-0.5">
{/* Edit button stays visible during loading */}
{canEdit && (
<TooltipAnchor
description={localize('com_ui_edit')}
side="top"
className={buttonBaseClass}
aria-label={localize('com_ui_edit')}
role="button"
onClick={onEditClick}
>
<Pencil className="size-3.5" aria-hidden="true" />
</TooltipAnchor>
)}
{/* Spinner with cancel on hover */}
{canCancel ? (
<TooltipAnchor
description={localize('com_ui_cancel')}
side="top"
className={cn(buttonBaseClass, 'group')}
aria-label={localize('com_ui_cancel')}
role="button"
onClick={onCancel}
>
<div className="relative size-4">
<Spinner className="size-4 group-hover:opacity-0" />
<X className="absolute inset-0 size-4 text-red-500 opacity-0 group-hover:opacity-100" />
</div>
</TooltipAnchor>
) : (
<div className={cn(buttonBaseClass, 'cursor-default hover:bg-transparent')}>
<Spinner
className="size-4"
aria-label={localize('com_nav_mcp_status_connecting', { 0: serverName })}
/>
</div>
)}
</div>
);
}
return (
<div className="flex items-center gap-0.5">
{/* Edit button - opens MCPServerDialog to edit server definition */}
{canEdit && (
<TooltipAnchor
description={localize('com_ui_edit')}
side="top"
className={buttonBaseClass}
aria-label={localize('com_ui_edit')}
role="button"
onClick={onEditClick}
>
<Pencil className="size-3.5" aria-hidden="true" />
</TooltipAnchor>
)}
{/* Connect button - for disconnected or error states */}
{(isDisconnected || isError) && (
<TooltipAnchor
description={localize('com_nav_mcp_connect')}
side="top"
className={buttonBaseClass}
aria-label={localize('com_nav_mcp_connect')}
role="button"
onClick={() => onInitialize()}
>
<PlugZap className="size-4" aria-hidden="true" />
</TooltipAnchor>
)}
{/* Configure button - for connected servers with custom vars */}
{isConnected && hasCustomUserVars && (
<TooltipAnchor
description={localize('com_ui_configure')}
side="top"
className={buttonBaseClass}
aria-label={localize('com_ui_configure')}
role="button"
onClick={onConfigClick}
>
<SlidersHorizontal className="size-3.5" aria-hidden="true" />
</TooltipAnchor>
)}
{/* Refresh button - for connected servers (allows reconnection) */}
{isConnected && (
<TooltipAnchor
description={localize('com_nav_mcp_reconnect')}
side="top"
className={buttonBaseClass}
aria-label={localize('com_nav_mcp_reconnect')}
role="button"
onClick={() => onInitialize()}
>
<RefreshCw className="size-3.5" aria-hidden="true" />
</TooltipAnchor>
)}
</div>
);
}

View file

@ -0,0 +1,152 @@
import { useState, useRef } from 'react';
import { MCPIcon } from '@librechat/client';
import { PermissionBits, hasPermissions } from 'librechat-data-provider';
import type { MCPServerStatusIconProps } from '~/components/MCP/MCPServerStatusIcon';
import type { MCPServerDefinition } from '~/hooks';
import MCPServerDialog from './MCPServerDialog';
import { getStatusDotColor } from './MCPStatusBadge';
import MCPCardActions from './MCPCardActions';
import { useMCPServerManager, useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPServerCardProps {
server: MCPServerDefinition;
getServerStatusIconProps: (serverName: string) => MCPServerStatusIconProps;
canCreateEditMCPs: boolean;
}
/**
* Compact card component for displaying an MCP server with status and actions.
*
* Visual design:
* - Status shown via colored dot on icon (no separate badge - avoids redundancy)
* - Action buttons clearly indicate available operations
* - Consistent with MCPServerMenuItem in chat dropdown
*/
export default function MCPServerCard({
server,
getServerStatusIconProps,
canCreateEditMCPs,
}: MCPServerCardProps) {
const localize = useLocalize();
const { initializeServer } = useMCPServerManager();
const [dialogOpen, setDialogOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
const statusIconProps = getServerStatusIconProps(server.serverName);
const {
serverStatus,
onConfigClick,
isInitializing,
canCancel,
onCancel,
hasCustomUserVars = false,
} = statusIconProps;
const canEditThisServer = hasPermissions(server.effectivePermissions, PermissionBits.EDIT);
const displayName = server.config?.title || server.serverName;
const description = server.config?.description;
const statusDotColor = getStatusDotColor(serverStatus, isInitializing);
const canEdit = canCreateEditMCPs && canEditThisServer;
const handleInitialize = () => {
initializeServer(server.serverName);
};
const handleEditClick = (e: React.MouseEvent) => {
e.stopPropagation();
e.preventDefault();
setDialogOpen(true);
};
// Determine status text for accessibility
const getStatusText = () => {
if (isInitializing) return localize('com_nav_mcp_status_initializing');
if (!serverStatus) return localize('com_nav_mcp_status_unknown');
const { connectionState, requiresOAuth } = serverStatus;
if (connectionState === 'connected') return localize('com_nav_mcp_status_connected');
if (connectionState === 'connecting') return localize('com_nav_mcp_status_connecting');
if (connectionState === 'error') return localize('com_nav_mcp_status_error');
if (connectionState === 'disconnected') {
return requiresOAuth
? localize('com_nav_mcp_status_needs_auth')
: localize('com_nav_mcp_status_disconnected');
}
return localize('com_nav_mcp_status_unknown');
};
return (
<>
<div
className={cn(
'group flex items-center gap-3 rounded-lg px-3 py-2.5',
'border border-border-light bg-transparent',
)}
aria-label={`${displayName} - ${getStatusText()}`}
>
{/* Server Icon with Status Dot */}
<div className="relative flex-shrink-0">
{server.config?.iconPath ? (
<img
src={server.config.iconPath}
className="size-8 rounded-lg object-cover"
alt=""
aria-hidden="true"
/>
) : (
<div className="flex size-8 items-center justify-center rounded-lg bg-surface-tertiary">
<MCPIcon className="size-5 text-text-secondary" aria-hidden="true" />
</div>
)}
{/* Status dot - color indicates connection state */}
<div
className={cn(
'absolute -bottom-0.5 -right-0.5 size-3 rounded-full',
'border-2 border-surface-primary',
statusDotColor,
(isInitializing || serverStatus?.connectionState === 'connecting') && 'animate-pulse',
)}
aria-hidden="true"
/>
</div>
{/* Server Info */}
<div className="min-w-0 flex-1">
<span className="truncate text-sm font-medium text-text-primary">{displayName}</span>
{description && <p className="truncate text-xs text-text-secondary">{description}</p>}
</div>
{/* Actions */}
<div className="flex-shrink-0">
<MCPCardActions
serverName={server.serverName}
serverStatus={serverStatus}
isInitializing={isInitializing}
canCancel={canCancel}
hasCustomUserVars={hasCustomUserVars}
canEdit={canEdit}
onEditClick={handleEditClick}
onConfigClick={onConfigClick}
onInitialize={handleInitialize}
onCancel={onCancel}
/>
</div>
</div>
{/* Edit Dialog - separate from card */}
{canEdit && (
<MCPServerDialog
open={dialogOpen}
onOpenChange={setDialogOpen}
triggerRef={triggerRef}
server={server}
>
{/* Hidden trigger for focus management */}
<button ref={triggerRef} className="sr-only" tabIndex={-1} aria-hidden="true">
{localize('com_ui_edit')} {displayName}
</button>
</MCPServerDialog>
)}
</>
);
}

View file

@ -1,694 +0,0 @@
import React, { useState, useEffect, useMemo } from 'react';
import { FormProvider, useForm, Controller } from 'react-hook-form';
import * as RadioGroup from '@radix-ui/react-radio-group';
import type { MCPServerCreateParams } from 'librechat-data-provider';
import {
OGDialog,
OGDialogTemplate,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
TrashIcon,
Button,
Label,
Checkbox,
Spinner,
useToastContext,
} from '@librechat/client';
import {
useCreateMCPServerMutation,
useUpdateMCPServerMutation,
useDeleteMCPServerMutation,
} from '~/data-provider/MCP';
import MCPAuth, { type AuthConfig, AuthTypeEnum, AuthorizationTypeEnum } from './MCPAuth';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import { useLocalize, useLocalizedConfig } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import { cn } from '~/utils';
import {
SystemRoles,
Permissions,
ResourceType,
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import { GenericGrantAccessDialog } from '~/components/Sharing';
import { useAuthContext, useHasAccess, useResourcePermissions, MCPServerDefinition } from '~/hooks';
// Form data with nested auth structure matching AuthConfig
interface MCPServerFormData {
// Server metadata
title: string;
description?: string;
icon?: string;
// Connection details
url: string;
type: 'streamable-http' | 'sse';
// Nested auth configuration (matches AuthConfig directly)
auth: AuthConfig;
// UI-only validation
trust: boolean;
}
interface MCPServerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
server?: MCPServerDefinition | null;
}
export default function MCPServerDialog({
open,
onOpenChange,
children,
triggerRef,
server,
}: MCPServerDialogProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const { data: startupConfig } = useGetStartupConfig();
const getLocalizedValue = useLocalizedConfig();
// Mutations
const createMutation = useCreateMCPServerMutation();
const updateMutation = useUpdateMCPServerMutation();
const deleteMutation = useDeleteMCPServerMutation();
// Convert McpServer to form data
const defaultValues = useMemo<MCPServerFormData>(() => {
if (server) {
// Determine auth type from server config
let authType: AuthTypeEnum = AuthTypeEnum.None;
if (server.config.oauth) {
authType = AuthTypeEnum.OAuth;
} else if ('apiKey' in server.config && server.config.apiKey) {
authType = AuthTypeEnum.ServiceHttp;
}
// Extract apiKey config if present
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
return {
title: server.config.title || '',
description: server.config.description || '',
url: 'url' in server.config ? server.config.url : '',
type: (server.config.type as 'streamable-http' | 'sse') || 'streamable-http',
icon: server.config.iconPath || '',
auth: {
auth_type: authType,
api_key: '', // NEVER pre-fill secrets
api_key_source: (apiKeyConfig?.source as 'admin' | 'user') || 'admin',
api_key_authorization_type:
(apiKeyConfig?.authorization_type as AuthorizationTypeEnum) ||
AuthorizationTypeEnum.Bearer,
api_key_custom_header: apiKeyConfig?.custom_header || '',
oauth_client_id: server.config.oauth?.client_id || '',
oauth_client_secret: '', // NEVER pre-fill secrets
oauth_authorization_url: server.config.oauth?.authorization_url || '',
oauth_token_url: server.config.oauth?.token_url || '',
oauth_scope: server.config.oauth?.scope || '',
server_id: server.serverName, // For edit mode redirect URI
},
trust: true, // Pre-check for existing servers
};
}
return {
title: '',
description: '',
url: '',
type: 'streamable-http',
icon: '',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: false,
};
}, [server]);
const methods = useForm<MCPServerFormData>({
defaultValues,
});
const {
handleSubmit,
register,
formState: { errors },
control,
watch,
reset,
} = methods;
const iconValue = watch('icon');
const [isSubmitting, setIsSubmitting] = useState(false);
const [showRedirectUriDialog, setShowRedirectUriDialog] = useState(false);
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Reset form when dialog opens or server changes
useEffect(() => {
if (open) {
reset(defaultValues);
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- defaultValues is derived from server
}, [open, server, reset]);
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
methods.setValue('icon', base64String);
};
reader.readAsDataURL(file);
}
};
const handleDelete = async () => {
if (!server) {
return;
}
setIsDeleting(true);
try {
await deleteMutation.mutateAsync(server.serverName);
showToast({
message: localize('com_ui_mcp_server_deleted'),
status: 'success',
});
setShowDeleteConfirm(false);
onOpenChange(false);
setTimeout(() => {
triggerRef?.current?.focus();
}, 0);
} catch (error: any) {
let errorMessage = localize('com_ui_error');
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as any;
if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
} else if (error.message) {
errorMessage = error.message;
}
showToast({
message: errorMessage,
status: 'error',
});
} finally {
setIsDeleting(false);
}
};
const onSubmit = handleSubmit(async (formData: MCPServerFormData) => {
setIsSubmitting(true);
try {
// Convert form data to API params - everything goes in config now
const config: any = {
type: formData.type,
url: formData.url,
title: formData.title,
...(formData.description && { description: formData.description }),
...(formData.icon && { iconPath: formData.icon }),
};
// Add OAuth if auth type is oauth and any fields are filled
if (
formData.auth.auth_type === AuthTypeEnum.OAuth &&
(formData.auth.oauth_client_id ||
formData.auth.oauth_client_secret ||
formData.auth.oauth_authorization_url ||
formData.auth.oauth_token_url ||
formData.auth.oauth_scope)
) {
config.oauth = {};
if (formData.auth.oauth_client_id) {
config.oauth.client_id = formData.auth.oauth_client_id;
}
if (formData.auth.oauth_client_secret) {
config.oauth.client_secret = formData.auth.oauth_client_secret;
}
if (formData.auth.oauth_authorization_url) {
config.oauth.authorization_url = formData.auth.oauth_authorization_url;
}
if (formData.auth.oauth_token_url) {
config.oauth.token_url = formData.auth.oauth_token_url;
}
if (formData.auth.oauth_scope) {
config.oauth.scope = formData.auth.oauth_scope;
}
}
// Add API Key if auth type is service_http
if (formData.auth.auth_type === AuthTypeEnum.ServiceHttp) {
const source = formData.auth.api_key_source || 'admin';
const authorizationType = formData.auth.api_key_authorization_type || 'bearer';
config.apiKey = {
source,
authorization_type: authorizationType,
...(source === 'admin' && formData.auth.api_key && { key: formData.auth.api_key }),
...(authorizationType === 'custom' &&
formData.auth.api_key_custom_header && {
custom_header: formData.auth.api_key_custom_header,
}),
};
}
const params: MCPServerCreateParams = {
config,
};
// Call mutation based on create vs edit mode
const result = server
? await updateMutation.mutateAsync({ serverName: server.serverName, data: params })
: await createMutation.mutateAsync(params);
showToast({
message: server
? localize('com_ui_mcp_server_updated')
: localize('com_ui_mcp_server_created'),
status: 'success',
});
// Show redirect URI dialog only on creation with OAuth
if (!server && formData.auth.auth_type === AuthTypeEnum.OAuth) {
setCreatedServerId(result.serverName);
setShowRedirectUriDialog(true);
} else {
onOpenChange(false);
}
setTimeout(() => {
triggerRef?.current?.focus();
}, 0);
} catch (error: any) {
let errorMessage = localize('com_ui_error');
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as any;
if (axiosError.response?.data?.error === 'MCP_INSPECTION_FAILED') {
errorMessage = localize('com_ui_mcp_server_connection_failed');
} else if (axiosError.response?.data?.error === 'MCP_DOMAIN_NOT_ALLOWED') {
errorMessage = localize('com_ui_mcp_domain_not_allowed');
} else if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
} else if (error.message) {
errorMessage = error.message;
}
showToast({
message: errorMessage,
status: 'error',
});
} finally {
setIsSubmitting(false);
}
});
const { user } = useAuthContext();
// Check global permission to share MCP servers
const hasAccessToShareMcpServers = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.SHARE,
});
// Check user's permissions on this specific MCP server
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
ResourceType.MCPSERVER,
server?.dbId || '',
);
const canShareThisServer = hasPermission(PermissionBits.SHARE);
const shouldShowShareButton =
server && // Only in edit mode
(user?.role === SystemRoles.ADMIN || canShareThisServer) &&
hasAccessToShareMcpServers &&
!permissionsLoading;
const redirectUri = createdServerId
? `${window.location.origin}/api/mcp/${createdServerId}/oauth/callback`
: '';
return (
<>
{/* Delete confirmation dialog */}
<OGDialog
open={showDeleteConfirm}
onOpenChange={(open) => {
setShowDeleteConfirm(open);
}}
>
<OGDialogTemplate
title={localize('com_ui_delete')}
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_mcp_server_delete_confirm')}
</Label>
}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isDeleting ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
{/* Post-creation redirect URI dialog */}
<OGDialog
open={showRedirectUriDialog}
onOpenChange={(open) => {
setShowRedirectUriDialog(open);
if (!open) {
onOpenChange(false);
setCreatedServerId(null);
}
}}
>
<OGDialogContent className="w-full max-w-lg border-none bg-surface-primary text-text-primary">
<OGDialogHeader className="border-b border-border-light sm:p-3">
<OGDialogTitle>{localize('com_ui_mcp_server_created')}</OGDialogTitle>
</OGDialogHeader>
<div className="p-4 sm:p-6 sm:pt-4">
<p className="mb-4 text-sm text-text-primary">
{localize('com_ui_redirect_uri_instructions')}
</p>
<div className="rounded-lg border border-border-medium bg-surface-secondary p-3">
<label className="mb-2 block text-xs font-medium text-text-secondary">
{localize('com_ui_redirect_uri')}
</label>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-border-medium bg-surface-primary px-3 py-2 text-sm"
value={redirectUri}
readOnly
/>
<Button
onClick={() => {
navigator.clipboard.writeText(redirectUri);
showToast({
message: localize('com_ui_copied'),
status: 'success',
});
}}
variant="outline"
className="whitespace-nowrap"
>
{localize('com_ui_copy_link')}
</Button>
</div>
</div>
<div className="mt-6 flex justify-end">
<Button
onClick={() => {
setShowRedirectUriDialog(false);
onOpenChange(false);
setCreatedServerId(null);
}}
variant="submit"
className="text-white"
>
{localize('com_ui_done')}
</Button>
</div>
</div>
</OGDialogContent>
</OGDialog>
{/* Main MCP Server Dialog */}
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
title={server ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')}
description={
server
? localize('com_ui_edit_mcp_server_dialog_description', {
serverName: server.serverName,
})
: undefined
}
className="w-11/12 md:max-w-2xl"
main={
<FormProvider {...methods}>
<div className="max-h-[60vh] space-y-4 overflow-y-auto px-1">
{/* Icon Picker */}
<div>
<MCPIcon icon={iconValue} onIconChange={handleIconChange} />
</div>
{/* Title */}
<div className="space-y-2">
<Label htmlFor="title" className="text-sm font-medium">
{localize('com_ui_name')} <span className="text-red-500">*</span>
</Label>
<input
autoComplete="off"
{...register('title', {
required: true,
pattern: {
value: /^[a-zA-Z0-9 ]+$/,
message: localize('com_ui_mcp_title_invalid'),
},
})}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={localize('com_agents_mcp_name_placeholder')}
/>
{errors.title && (
<span className="text-xs text-red-500">
{errors.title.type === 'pattern'
? errors.title.message
: localize('com_ui_field_required')}
</span>
)}
</div>
{/* Description */}
<div className="space-y-2">
<Label htmlFor="description" className="text-sm font-medium">
{localize('com_ui_description')}
<span className="ml-1 text-xs text-text-secondary-alt">
{localize('com_ui_optional')}
</span>
</Label>
<input
id="description"
{...register('description')}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder={localize('com_agents_mcp_description_placeholder')}
/>
</div>
{/* URL */}
<div className="space-y-2">
<Label htmlFor="url" className="text-sm font-medium">
{localize('com_ui_mcp_url')} <span className="text-red-500">*</span>
</Label>
<input
id="url"
{...register('url', {
required: true,
})}
className="border-token-border-medium flex h-9 w-full rounded-lg border bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-text-secondary-alt focus:ring-1 focus:ring-border-light"
placeholder="https://mcp.example.com"
/>
{errors.url && (
<span className="text-xs text-red-500">
{errors.url.type === 'required'
? localize('com_ui_field_required')
: errors.url.message}
</span>
)}
</div>
{/* Server Type */}
<div className="space-y-2">
<Label htmlFor="type" className="text-sm font-medium">
{localize('com_ui_mcp_server_type')}
</Label>
<Controller
name="type"
control={control}
render={({ field }) => (
<RadioGroup.Root
value={field.value}
onValueChange={field.onChange}
className="flex gap-4"
>
<div className="flex items-center gap-2">
<label
htmlFor="type-streamable-http"
className="flex cursor-pointer items-center gap-1"
>
<RadioGroup.Item
type="button"
value="streamable-http"
id="type-streamable-http"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_mcp_type_streamable_http')}
</label>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="type-sse"
className="flex cursor-pointer items-center gap-1"
>
<RadioGroup.Item
type="button"
value="sse"
id="type-sse"
className={cn(
'mr-1 flex h-5 w-5 items-center justify-center rounded-full border',
'border-border-heavy bg-surface-primary',
)}
>
<RadioGroup.Indicator className="h-2 w-2 rounded-full bg-text-primary" />
</RadioGroup.Item>
{localize('com_ui_mcp_type_sse')}
</label>
</div>
</RadioGroup.Root>
)}
/>
</div>
{/* Authentication */}
<Controller
name="auth"
control={control}
render={({ field }) => <MCPAuth value={field.value} onChange={field.onChange} />}
/>
{/* Trust Checkbox */}
<div className="flex items-center gap-2">
<Controller
name="trust"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox
id="trust"
checked={field.value}
onCheckedChange={field.onChange}
aria-labelledby="trust-this-mcp-label"
/>
)}
/>
<Label
id="trust-this-mcp-label"
htmlFor="trust"
className="flex cursor-pointer flex-col break-words text-sm font-medium"
>
<span>
{startupConfig?.interface?.mcpServers?.trustCheckbox?.label ? (
<span
/** No sanitization required. trusted admin-controlled source (yml) */
dangerouslySetInnerHTML={{
__html: getLocalizedValue(
startupConfig.interface.mcpServers.trustCheckbox.label,
localize('com_ui_trust_app'),
),
}}
/>
) : (
localize('com_ui_trust_app')
)}{' '}
<span className="text-red-500">*</span>
</span>
<span className="text-xs font-normal text-text-secondary">
{startupConfig?.interface?.mcpServers?.trustCheckbox?.subLabel ? (
<span
/** No sanitization required. trusted admin-controlled source (yml) */
dangerouslySetInnerHTML={{
__html: getLocalizedValue(
startupConfig.interface.mcpServers.trustCheckbox.subLabel,
localize('com_agents_mcp_trust_subtext'),
),
}}
/>
) : (
localize('com_agents_mcp_trust_subtext')
)}
</span>
</Label>
</div>
{errors.trust && (
<span className="text-xs text-red-500">{localize('com_ui_field_required')}</span>
)}
</div>
</FormProvider>
}
footerClassName="sm:justify-between"
leftButtons={
server ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
aria-label="Delete MCP server"
onClick={() => setShowDeleteConfirm(true)}
disabled={isSubmitting || isDeleting}
>
<div className="flex w-full items-center justify-center gap-2 text-red-500">
<TrashIcon />
</div>
</Button>
{shouldShowShareButton && (
<GenericGrantAccessDialog
resourceDbId={server.dbId}
resourceName={server.config.title || ''}
resourceType={ResourceType.MCPSERVER}
/>
)}
</div>
) : null
}
buttons={
<Button
type="button"
variant="submit"
onClick={onSubmit}
disabled={isSubmitting}
className="text-white"
>
{isSubmitting ? (
<Spinner className="size-4" />
) : (
localize(server ? 'com_ui_update' : 'com_ui_create')
)}
</Button>
}
/>
</OGDialog>
</>
);
}

View file

@ -0,0 +1,31 @@
import { FormProvider } from 'react-hook-form';
import ConnectionSection from './sections/ConnectionSection';
import BasicInfoSection from './sections/BasicInfoSection';
import TransportSection from './sections/TransportSection';
import AuthSection from './sections/AuthSection';
import TrustSection from './sections/TrustSection';
import type { useMCPServerForm } from './hooks/useMCPServerForm';
interface MCPServerFormProps {
formHook: ReturnType<typeof useMCPServerForm>;
}
export default function MCPServerForm({ formHook }: MCPServerFormProps) {
const { methods, isEditMode, server } = formHook;
return (
<FormProvider {...methods}>
<div className="max-h-[70vh] space-y-4 overflow-y-auto px-1 py-1">
<BasicInfoSection />
<ConnectionSection />
<TransportSection />
<AuthSection isEditMode={isEditMode} serverName={server?.serverName} />
<TrustSection />
</div>
</FormProvider>
);
}

View file

@ -0,0 +1,311 @@
import { useEffect, useMemo, useCallback, useState } from 'react';
import { useForm } from 'react-hook-form';
import type { MCPServerCreateParams } from 'librechat-data-provider';
import {
useCreateMCPServerMutation,
useUpdateMCPServerMutation,
useDeleteMCPServerMutation,
} from '~/data-provider/MCP';
import { useToastContext } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { extractServerNameFromUrl, isValidUrl, normalizeUrl } from '../utils/urlUtils';
import type { MCPServerDefinition } from '~/hooks';
// Auth type enum
export enum AuthTypeEnum {
None = 'none',
ServiceHttp = 'service_http',
OAuth = 'oauth',
}
// Authorization type enum
export enum AuthorizationTypeEnum {
Basic = 'basic',
Bearer = 'bearer',
Custom = 'custom',
}
// Auth configuration interface
export interface AuthConfig {
auth_type: AuthTypeEnum;
api_key?: string;
api_key_source?: 'admin' | 'user';
api_key_authorization_type?: AuthorizationTypeEnum;
api_key_custom_header?: string;
oauth_client_id?: string;
oauth_client_secret?: string;
oauth_authorization_url?: string;
oauth_token_url?: string;
oauth_scope?: string;
server_id?: string;
}
// Form data interface
export interface MCPServerFormData {
title: string;
description?: string;
icon?: string;
url: string;
type: 'streamable-http' | 'sse';
auth: AuthConfig;
trust: boolean;
}
interface UseMCPServerFormProps {
server?: MCPServerDefinition | null;
onSuccess?: (serverName: string, isOAuth: boolean) => void;
onClose?: () => void;
}
export function useMCPServerForm({ server, onSuccess, onClose }: UseMCPServerFormProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
// Mutations
const createMutation = useCreateMCPServerMutation();
const updateMutation = useUpdateMCPServerMutation();
const deleteMutation = useDeleteMCPServerMutation();
// State
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// Check if editing existing server
const isEditMode = !!server;
// Default form values
const defaultValues = useMemo<MCPServerFormData>(() => {
if (server) {
let authType = AuthTypeEnum.None;
if (server.config.oauth) {
authType = AuthTypeEnum.OAuth;
} else if ('apiKey' in server.config && server.config.apiKey) {
authType = AuthTypeEnum.ServiceHttp;
}
const apiKeyConfig = 'apiKey' in server.config ? server.config.apiKey : undefined;
return {
title: server.config.title || '',
description: server.config.description || '',
url: 'url' in server.config ? server.config.url : '',
type: (server.config.type as 'streamable-http' | 'sse') || 'streamable-http',
icon: server.config.iconPath || '',
auth: {
auth_type: authType,
api_key: '', // Never pre-fill secrets
api_key_source: (apiKeyConfig?.source as 'admin' | 'user') || 'admin',
api_key_authorization_type:
(apiKeyConfig?.authorization_type as AuthorizationTypeEnum) ||
AuthorizationTypeEnum.Bearer,
api_key_custom_header: apiKeyConfig?.custom_header || '',
oauth_client_id: server.config.oauth?.client_id || '',
oauth_client_secret: '', // Never pre-fill secrets
oauth_authorization_url: server.config.oauth?.authorization_url || '',
oauth_token_url: server.config.oauth?.token_url || '',
oauth_scope: server.config.oauth?.scope || '',
server_id: server.serverName,
},
trust: true, // Pre-checked for existing servers
};
}
return {
title: '',
description: '',
url: '',
type: 'streamable-http',
icon: '',
auth: {
auth_type: AuthTypeEnum.None,
api_key: '',
api_key_source: 'admin',
api_key_authorization_type: AuthorizationTypeEnum.Bearer,
api_key_custom_header: '',
oauth_client_id: '',
oauth_client_secret: '',
oauth_authorization_url: '',
oauth_token_url: '',
oauth_scope: '',
},
trust: false,
};
}, [server]);
// Form instance
const methods = useForm<MCPServerFormData>({
defaultValues,
mode: 'onChange',
});
const { reset, watch, setValue, getValues } = methods;
// Watch URL for auto-fill
const watchedUrl = watch('url');
const watchedTitle = watch('title');
// Auto-fill title from URL when title is empty
const handleUrlChange = useCallback(
(url: string) => {
const currentTitle = getValues('title');
if (!currentTitle && url) {
const normalizedUrl = normalizeUrl(url);
if (isValidUrl(normalizedUrl)) {
const suggestedName = extractServerNameFromUrl(normalizedUrl);
if (suggestedName) {
setValue('title', suggestedName, { shouldValidate: true });
}
}
}
},
[getValues, setValue],
);
// Watch for URL changes
useEffect(() => {
handleUrlChange(watchedUrl);
}, [watchedUrl, handleUrlChange]);
// Reset form when dialog opens
const resetForm = useCallback(() => {
reset(defaultValues);
}, [reset, defaultValues]);
// Handle form submission
const onSubmit = methods.handleSubmit(async (formData: MCPServerFormData) => {
setIsSubmitting(true);
try {
const config: Record<string, unknown> = {
type: formData.type,
url: formData.url,
title: formData.title,
...(formData.description && { description: formData.description }),
...(formData.icon && { iconPath: formData.icon }),
};
// Add OAuth configuration
if (
formData.auth.auth_type === AuthTypeEnum.OAuth &&
(formData.auth.oauth_client_id ||
formData.auth.oauth_client_secret ||
formData.auth.oauth_authorization_url ||
formData.auth.oauth_token_url ||
formData.auth.oauth_scope)
) {
config.oauth = {
...(formData.auth.oauth_client_id && { client_id: formData.auth.oauth_client_id }),
...(formData.auth.oauth_client_secret && {
client_secret: formData.auth.oauth_client_secret,
}),
...(formData.auth.oauth_authorization_url && {
authorization_url: formData.auth.oauth_authorization_url,
}),
...(formData.auth.oauth_token_url && { token_url: formData.auth.oauth_token_url }),
...(formData.auth.oauth_scope && { scope: formData.auth.oauth_scope }),
};
}
// Add API Key configuration
if (formData.auth.auth_type === AuthTypeEnum.ServiceHttp) {
const source = formData.auth.api_key_source || 'admin';
const authorizationType = formData.auth.api_key_authorization_type || 'bearer';
config.apiKey = {
source,
authorization_type: authorizationType,
...(source === 'admin' && formData.auth.api_key && { key: formData.auth.api_key }),
...(authorizationType === 'custom' &&
formData.auth.api_key_custom_header && {
custom_header: formData.auth.api_key_custom_header,
}),
};
}
const params: MCPServerCreateParams = { config };
const result = server
? await updateMutation.mutateAsync({ serverName: server.serverName, data: params })
: await createMutation.mutateAsync(params);
showToast({
message: server
? localize('com_ui_mcp_server_updated')
: localize('com_ui_mcp_server_created'),
status: 'success',
});
const isOAuth = formData.auth.auth_type === AuthTypeEnum.OAuth;
onSuccess?.(result.serverName, isOAuth && !server);
} catch (error: unknown) {
let errorMessage = localize('com_ui_error');
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string } } };
if (axiosError.response?.data?.error === 'MCP_INSPECTION_FAILED') {
errorMessage = localize('com_ui_mcp_server_connection_failed');
} else if (axiosError.response?.data?.error === 'MCP_DOMAIN_NOT_ALLOWED') {
errorMessage = localize('com_ui_mcp_domain_not_allowed');
} else if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
showToast({
message: errorMessage,
status: 'error',
});
} finally {
setIsSubmitting(false);
}
});
// Handle delete
const handleDelete = useCallback(async () => {
if (!server) {
return;
}
setIsDeleting(true);
try {
await deleteMutation.mutateAsync(server.serverName);
showToast({
message: localize('com_ui_mcp_server_deleted'),
status: 'success',
});
onClose?.();
} catch (error: unknown) {
let errorMessage = localize('com_ui_error');
if (error && typeof error === 'object' && 'response' in error) {
const axiosError = error as { response?: { data?: { error?: string } } };
if (axiosError.response?.data?.error) {
errorMessage = axiosError.response.data.error;
}
} else if (error instanceof Error) {
errorMessage = error.message;
}
showToast({
message: errorMessage,
status: 'error',
});
} finally {
setIsDeleting(false);
}
}, [server, deleteMutation, showToast, localize, onClose]);
return {
methods,
isEditMode,
isSubmitting,
isDeleting,
onSubmit,
handleDelete,
resetForm,
server,
};
}

View file

@ -0,0 +1,237 @@
import React, { useState, useEffect } from 'react';
import {
OGDialog,
OGDialogTemplate,
OGDialogContent,
OGDialogHeader,
OGDialogTitle,
Button,
TrashIcon,
Spinner,
} from '@librechat/client';
import {
SystemRoles,
Permissions,
ResourceType,
PermissionBits,
PermissionTypes,
} from 'librechat-data-provider';
import { GenericGrantAccessDialog } from '~/components/Sharing';
import { useAuthContext, useHasAccess, useResourcePermissions, MCPServerDefinition } from '~/hooks';
import { useLocalize } from '~/hooks';
import { useMCPServerForm } from './hooks/useMCPServerForm';
import MCPServerForm from './MCPServerForm';
interface MCPServerDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
children: React.ReactNode;
triggerRef?: React.MutableRefObject<HTMLButtonElement | null>;
server?: MCPServerDefinition | null;
}
export default function MCPServerDialog({
open,
onOpenChange,
children,
triggerRef,
server,
}: MCPServerDialogProps) {
const localize = useLocalize();
const { user } = useAuthContext();
// State for dialogs
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [showRedirectUriDialog, setShowRedirectUriDialog] = useState(false);
const [createdServerId, setCreatedServerId] = useState<string | null>(null);
// Form hook
const formHook = useMCPServerForm({
server,
onSuccess: (serverName, isOAuth) => {
if (isOAuth) {
setCreatedServerId(serverName);
setShowRedirectUriDialog(true);
} else {
onOpenChange(false);
setTimeout(() => {
triggerRef?.current?.focus();
}, 0);
}
},
onClose: () => {
onOpenChange(false);
setTimeout(() => {
triggerRef?.current?.focus();
}, 0);
},
});
const { isEditMode, isSubmitting, isDeleting, onSubmit, handleDelete, resetForm } = formHook;
// Reset form when dialog opens
useEffect(() => {
if (open) {
resetForm();
}
}, [open, resetForm]);
// Permissions
const hasAccessToShareMcpServers = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.SHARE,
});
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
ResourceType.MCPSERVER,
server?.dbId || '',
);
const canShareThisServer = hasPermission(PermissionBits.SHARE);
const shouldShowShareButton =
server &&
(user?.role === SystemRoles.ADMIN || canShareThisServer) &&
hasAccessToShareMcpServers &&
!permissionsLoading;
const redirectUri = createdServerId
? `${window.location.origin}/api/mcp/${createdServerId}/oauth/callback`
: '';
return (
<>
{/* Delete confirmation dialog */}
<OGDialog open={showDeleteConfirm} onOpenChange={(isOpen) => setShowDeleteConfirm(isOpen)}>
<OGDialogTemplate
title={localize('com_ui_delete')}
className="max-w-[450px]"
main={<p className="text-left text-sm">{localize('com_ui_mcp_server_delete_confirm')}</p>}
selection={{
selectHandler: handleDelete,
selectClasses:
'bg-destructive text-white transition-all duration-200 hover:bg-destructive/80',
selectText: isDeleting ? <Spinner /> : localize('com_ui_delete'),
}}
/>
</OGDialog>
{/* Post-creation redirect URI dialog */}
<OGDialog
open={showRedirectUriDialog}
onOpenChange={(isOpen) => {
setShowRedirectUriDialog(isOpen);
if (!isOpen) {
onOpenChange(false);
setCreatedServerId(null);
}
}}
>
<OGDialogContent className="w-full max-w-lg border-none bg-surface-primary text-text-primary">
<OGDialogHeader className="border-b border-border-light px-4 py-3">
<OGDialogTitle>{localize('com_ui_mcp_server_created')}</OGDialogTitle>
</OGDialogHeader>
<div className="space-y-4 p-4">
<p className="text-sm text-text-secondary">
{localize('com_ui_redirect_uri_instructions')}
</p>
<div className="rounded-lg border border-border-medium bg-surface-secondary p-3">
<label className="mb-2 block text-xs font-medium text-text-secondary">
{localize('com_ui_redirect_uri')}
</label>
<div className="flex items-center gap-2">
<input
className="flex-1 rounded border border-border-medium bg-surface-primary px-3 py-2 text-sm"
value={redirectUri}
readOnly
/>
<Button
onClick={() => {
navigator.clipboard.writeText(redirectUri);
}}
variant="outline"
className="whitespace-nowrap"
>
{localize('com_ui_copy_link')}
</Button>
</div>
</div>
<div className="flex justify-end">
<Button
onClick={() => {
setShowRedirectUriDialog(false);
onOpenChange(false);
setCreatedServerId(null);
}}
variant="submit"
className="text-white"
>
{localize('com_ui_done')}
</Button>
</div>
</div>
</OGDialogContent>
</OGDialog>
{/* Main Dialog */}
<OGDialog open={open} onOpenChange={onOpenChange} triggerRef={triggerRef}>
{children}
<OGDialogTemplate
title={
isEditMode ? localize('com_ui_edit_mcp_server') : localize('com_ui_add_mcp_server')
}
description={
isEditMode
? localize('com_ui_edit_mcp_server_dialog_description', {
serverName: server?.serverName || '',
})
: undefined
}
className="w-11/12 md:max-w-3xl"
main={<MCPServerForm formHook={formHook} />}
footerClassName="sm:justify-between"
leftButtons={
isEditMode ? (
<div className="flex items-center gap-2">
<Button
type="button"
variant="outline"
size="sm"
aria-label={localize('com_ui_delete')}
onClick={() => setShowDeleteConfirm(true)}
disabled={isSubmitting || isDeleting}
>
<div className="flex w-full items-center justify-center gap-2 text-red-500">
<TrashIcon />
</div>
</Button>
{shouldShowShareButton && server && (
<GenericGrantAccessDialog
resourceDbId={server.dbId}
resourceName={server.config.title || ''}
resourceType={ResourceType.MCPSERVER}
/>
)}
</div>
) : null
}
buttons={
<Button
type="button"
variant="submit"
onClick={onSubmit}
disabled={isSubmitting}
className="text-white"
>
{isSubmitting ? (
<Spinner className="size-4" />
) : (
localize(isEditMode ? 'com_ui_update' : 'com_ui_create')
)}
</Button>
}
/>
</OGDialog>
</>
);
}

View file

@ -0,0 +1,226 @@
import { useMemo, useState } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Label, Input, Checkbox, SecretInput, Radio, useToastContext } from '@librechat/client';
import { Copy, CopyCheck } from 'lucide-react';
import { useLocalize, useCopyToClipboard } from '~/hooks';
import { cn } from '~/utils';
import { AuthTypeEnum, AuthorizationTypeEnum } from '../hooks/useMCPServerForm';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
interface AuthSectionProps {
isEditMode: boolean;
serverName?: string;
}
export default function AuthSection({ isEditMode, serverName }: AuthSectionProps) {
const localize = useLocalize();
const { showToast } = useToastContext();
const {
register,
setValue,
formState: { errors },
} = useFormContext<MCPServerFormData>();
const [isCopying, setIsCopying] = useState(false);
const authType = useWatch<MCPServerFormData, 'auth.auth_type'>({
name: 'auth.auth_type',
}) as AuthTypeEnum;
const apiKeySource = useWatch<MCPServerFormData, 'auth.api_key_source'>({
name: 'auth.api_key_source',
}) as 'admin' | 'user';
const authorizationType = useWatch<MCPServerFormData, 'auth.api_key_authorization_type'>({
name: 'auth.api_key_authorization_type',
}) as AuthorizationTypeEnum;
const redirectUri = serverName
? `${window.location.origin}/api/mcp/${serverName}/oauth/callback`
: '';
const copyLink = useCopyToClipboard({ text: redirectUri });
const authTypeOptions = useMemo(
() => [
{ value: AuthTypeEnum.None, label: localize('com_ui_no_auth') },
{ value: AuthTypeEnum.ServiceHttp, label: localize('com_ui_api_key') },
{ value: AuthTypeEnum.OAuth, label: 'OAuth' },
],
[localize],
);
const headerFormatOptions = useMemo(
() => [
{ value: AuthorizationTypeEnum.Bearer, label: localize('com_ui_bearer') },
{ value: AuthorizationTypeEnum.Basic, label: localize('com_ui_basic') },
{ value: AuthorizationTypeEnum.Custom, label: localize('com_ui_custom') },
],
[localize],
);
return (
<div className="space-y-3">
{/* Auth Type Radio */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{localize('com_ui_authentication')}</Label>
<Radio
options={authTypeOptions}
value={authType || AuthTypeEnum.None}
onChange={(val) => setValue('auth.auth_type', val as AuthTypeEnum)}
fullWidth
/>
</div>
{/* API Key Fields */}
{authType === AuthTypeEnum.ServiceHttp && (
<div className="space-y-3 rounded-lg border border-border-light p-3">
{/* User provides own key checkbox */}
<div className="flex items-center gap-2">
<Checkbox
id="user_provides_key"
checked={apiKeySource === 'user'}
onCheckedChange={(checked) =>
setValue('auth.api_key_source', checked ? 'user' : 'admin')
}
aria-label={localize('com_ui_user_provides_key')}
/>
<label htmlFor="user_provides_key" className="cursor-pointer text-sm">
{localize('com_ui_user_provides_key')}
</label>
</div>
{/* API Key input - only when admin provides */}
{apiKeySource !== 'user' && (
<div className="space-y-1.5">
<Label htmlFor="api_key" className="text-sm font-medium">
{localize('com_ui_api_key')}
</Label>
<SecretInput id="api_key" placeholder="sk-..." {...register('auth.api_key')} />
</div>
)}
{/* Header Format Radio */}
<div className="space-y-1.5">
<Label className="text-sm font-medium">{localize('com_ui_header_format')}</Label>
<Radio
options={headerFormatOptions}
value={authorizationType || AuthorizationTypeEnum.Bearer}
onChange={(val) =>
setValue('auth.api_key_authorization_type', val as AuthorizationTypeEnum)
}
fullWidth
/>
</div>
{/* Custom header name */}
{authorizationType === AuthorizationTypeEnum.Custom && (
<div className="space-y-1.5">
<Label htmlFor="custom_header" className="text-sm font-medium">
{localize('com_ui_custom_header_name')}
</Label>
<Input
id="custom_header"
placeholder="X-Api-Key"
{...register('auth.api_key_custom_header')}
/>
</div>
)}
</div>
)}
{/* OAuth Fields */}
{authType === AuthTypeEnum.OAuth && (
<div className="space-y-3 rounded-lg border border-border-light p-3">
{/* Client ID & Secret in a grid */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="oauth_client_id" className="text-sm font-medium">
{localize('com_ui_client_id')}{' '}
{!isEditMode && <span className="text-text-secondary">*</span>}
</Label>
<Input
id="oauth_client_id"
autoComplete="off"
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
{...register('auth.oauth_client_id', { required: !isEditMode })}
className={cn(errors.auth?.oauth_client_id && 'border-red-500')}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="oauth_client_secret" className="text-sm font-medium">
{localize('com_ui_client_secret')}{' '}
{!isEditMode && <span className="text-text-secondary">*</span>}
</Label>
<SecretInput
id="oauth_client_secret"
placeholder={isEditMode ? localize('com_ui_leave_blank_to_keep') : ''}
{...register('auth.oauth_client_secret', { required: !isEditMode })}
className={cn(errors.auth?.oauth_client_secret && 'border-red-500')}
/>
</div>
</div>
{/* Auth URL & Token URL in a grid */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<Label htmlFor="oauth_authorization_url" className="text-sm font-medium">
{localize('com_ui_auth_url')}
</Label>
<Input
id="oauth_authorization_url"
placeholder="https://..."
{...register('auth.oauth_authorization_url')}
/>
</div>
<div className="space-y-1.5">
<Label htmlFor="oauth_token_url" className="text-sm font-medium">
{localize('com_ui_token_url')}
</Label>
<Input
id="oauth_token_url"
placeholder="https://..."
{...register('auth.oauth_token_url')}
/>
</div>
</div>
{/* Scope */}
<div className="space-y-1.5">
<Label htmlFor="oauth_scope" className="text-sm font-medium">
{localize('com_ui_scope')}
</Label>
<Input id="oauth_scope" placeholder="read write" {...register('auth.oauth_scope')} />
</div>
{/* Redirect URI */}
{isEditMode && redirectUri && (
<div className="space-y-1.5">
<Label className="text-sm font-medium">{localize('com_ui_redirect_uri')}</Label>
<div className="flex items-center gap-2">
<Input
type="text"
readOnly
value={redirectUri}
className="flex-1 text-xs text-text-secondary"
/>
<button
type="button"
onClick={() => {
if (isCopying) return;
showToast({ message: localize('com_ui_copied_to_clipboard') });
copyLink(setIsCopying);
}}
className="flex size-10 shrink-0 items-center justify-center rounded-lg border border-border-light text-text-secondary transition-colors hover:bg-surface-hover hover:text-text-primary"
aria-label={localize('com_ui_copy_link')}
>
{isCopying ? <CopyCheck className="size-4" /> : <Copy className="size-4" />}
</button>
</div>
</div>
)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,77 @@
import { useFormContext } from 'react-hook-form';
import { Input, Label, TextareaAutosize } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import MCPIcon from '~/components/SidePanel/Agents/MCPIcon';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function BasicInfoSection() {
const localize = useLocalize();
const {
register,
watch,
setValue,
formState: { errors },
} = useFormContext<MCPServerFormData>();
const iconValue = watch('icon');
const handleIconChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onloadend = () => {
const base64String = reader.result as string;
setValue('icon', base64String);
};
reader.readAsDataURL(file);
}
};
return (
<div className="space-y-3">
{/* Icon + Name row */}
<div className="flex items-start gap-4">
<div className="flex-shrink-0">
<MCPIcon icon={iconValue} onIconChange={handleIconChange} />
</div>
<div className="flex-1 space-y-1.5">
<Label htmlFor="title" className="text-sm font-medium">
{localize('com_ui_name')} <span className="text-text-secondary">*</span>
</Label>
<Input
id="title"
autoComplete="off"
placeholder={localize('com_agents_mcp_name_placeholder')}
{...register('title', {
required: localize('com_ui_field_required'),
pattern: {
value: /^[a-zA-Z0-9 ]+$/,
message: localize('com_ui_mcp_title_invalid'),
},
})}
className={cn(errors.title && 'border-red-500 focus:border-red-500')}
/>
{errors.title && <p className="text-xs text-red-500">{errors.title.message}</p>}
</div>
</div>
{/* Description - always visible, full width */}
<div className="space-y-1.5">
<Label htmlFor="description" className="text-sm font-medium">
{localize('com_ui_description')}{' '}
<span className="text-xs text-text-secondary">{localize('com_ui_optional')}</span>
</Label>
<TextareaAutosize
id="description"
aria-label={localize('com_ui_description')}
placeholder={localize('com_agents_mcp_description_placeholder')}
{...register('description')}
minRows={2}
maxRows={4}
className="w-full resize-none rounded-lg border border-input bg-transparent px-3 py-2 text-sm placeholder:text-muted-foreground focus-visible:outline-none"
/>
</div>
</div>
);
}

View file

@ -0,0 +1,37 @@
import { useFormContext } from 'react-hook-form';
import { Input, Label } from '@librechat/client';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
import { isValidUrl, normalizeUrl } from '../utils/urlUtils';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function ConnectionSection() {
const localize = useLocalize();
const {
register,
formState: { errors },
} = useFormContext<MCPServerFormData>();
return (
<div className="space-y-1.5">
<Label htmlFor="url" className="text-sm font-medium">
{localize('com_ui_mcp_url')} <span className="text-text-secondary">*</span>
</Label>
<Input
id="url"
type="url"
autoComplete="off"
placeholder={localize('com_ui_mcp_server_url_placeholder')}
{...register('url', {
required: localize('com_ui_field_required'),
validate: (value) => {
const normalized = normalizeUrl(value);
return isValidUrl(normalized) || localize('com_ui_mcp_invalid_url');
},
})}
className={cn(errors.url && 'border-red-500 focus:border-red-500')}
/>
{errors.url && <p className="text-xs text-red-500">{errors.url.message}</p>}
</div>
);
}

View file

@ -0,0 +1,38 @@
import { useMemo } from 'react';
import { useFormContext, useWatch } from 'react-hook-form';
import { Label, Radio } from '@librechat/client';
import { useLocalize } from '~/hooks';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function TransportSection() {
const localize = useLocalize();
const { setValue } = useFormContext<MCPServerFormData>();
const transportType = useWatch<MCPServerFormData, 'type'>({
name: 'type',
});
const handleTransportChange = (value: string) => {
setValue('type', value as 'streamable-http' | 'sse');
};
const transportOptions = useMemo(
() => [
{ value: 'streamable-http', label: localize('com_ui_mcp_type_streamable_http') },
{ value: 'sse', label: localize('com_ui_mcp_type_sse') },
],
[localize],
);
return (
<div className="space-y-2">
<Label className="text-sm font-medium">{localize('com_ui_mcp_transport')}</Label>
<Radio
options={transportOptions}
value={transportType}
onChange={handleTransportChange}
fullWidth
/>
</div>
);
}

View file

@ -0,0 +1,75 @@
import { useFormContext, Controller } from 'react-hook-form';
import { Checkbox, Label } from '@librechat/client';
import { useLocalize, useLocalizedConfig } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import type { MCPServerFormData } from '../hooks/useMCPServerForm';
export default function TrustSection() {
const localize = useLocalize();
const { data: startupConfig } = useGetStartupConfig();
const getLocalizedValue = useLocalizedConfig();
const {
control,
formState: { errors },
} = useFormContext<MCPServerFormData>();
return (
<div className="rounded-lg border border-border-light bg-surface-secondary p-2">
<div className="flex items-start gap-3">
<Controller
name="trust"
control={control}
rules={{ required: true }}
render={({ field }) => (
<Checkbox
id="trust"
checked={field.value}
onCheckedChange={field.onChange}
aria-labelledby="trust-label"
aria-describedby="trust-description"
className="mt-0.5"
/>
)}
/>
<Label
id="trust-label"
htmlFor="trust"
className="flex cursor-pointer flex-col gap-0.5 text-sm"
>
<span className="font-medium text-text-primary">
{startupConfig?.interface?.mcpServers?.trustCheckbox?.label ? (
<span
dangerouslySetInnerHTML={{
__html: getLocalizedValue(
startupConfig.interface.mcpServers.trustCheckbox.label,
localize('com_ui_trust_app'),
),
}}
/>
) : (
localize('com_ui_trust_app')
)}{' '}
<span className="text-text-secondary">*</span>
</span>
<span id="trust-description" className="text-xs font-normal text-text-secondary">
{startupConfig?.interface?.mcpServers?.trustCheckbox?.subLabel ? (
<span
dangerouslySetInnerHTML={{
__html: getLocalizedValue(
startupConfig.interface.mcpServers.trustCheckbox.subLabel,
localize('com_agents_mcp_trust_subtext'),
),
}}
/>
) : (
localize('com_agents_mcp_trust_subtext')
)}
</span>
</Label>
</div>
{errors.trust && (
<p className="mt-2 text-xs text-red-500">{localize('com_ui_field_required')}</p>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
/**
* URL parsing and auto-fill utilities for MCP Server Dialog
*/
/**
* Extracts a readable server name from a URL
* Examples:
* "https://api.example.com/mcp" "Example API"
* "https://mcp.github.com" "Github"
* "https://tools.anthropic.com" "Anthropic Tools"
*/
export function extractServerNameFromUrl(url: string): string {
try {
const parsed = new URL(url);
const hostname = parsed.hostname;
// Remove common prefixes and suffixes
let name = hostname
.replace(/^(www\.|api\.|mcp\.|tools\.)/, '')
.replace(/\.(com|org|io|net|dev|ai|app)$/, '');
// Split by dots and take the main domain part
const parts = name.split('.');
name = parts[0] || name;
// Convert to title case and add context based on subdomain
const titleCase = name
.split(/[-_]/)
.map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
.join(' ');
// Add suffix based on original subdomain (but not for mcp. prefix)
if (hostname.startsWith('api.')) {
return `${titleCase} API`;
}
if (hostname.startsWith('tools.')) {
return `${titleCase} Tools`;
}
return titleCase;
} catch {
return '';
}
}
/**
* Validates a URL format
*/
export function isValidUrl(url: string): boolean {
if (!url) {
return false;
}
try {
const parsed = new URL(url);
return parsed.protocol === 'https:' || parsed.protocol === 'http:';
} catch {
return false;
}
}
/**
* Checks if URL uses HTTPS
*/
export function isHttps(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === 'https:';
} catch {
return false;
}
}
/**
* Normalizes a URL (adds https:// if missing protocol)
*/
export function normalizeUrl(url: string): string {
const trimmed = url.trim();
if (!trimmed) {
return '';
}
// If no protocol, assume https
if (!trimmed.includes('://')) {
return `https://${trimmed}`;
}
return trimmed;
}
/**
* Extracts transport type hint from URL patterns
* Some MCP servers use specific URL patterns for SSE vs HTTP
*/
export function detectTransportFromUrl(url: string): 'streamable-http' | 'sse' | null {
try {
const parsed = new URL(url);
const pathname = parsed.pathname.toLowerCase();
// Common SSE patterns
if (pathname.includes('/sse') || pathname.includes('/events') || pathname.includes('/stream')) {
return 'sse';
}
// Default to null (let user choose or use default)
return null;
} catch {
return null;
}
}

View file

@ -1,65 +1,44 @@
import { useState, useRef } from 'react';
import { GearIcon, MCPIcon, OGDialogTrigger } from '@librechat/client';
import {
PermissionBits,
PermissionTypes,
Permissions,
hasPermissions,
} from 'librechat-data-provider';
import { useLocalize, useHasAccess, MCPServerDefinition } from '~/hooks';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPServerDialog from './MCPServerDialog';
import { MCPIcon } from '@librechat/client';
import { PermissionTypes, Permissions } from 'librechat-data-provider';
import type { MCPServerStatusIconProps } from '~/components/MCP/MCPServerStatusIcon';
import type { MCPServerDefinition } from '~/hooks';
import { useLocalize, useHasAccess } from '~/hooks';
import MCPServerCard from './MCPServerCard';
interface MCPServerListProps {
servers: MCPServerDefinition[];
getServerStatusIconProps: (
serverName: string,
) => React.ComponentProps<typeof MCPServerStatusIcon>;
getServerStatusIconProps: (serverName: string) => MCPServerStatusIconProps;
isFiltered?: boolean;
}
// Self-contained edit button component (follows MemoryViewer pattern)
const EditMCPServerButton = ({ server }: { server: MCPServerDefinition }) => {
const localize = useLocalize();
const [open, setOpen] = useState(false);
const triggerRef = useRef<HTMLButtonElement>(null);
return (
<MCPServerDialog open={open} onOpenChange={setOpen} triggerRef={triggerRef} server={server}>
<OGDialogTrigger asChild>
<button
ref={triggerRef}
onClick={() => setOpen(true)}
className="flex h-5 w-5 items-center justify-center rounded hover:bg-surface-secondary"
aria-label={localize('com_ui_edit')}
>
<GearIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</MCPServerDialog>
);
};
/**
* Renders a list of MCP server cards with empty state handling
*/
export default function MCPServerList({
servers,
getServerStatusIconProps,
isFiltered = false,
}: MCPServerListProps) {
const localize = useLocalize();
const canCreateEditMCPs = useHasAccess({
permissionType: PermissionTypes.MCP_SERVERS,
permission: Permissions.CREATE,
});
const localize = useLocalize();
if (servers.length === 0) {
return (
<div className="rounded-lg border border-border-light bg-transparent p-8 text-center shadow-sm">
<div className="flex flex-col items-center justify-center rounded-lg border border-border-light bg-transparent p-6 text-center">
<div className="mb-2 flex size-10 items-center justify-center rounded-full bg-surface-tertiary">
<MCPIcon className="size-5 text-text-secondary" aria-hidden="true" />
</div>
{isFiltered ? (
<p className="text-sm text-text-secondary">{localize('com_ui_no_mcp_servers_match')}</p>
) : (
<>
<p className="text-sm text-text-secondary">{localize('com_ui_no_mcp_servers')}</p>
<p className="mt-1 text-xs text-text-tertiary">
<p className="text-sm font-medium text-text-primary">
{localize('com_ui_no_mcp_servers')}
</p>
<p className="mt-0.5 text-xs text-text-secondary">
{localize('com_ui_add_first_mcp_server')}
</p>
</>
@ -69,36 +48,16 @@ export default function MCPServerList({
}
return (
<div className="space-y-2">
{servers.map((server) => {
const canEditThisServer = hasPermissions(server.effectivePermissions, PermissionBits.EDIT);
const displayName = server.config?.title || server.serverName;
const serverKey = `key_${server.serverName}`;
return (
<div key={serverKey} className="rounded-lg border border-border-light bg-transparent p-3">
<div className="flex items-center gap-3">
{/* Server Icon */}
{server.config?.iconPath ? (
<img src={server.config.iconPath} className="h-5 w-5 rounded" alt={displayName} />
) : (
<MCPIcon className="h-5 w-5" />
)}
{/* Server Info */}
<div className="min-w-0 flex-1">
<h3 className="truncate font-semibold text-text-primary">{displayName}</h3>
</div>
{/* Edit Button - Only for DB servers and when user has CREATE access */}
{canCreateEditMCPs && canEditThisServer && <EditMCPServerButton server={server} />}
{/* Connection Status Icon */}
<MCPServerStatusIcon {...getServerStatusIconProps(server.serverName)} />
</div>
</div>
);
})}
<div className="space-y-2" role="list" aria-label={localize('com_ui_mcp_servers')}>
{servers.map((server) => (
<div key={`card_${server.serverName}`} role="listitem">
<MCPServerCard
server={server}
getServerStatusIconProps={getServerStatusIconProps}
canCreateEditMCPs={canCreateEditMCPs}
/>
</div>
))}
</div>
);
}

View file

@ -0,0 +1,179 @@
import { Check, PlugZap } from 'lucide-react';
import { Spinner } from '@librechat/client';
import type { MCPServerStatus } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
interface MCPStatusBadgeProps {
serverStatus?: MCPServerStatus;
isInitializing?: boolean;
className?: string;
}
/**
* Status badge component for MCP servers - used in dialogs where text status is needed.
*
* Unified color system:
* - Green: Connected/Active (success)
* - Blue: Connecting/In-progress
* - Amber: Needs user action (OAuth required)
* - Gray: Disconnected/Inactive (neutral)
* - Red: Error
*/
export default function MCPStatusBadge({
serverStatus,
isInitializing = false,
className,
}: MCPStatusBadgeProps) {
const localize = useLocalize();
const badgeBaseClass = cn(
'flex items-center gap-1.5 rounded-full px-2 py-0.5 text-xs font-medium',
className,
);
// Initializing/Connecting state - blue
if (isInitializing) {
return (
<div
role="status"
aria-live="polite"
className={cn(
badgeBaseClass,
'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
)}
>
<Spinner className="size-3" />
<span>{localize('com_nav_mcp_status_initializing')}</span>
</div>
);
}
if (!serverStatus) {
return null;
}
const { connectionState, requiresOAuth } = serverStatus;
// Connecting state - blue
if (connectionState === 'connecting') {
return (
<div
role="status"
aria-live="polite"
className={cn(
badgeBaseClass,
'bg-blue-50 text-blue-600 dark:bg-blue-950 dark:text-blue-400',
)}
>
<Spinner className="size-3" />
<span>{localize('com_nav_mcp_status_connecting')}</span>
</div>
);
}
// Disconnected state - check if needs action
if (connectionState === 'disconnected') {
if (requiresOAuth) {
// Needs OAuth - amber (requires action)
return (
<div
role="status"
className={cn(
badgeBaseClass,
'bg-amber-50 text-amber-600 dark:bg-amber-950 dark:text-amber-400',
)}
>
<PlugZap className="size-3" aria-hidden="true" />
<span>{localize('com_nav_mcp_status_needs_auth')}</span>
</div>
);
}
// Simply disconnected - gray (neutral)
return (
<div
role="status"
className={cn(
badgeBaseClass,
'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
)}
>
<span>{localize('com_nav_mcp_status_disconnected')}</span>
</div>
);
}
// Error state - red
if (connectionState === 'error') {
return (
<div
role="status"
className={cn(badgeBaseClass, 'bg-red-50 text-red-600 dark:bg-red-950 dark:text-red-400')}
>
<span>{localize('com_nav_mcp_status_error')}</span>
</div>
);
}
// Connected state - green
if (connectionState === 'connected') {
return (
<div
role="status"
className={cn(
badgeBaseClass,
'bg-green-50 text-green-600 dark:bg-green-950 dark:text-green-400',
)}
>
<Check className="size-3" aria-hidden="true" />
<span>{localize('com_nav_mcp_status_connected')}</span>
</div>
);
}
return null;
}
/**
* Returns the status dot color class - unified across all MCP components.
*
* Colors:
* - Green: Connected
* - Blue: Connecting/Initializing
* - Amber: Needs action (OAuth required while disconnected)
* - Gray: Disconnected (neutral)
* - Red: Error
*/
export function getStatusDotColor(
serverStatus?: MCPServerStatus,
isInitializing?: boolean,
): string {
if (isInitializing) {
return 'bg-blue-500';
}
if (!serverStatus) {
return 'bg-gray-400';
}
const { connectionState, requiresOAuth } = serverStatus;
if (connectionState === 'connecting') {
return 'bg-blue-500';
}
if (connectionState === 'connected') {
return 'bg-green-500';
}
if (connectionState === 'error') {
return 'bg-red-500';
}
if (connectionState === 'disconnected') {
// Needs OAuth = amber, otherwise gray
return requiresOAuth ? 'bg-amber-500' : 'bg-gray-400';
}
return 'bg-gray-400';
}

View file

@ -2,4 +2,7 @@ export { default } from './MCPBuilderPanel';
export { default as MCPBuilderPanel } from './MCPBuilderPanel';
export { default as MCPServerList } from './MCPServerList';
export { default as MCPServerDialog } from './MCPServerDialog';
export { default as MCPServerCard } from './MCPServerCard';
export { default as MCPStatusBadge, getStatusDotColor } from './MCPStatusBadge';
export { default as MCPCardActions } from './MCPCardActions';
export { default as MCPAuth } from './MCPAuth';

View file

@ -1,4 +1,5 @@
import { useCallback, useState, useMemo, useRef, useEffect } from 'react';
import { useAtom } from 'jotai';
import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys, MCPOptions, ResourceType } from 'librechat-data-provider';
@ -12,6 +13,8 @@ import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-
import type { ConfigFieldDetail } from '~/common';
import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider';
import { mcpServerInitStatesAtom, getServerInitState } from '~/store/mcp';
import type { MCPServerInitState } from '~/store/mcp';
export interface MCPServerDefinition {
serverName: string;
@ -21,13 +24,9 @@ export interface MCPServerDefinition {
consumeOnly?: boolean;
}
interface ServerState {
isInitializing: boolean;
oauthUrl: string | null;
oauthStartTime: number | null;
isCancellable: boolean;
pollInterval: NodeJS.Timeout | null;
}
// Poll intervals are kept local since they're timer references that can't be serialized
// The init states (isInitializing, isCancellable, etc.) are stored in the global Jotai atom
type PollIntervals = Record<string, NodeJS.Timeout | null>;
export function useMCPServerManager({ conversationId }: { conversationId?: string | null } = {}) {
const localize = useLocalize();
@ -114,61 +113,53 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
},
});
const [serverStates, setServerStates] = useState<Record<string, ServerState>>(() => {
const initialStates: Record<string, ServerState> = {};
availableMCPServers.forEach((server) => {
initialStates[server.serverName] = {
isInitializing: false,
oauthUrl: null,
oauthStartTime: null,
isCancellable: false,
pollInterval: null,
};
});
return initialStates;
});
// Global atom for init states - shared across all useMCPServerManager instances
// This enables canceling OAuth from both chat dropdown and settings panel
const [serverInitStates, setServerInitStates] = useAtom(mcpServerInitStatesAtom);
// Poll intervals are kept local (not serializable)
const pollIntervalsRef = useRef<PollIntervals>({});
const { connectionStatus } = useMCPConnectionStatus({
enabled: !isLoading && availableMCPServers.length > 0,
});
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
setServerStates((prev) => {
const newStates = { ...prev };
const currentState = newStates[serverName] || {
isInitializing: false,
oauthUrl: null,
oauthStartTime: null,
isCancellable: false,
pollInterval: null,
};
newStates[serverName] = { ...currentState, ...updates };
return newStates;
});
}, []);
const updateServerInitState = useCallback(
(serverName: string, updates: Partial<MCPServerInitState>) => {
setServerInitStates((prev) => {
const currentState = getServerInitState(prev, serverName);
return {
...prev,
[serverName]: { ...currentState, ...updates },
};
});
},
[setServerInitStates],
);
const cleanupServerState = useCallback(
(serverName: string) => {
const state = serverStates[serverName];
if (state?.pollInterval) {
clearTimeout(state.pollInterval);
// Clear local poll interval
const pollInterval = pollIntervalsRef.current[serverName];
if (pollInterval) {
clearTimeout(pollInterval);
pollIntervalsRef.current[serverName] = null;
}
updateServerState(serverName, {
// Reset global init state
updateServerInitState(serverName, {
isInitializing: false,
oauthUrl: null,
oauthStartTime: null,
isCancellable: false,
pollInterval: null,
});
},
[serverStates, updateServerState],
[updateServerInitState],
);
const startServerPolling = useCallback(
(serverName: string) => {
// Prevent duplicate polling for the same server
const existingState = serverStates[serverName];
if (existingState?.pollInterval) {
if (pollIntervalsRef.current[serverName]) {
console.debug(`[MCP Manager] Polling already active for ${serverName}, skipping duplicate`);
return;
}
@ -191,7 +182,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const pollOnce = async () => {
try {
pollAttempts++;
const state = serverStates[serverName];
const state = getServerInitState(serverInitStates, serverName);
/** Stop polling after 3 minutes or max attempts */
const elapsedTime = state?.oauthStartTime
@ -283,7 +274,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
}
timeoutId = setTimeout(pollOnce, nextInterval);
updateServerState(serverName, { pollInterval: timeoutId });
pollIntervalsRef.current[serverName] = timeoutId;
} catch (error) {
console.error(`[MCP Manager] Error polling server ${serverName}:`, error);
if (timeoutId) {
@ -296,22 +287,14 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
// Start the first poll
timeoutId = setTimeout(pollOnce, getPollInterval(0));
updateServerState(serverName, { pollInterval: timeoutId });
pollIntervalsRef.current[serverName] = timeoutId;
},
[
queryClient,
serverStates,
showToast,
localize,
setMCPValues,
cleanupServerState,
updateServerState,
],
[queryClient, serverInitStates, showToast, localize, setMCPValues, cleanupServerState],
);
const initializeServer = useCallback(
async (serverName: string, autoOpenOAuth: boolean = true) => {
updateServerState(serverName, { isInitializing: true });
updateServerInitState(serverName, { isInitializing: true });
try {
const response = await reinitializeMutation.mutateAsync(serverName);
if (!response.success) {
@ -324,7 +307,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
}
if (response.oauthRequired && response.oauthUrl) {
updateServerState(serverName, {
updateServerInitState(serverName, {
oauthUrl: response.oauthUrl,
oauthStartTime: Date.now(),
isCancellable: true,
@ -367,7 +350,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
}
},
[
updateServerState,
updateServerInitState,
reinitializeMutation,
startServerPolling,
queryClient,
@ -410,23 +393,23 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const isInitializing = useCallback(
(serverName: string) => {
return serverStates[serverName]?.isInitializing || false;
return getServerInitState(serverInitStates, serverName).isInitializing;
},
[serverStates],
[serverInitStates],
);
const isCancellable = useCallback(
(serverName: string) => {
return serverStates[serverName]?.isCancellable || false;
return getServerInitState(serverInitStates, serverName).isCancellable;
},
[serverStates],
[serverInitStates],
);
const getOAuthUrl = useCallback(
(serverName: string) => {
return serverStates[serverName]?.oauthUrl || null;
return getServerInitState(serverInitStates, serverName).oauthUrl;
},
[serverStates],
[serverInitStates],
);
const placeholderText = useMemo(

View file

@ -535,8 +535,16 @@
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
"com_nav_maximize_chat_space": "Maximize chat space",
"com_nav_mcp_configure_server": "Configure {{0}}",
"com_nav_mcp_connect": "Connect",
"com_nav_mcp_connect_server": "Connect {{0}}",
"com_nav_mcp_reconnect": "Reconnect",
"com_nav_mcp_status_connected": "Connected",
"com_nav_mcp_status_connecting": "{{0}} - Connecting",
"com_nav_mcp_status_connecting": "Connecting",
"com_nav_mcp_status_disconnected": "Disconnected",
"com_nav_mcp_status_error": "Error",
"com_nav_mcp_status_initializing": "Initializing",
"com_nav_mcp_status_needs_auth": "Needs Auth",
"com_nav_mcp_status_unknown": "Unknown",
"com_nav_mcp_vars_update_error": "Error updating MCP custom user variables",
"com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.",
"com_nav_modular_chat": "Enable switching Endpoints mid-conversation",
@ -639,7 +647,6 @@
"com_ui_additional_details": "Additional Details",
"com_ui_admin": "Admin",
"com_ui_admin_access_warning": "Disabling Admin access to this feature may cause unexpected UI issues requiring refresh. If saved, the only way to revert is via the interface setting in librechat.yaml config which affects all roles.",
"com_ui_admin_provides_key": "Provide a key for all users",
"com_ui_admin_settings": "Admin Settings",
"com_ui_admin_settings_section": "Admin Settings - {{section}}",
"com_ui_advanced": "Advanced",
@ -698,7 +705,6 @@
"com_ui_analyzing": "Analyzing",
"com_ui_analyzing_finished": "Finished analyzing",
"com_ui_api_key": "API Key",
"com_ui_api_key_source": "API Key Source",
"com_ui_archive": "Archive",
"com_ui_archive_delete_error": "Failed to delete archived conversation",
"com_ui_archive_error": "Failed to archive conversation",
@ -728,8 +734,6 @@
"com_ui_authentication": "Authentication",
"com_ui_authentication_type": "Authentication Type",
"com_ui_auto": "Auto",
"com_ui_auto_detect": "Auto Detect",
"com_ui_auto_detect_description": "DCR will be attempted if auth is required. Choose this if your MCP server has no auth requirements or supports DCR.",
"com_ui_avatar": "Avatar",
"com_ui_azure": "Azure",
"com_ui_azure_ad": "Entra ID",
@ -799,6 +803,7 @@
"com_ui_command_usage_placeholder": "Select a Prompt by command or name",
"com_ui_complete_setup": "Complete Setup",
"com_ui_concise": "Concise",
"com_ui_configure": "Configure",
"com_ui_configure_mcp_variables_for": "Configure Variables for {{0}}",
"com_ui_confirm": "Confirm",
"com_ui_confirm_action": "Confirm Action",
@ -1048,7 +1053,6 @@
"com_ui_logo": "{{0}} Logo",
"com_ui_low": "Low",
"com_ui_manage": "Manage",
"com_ui_manual_oauth": "Manual OAuth",
"com_ui_marketplace": "Marketplace",
"com_ui_marketplace_allow_use": "Allow using Marketplace",
"com_ui_max_favorites_reached": "Maximum pinned items reached ({{0}}). Unpin an item to add more.",
@ -1076,7 +1080,6 @@
"com_ui_mcp_server_role_owner_desc": "Full control over MCP servers",
"com_ui_mcp_server_role_viewer": "MCP Server Viewer",
"com_ui_mcp_server_role_viewer_desc": "Can view and use MCP servers",
"com_ui_mcp_server_type": "Server Type",
"com_ui_mcp_server_updated": "MCP server updated successfully",
"com_ui_mcp_servers": "MCP Servers",
"com_ui_mcp_servers_allow_create": "Allow users to create MCP servers",
@ -1087,6 +1090,9 @@
"com_ui_mcp_type_streamable_http": "Streamable HTTPS",
"com_ui_mcp_update_var": "Update {{0}}",
"com_ui_mcp_url": "MCP Server URL",
"com_ui_mcp_server_url_placeholder": "https://mcp.example.com",
"com_ui_mcp_transport": "Transport",
"com_ui_mcp_invalid_url": "Please enter a valid URL",
"com_ui_medium": "Medium",
"com_ui_memories": "Memories",
"com_ui_memories_allow_create": "Allow creating Memories",
@ -1143,6 +1149,7 @@
"com_ui_no_read_access": "You don't have permission to view memories",
"com_ui_no_results_found": "No results found",
"com_ui_no_terms_content": "No terms and conditions content to display",
"com_ui_no_auth": "No Auth",
"com_ui_none": "None",
"com_ui_not_used": "Not Used",
"com_ui_nothing_found": "Nothing found",
@ -1201,7 +1208,6 @@
"com_ui_quality": "Quality",
"com_ui_read_aloud": "Read aloud",
"com_ui_redirect_uri": "Redirect URI",
"com_ui_redirect_uri_info": "The redirect URI will be provided after the server is created. Configure it in your OAuth provider settings.",
"com_ui_redirect_uri_instructions": "Copy this redirect URI and configure it in your OAuth provider settings.",
"com_ui_redirecting_to_provider": "Redirecting to {{0}}, please wait...",
"com_ui_reference_saved_memories": "Reference saved memories",
@ -1281,7 +1287,6 @@
"com_ui_select_model": "Select a model",
"com_ui_select_options": "Select options...",
"com_ui_select_or_create_prompt": "Select or Create a Prompt",
"com_ui_select_placeholder": "Select...",
"com_ui_select_provider": "Select a provider",
"com_ui_select_provider_first": "Select a provider first",
"com_ui_select_region": "Select a region",
@ -1395,7 +1400,6 @@
"com_ui_user": "User",
"com_ui_user_group_permissions": "User & Group Permissions",
"com_ui_user_provides_key": "Each user provides their own key",
"com_ui_user_provides_key_note": "Users will be prompted to enter their API key when connecting to this server.",
"com_ui_value": "Value",
"com_ui_variables": "Variables",
"com_ui_variables_info": "Use double braces in your text to create variables, e.g. `{{example variable}}`, to later fill when using the prompt.",

View file

@ -1,3 +1,4 @@
import { atom } from 'jotai';
import { atomFamily, atomWithStorage } from 'jotai/utils';
import { Constants, LocalStorageKeys } from 'librechat-data-provider';
@ -18,3 +19,39 @@ export const mcpValuesAtomFamily = atomFamily((conversationId: string | null) =>
export const mcpPinnedAtom = atomWithStorage<boolean>(LocalStorageKeys.PIN_MCP_, true, undefined, {
getOnInit: true,
});
/**
* Server initialization state - shared globally so chat dropdown and settings panel
* both see the same OAuth/initialization state.
*
* This enables canceling OAuth from either location.
*/
export interface MCPServerInitState {
isInitializing: boolean;
isCancellable: boolean;
oauthUrl: string | null;
oauthStartTime: number | null;
}
const defaultServerInitState: MCPServerInitState = {
isInitializing: false,
isCancellable: false,
oauthUrl: null,
oauthStartTime: null,
};
/**
* Global atom for MCP server initialization states.
* Keyed by server name.
*/
export const mcpServerInitStatesAtom = atom<Record<string, MCPServerInitState>>({});
/**
* Helper to get or create a server's init state
*/
export const getServerInitState = (
states: Record<string, MCPServerInitState>,
serverName: string,
): MCPServerInitState => {
return states[serverName] || defaultServerInitState;
};

View file

@ -33,7 +33,7 @@ const FilterInput = React.forwardRef<HTMLInputElement, FilterInputProps>(
placeholder=" "
aria-label={label}
className={cn(
'peer flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
'peer flex h-10 w-full rounded-lg border border-border-light bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
{...props}

View file

@ -1,4 +1,4 @@
import React, { useState, useRef, useLayoutEffect, useCallback, memo } from 'react';
import React, { useState, useRef, useLayoutEffect, useEffect, useCallback, memo } from 'react';
import { useLocalize } from '~/hooks';
interface Option {
@ -25,8 +25,9 @@ const Radio = memo(function Radio({
fullWidth = false,
}: RadioProps) {
const localize = useLocalize();
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
const buttonRefs = useRef<(HTMLButtonElement | null)[]>([]);
const [isMounted, setIsMounted] = useState(false);
const [currentValue, setCurrentValue] = useState<string>(value ?? '');
const [backgroundStyle, setBackgroundStyle] = useState<React.CSSProperties>({});
const handleChange = (newValue: string) => {
@ -51,9 +52,21 @@ const Radio = memo(function Radio({
}
}, [currentValue, options]);
// Mark as mounted after dialog animations settle
// Timeout ensures we wait for CSS transitions to complete
useEffect(() => {
const timeout = setTimeout(() => {
setIsMounted(true);
}, 50);
return () => clearTimeout(timeout);
}, []);
useLayoutEffect(() => {
updateBackgroundStyle();
}, [updateBackgroundStyle]);
if (isMounted) {
updateBackgroundStyle();
}
}, [isMounted, updateBackgroundStyle]);
useLayoutEffect(() => {
if (value !== undefined) {
@ -81,7 +94,7 @@ const Radio = memo(function Radio({
className={`relative ${fullWidth ? 'flex' : 'inline-flex'} items-center rounded-lg bg-muted p-1 ${className}`}
role="radiogroup"
>
{selectedIndex >= 0 && (
{selectedIndex >= 0 && isMounted && (
<div
className="pointer-events-none absolute inset-y-1 rounded-md border border-border/50 bg-background shadow-sm transition-all duration-300 ease-out"
style={backgroundStyle}

View file

@ -0,0 +1,106 @@
import * as React from 'react';
import { useState, useCallback } from 'react';
import { Eye, EyeOff, Copy, Check } from 'lucide-react';
import { cn } from '~/utils';
export interface SecretInputProps
extends Omit<React.InputHTMLAttributes<HTMLInputElement>, 'type'> {
/** Show copy button */
showCopy?: boolean;
/** Callback when value is copied */
onCopy?: () => void;
/** Duration in ms to show checkmark after copy (default: 2000) */
copyFeedbackDuration?: number;
}
const SecretInput = React.forwardRef<HTMLInputElement, SecretInputProps>(
(
{ className, showCopy = false, onCopy, copyFeedbackDuration = 2000, disabled, value, ...props },
ref,
) => {
const [isVisible, setIsVisible] = useState(false);
const [isCopied, setIsCopied] = useState(false);
const toggleVisibility = useCallback(() => {
setIsVisible((prev) => !prev);
}, []);
const handleCopy = useCallback(async () => {
if (isCopied || disabled) {
return;
}
const textToCopy = typeof value === 'string' ? value : '';
if (!textToCopy) {
return;
}
try {
await navigator.clipboard.writeText(textToCopy);
setIsCopied(true);
onCopy?.();
setTimeout(() => {
setIsCopied(false);
}, copyFeedbackDuration);
} catch (err) {
console.error('Failed to copy:', err);
}
}, [value, isCopied, disabled, onCopy, copyFeedbackDuration]);
return (
<div className="relative flex items-center">
<input
type={isVisible ? 'text' : 'password'}
className={cn(
'flex h-10 w-full rounded-lg border border-input bg-transparent px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
showCopy ? 'pr-20' : 'pr-10',
className ?? '',
)}
ref={ref}
disabled={disabled}
value={value}
autoComplete="off"
spellCheck={false}
{...props}
/>
<div className="absolute right-1 flex items-center gap-0.5">
{showCopy && (
<button
type="button"
onClick={handleCopy}
disabled={disabled || !value}
className={cn(
'flex size-8 items-center justify-center rounded-md text-text-secondary transition-colors',
disabled || !value
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-hover hover:text-text-primary',
)}
aria-label={isCopied ? 'Copied' : 'Copy to clipboard'}
>
{isCopied ? <Check className="size-4" /> : <Copy className="size-4" />}
</button>
)}
<button
type="button"
onClick={toggleVisibility}
disabled={disabled}
className={cn(
'flex size-8 items-center justify-center rounded-md text-text-secondary transition-colors',
disabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-surface-hover hover:text-text-primary',
)}
aria-label={isVisible ? 'Hide password' : 'Show password'}
>
{isVisible ? <EyeOff className="size-4" /> : <Eye className="size-4" />}
</button>
</div>
</div>
);
},
);
SecretInput.displayName = 'SecretInput';
export { SecretInput };

View file

@ -9,6 +9,7 @@ export * from './DropdownMenu';
export * from './HoverCard';
export * from './Input';
export * from './InputNumber';
export * from './SecretInput';
export * from './FilterInput';
export * from './Label';
export * from './OriginalDialog';
@ -31,12 +32,12 @@ export * from './InputOTP';
export * from './MultiSearch';
export * from './Resizable';
export * from './Select';
export { default as DataTable } from './DataTable';
export { default as Radio } from './Radio';
export { default as Badge } from './Badge';
export { default as Avatar } from './Avatar';
export { default as Combobox } from './Combobox';
export { default as Dropdown } from './Dropdown';
export { default as DataTable } from './DataTable';
export { default as SplitText } from './SplitText';
export { default as FormInput } from './FormInput';
export { default as PixelCard } from './PixelCard';