✂️ refactor: MCP UI Separation for Agents (#9237)

* refactor: MCP UI Separation for Agents (Dustin WIP)

feat: separate MCPs into their own lists away from tools + actions and add the status indicator functionality from chat to their dropdown ui

fix: spotify mcp was not persisting on agent creation

feat: show disconnected saved servers and their tools in agent mcp list in created agents

fix: select-all regression fixed (caused by deleting tools we were drawing from for rendering list)

fix: dont show all mcps, only those installed in agent in list

feat: separate ToolSelectDialog for MCPServerTools

fix: uninitialized mcp servers not showing as added in toolselectdialog

refactor: reduce looping in AgentPanelContext for categorizing groups and mcps

refactor: split ToolSelectDialog and MCPToolSelectDialog functionality (still needs customization for custom user vars)

chore: address ESLint comments

chore: address ESLint comments

feat: one-click initialization on MCP servers in agent builder

fix: stop propagation triggering reinit on caret click

refactor: split uninitialized MCPs component from initialized MCPs

feat: new mcp tool select dialog ui with custom user vars

feat: show initialization state for CUV configurable MCPs too

chore: remove unused localization string

fix: deselecting all tools caused a re-render

fix: remove subtools so removal from MCPToolSelectDialog works more consistently

feat: added servers have all tools enabled by default

feat: mcp server list now alphabetical to prevent annoying ui behavior of servers jumping around depending on tool selection

fix: filter out placeholder group mcp tools from any actual tool calls / definitions

feat: indicator now takes you to config dialog for uninitialized servers

feat: show previously configured mcp servers that are now missing from the yaml

feat: select all enabled by default on first add to mcp server list

chore: address ESLint comments

* refactor: MCP UI Separation for Agents (Danny WIP)

chore: remove use of `{serverName}_mcp_{serverName}`

chore: import order

WIP: separate component concerns

refactor: streamline agent mcp tools

refactor: unify MCP server handling and improve tool visibility logic, remove unnecessary normalization or sorting, remove nesting button, make variable names clear

refactor: rename mcpServerIds to mcpServerNames for clarity and consistency across components

refactor: remove groupedMCPTools and toolToServerMap, streamline MCP server handling in context and components to effectively utilize mcpServersMap

refactor: optimize tool selection logic by replacing array includes with Set for improved performance

chore: add error logging for failed auth URL parsing in ToolCall component

refactor: enhance MCP tool handling by improving server name management and updating UI elements for better clarity

* refactor: decouple connection status from useMCPServerManager with useMCPConnectionStatus

* fix: improve MCP tool validation logic to handle unconfigured servers

* chore: enhance log message clarity for MCP server disconnection in updateUserPluginsController

* refactor: simplify connection status extraction in useMCPConnectionStatus hook

* refactor: improve initializing UX

* chore: replace string literal with ResourceType constant in useResourcePermissions

* refactor: cleanup code, remove redundancies, rename variables for clarity

* chore: add back filtering and sorting for mcp tools dialog

* refactor: initializeServer to return response and early return

* refactor: enhance server initialization logic and improve UI for OAuth interaction

* chore: clarify warning message for unconfigured MCP server in handleTools

* refactor: prevent CustomUserVarsSection from submitting tools dialog form

* fix: nested button of button issue in UninitializedMCPTool

* feat: add functionality to revoke custom user variables in MCPToolSelectDialog

---------

Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Dustin Healy 2025-08-29 19:57:01 -07:00 committed by GitHub
parent d16f93b5f7
commit 49e8443ec5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
30 changed files with 1589 additions and 180 deletions

View file

@ -12,22 +12,23 @@ import {
getIconKey,
cn,
} from '~/utils';
import { useFileMapContext, useAgentPanelContext } from '~/Providers';
import { ToolSelectDialog, MCPToolSelectDialog } from '~/components/Tools';
import useAgentCapabilities from '~/hooks/Agents/useAgentCapabilities';
import { useFileMapContext, useAgentPanelContext } from '~/Providers';
import AgentCategorySelector from './AgentCategorySelector';
import Action from '~/components/SidePanel/Builder/Action';
import { ToolSelectDialog } from '~/components/Tools';
import { useLocalize, useVisibleTools } from '~/hooks';
import { useGetAgentFiles } from '~/data-provider';
import { icons } from '~/hooks/Endpoint/Icons';
import Instructions from './Instructions';
import AgentAvatar from './AgentAvatar';
import FileContext from './FileContext';
import SearchForm from './Search/Form';
import { useLocalize } from '~/hooks';
import FileSearch from './FileSearch';
import Artifacts from './Artifacts';
import AgentTool from './AgentTool';
import CodeForm from './Code/Form';
import MCPTools from './MCPTools';
import { Panel } from '~/common';
const labelClass = 'mb-2 text-token-text-primary block font-medium';
@ -43,10 +44,12 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
const { showToast } = useToastContext();
const methods = useFormContext<AgentForm>();
const [showToolDialog, setShowToolDialog] = useState(false);
const [showMCPToolDialog, setShowMCPToolDialog] = useState(false);
const {
actions,
setAction,
agentsConfig,
mcpServersMap,
setActivePanel,
endpointsConfig,
groupedTools: allTools,
@ -173,19 +176,7 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
Icon = icons[iconKey];
}
// Determine what to show
const selectedToolIds = tools ?? [];
const visibleToolIds = new Set(selectedToolIds);
// Check what group parent tools should be shown if any subtool is present
Object.entries(allTools ?? {}).forEach(([toolId, toolObj]) => {
if (toolObj.tools?.length) {
// if any subtool of this group is selected, ensure group parent tool rendered
if (toolObj.tools.some((st) => selectedToolIds.includes(st.tool_id))) {
visibleToolIds.add(toolId);
}
}
});
const { toolIds, mcpServerNames } = useVisibleTools(tools, allTools, mcpServersMap);
return (
<>
@ -326,8 +317,8 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
</label>
<div>
<div className="mb-1">
{/* // Render all visible IDs (including groups with subtools selected) */}
{[...visibleToolIds].map((toolId, i) => {
{/* Render all visible IDs (including groups with subtools selected) */}
{toolIds.map((toolId, i) => {
if (!allTools) return null;
const tool = allTools[toolId];
if (!tool) return null;
@ -385,8 +376,11 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
</div>
</div>
{/* MCP Section */}
{/* <MCPSection /> */}
<MCPTools
agentId={agent_id}
mcpServerNames={mcpServerNames}
setShowMCPToolDialog={setShowMCPToolDialog}
/>
{/* Support Contact (Optional) */}
<div className="mb-4">
<div className="mb-1.5 flex items-center gap-2">
@ -477,6 +471,13 @@ export default function AgentConfig({ createMutation }: Pick<AgentPanelProps, 'c
setIsOpen={setShowToolDialog}
endpoint={EModelEndpoint.agents}
/>
<MCPToolSelectDialog
agentId={agent_id}
isOpen={showMCPToolDialog}
mcpServerNames={mcpServerNames}
setIsOpen={setShowMCPToolDialog}
endpoint={EModelEndpoint.agents}
/>
</>
);
}

View file

@ -7,6 +7,7 @@ import {
Tools,
Constants,
SystemRoles,
ResourceType,
EModelEndpoint,
PermissionBits,
isAssistantsEndpoint,
@ -53,7 +54,7 @@ export default function AgentPanel() {
});
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'agent',
ResourceType.AGENT,
basicAgentQuery.data?._id || '',
);

View file

@ -0,0 +1,368 @@
import React, { useState } from 'react';
import * as Ariakit from '@ariakit/react';
import { ChevronDown } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import * as AccordionPrimitive from '@radix-ui/react-accordion';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
Checkbox,
OGDialog,
Accordion,
TrashIcon,
AccordionItem,
CircleHelpIcon,
OGDialogTrigger,
useToastContext,
AccordionContent,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function MCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const [accordionValue, setAccordionValue] = useState<string>('');
const [hoveredToolId, setHoveredToolId] = useState<string | null>(null);
if (!serverInfo) {
return null;
}
const currentServerName = serverInfo.serverName;
const getSelectedTools = () => {
if (!serverInfo?.tools) return [];
const formTools = getValues('tools') || [];
return serverInfo.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id);
};
const updateFormTools = (newSelectedTools: string[]) => {
const currentTools = getValues('tools') || [];
const otherTools = currentTools.filter(
(t: string) => !serverInfo?.tools?.some((st) => st.tool_id === t),
);
setValue('tools', [...otherTools, ...newSelectedTools]);
};
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' });
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: 'Tool deleted successfully', status: 'success' });
},
},
);
};
const selectedTools = getSelectedTools();
const isExpanded = accordionValue === currentServerName;
const statusIconProps = getServerStatusIconProps(currentServerName);
const configDialogProps = getConfigDialogProps();
const statusIcon = statusIconProps && (
<div
onClick={(e) => {
e.stopPropagation();
}}
className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary"
>
<MCPServerStatusIcon {...statusIconProps} />
</div>
);
return (
<OGDialog>
<Accordion type="single" value={accordionValue} onValueChange={setAccordionValue} collapsible>
<AccordionItem value={currentServerName} className="group relative w-full border-none">
<div
className="relative flex w-full items-center gap-1 rounded-lg p-1 hover:bg-surface-primary-alt"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<AccordionPrimitive.Header asChild>
<div
className="flex grow cursor-pointer select-none items-center gap-1 rounded bg-transparent p-0 text-left transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1"
onClick={() =>
setAccordionValue((prev) => {
if (prev) {
return '';
}
return currentServerName;
})
}
>
{statusIcon && <div className="flex items-center">{statusIcon}</div>}
{serverInfo.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${serverInfo.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{currentServerName}
</div>
<div className="flex items-center">
<div className="relative flex items-center">
<div
className={cn(
'absolute right-0 transition-all duration-300',
isHovering || isFocused
? 'translate-x-0 opacity-100'
: 'translate-x-8 opacity-0',
)}
>
<div className="flex items-center gap-2">
<div
data-checkbox-container
onClick={(e) => e.stopPropagation()}
className="mt-1"
>
<Checkbox
id={`select-all-${currentServerName}`}
checked={
selectedTools.length === serverInfo.tools?.length &&
selectedTools.length > 0
}
onCheckedChange={(checked) => {
if (serverInfo.tools) {
const newSelectedTools = checked
? serverInfo.tools.map((t) => t.tool_id)
: [
`${Constants.mcp_server}${Constants.mcp_delimiter}${currentServerName}`,
];
updateFormTools(newSelectedTools);
}
}}
className={cn(
'h-4 w-4 rounded border border-border-medium transition-all duration-200 hover:border-border-heavy',
isExpanded ? 'visible' : 'pointer-events-none invisible',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
e.stopPropagation();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
tabIndex={isExpanded ? 0 : -1}
/>
</div>
<div className="flex items-center gap-1">
{/* Caret button for accordion */}
<AccordionPrimitive.Trigger asChild>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
}}
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200 hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isExpanded && 'bg-surface-active-alt',
)}
aria-hidden="true"
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<ChevronDown
className={cn(
'h-4 w-4 transition-transform duration-200',
isExpanded && 'rotate-180',
)}
/>
</button>
</AccordionPrimitive.Trigger>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-colors duration-200',
'hover:bg-surface-active-alt focus:translate-x-0 focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
)}
onClick={(e) => e.stopPropagation()}
aria-label={`Delete ${currentServerName}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
</div>
</div>
</div>
</div>
</div>
</AccordionPrimitive.Header>
</div>
<AccordionContent className="relative ml-1 pt-1 before:absolute before:bottom-2 before:left-0 before:top-0 before:w-0.5 before:bg-border-medium">
<div className="space-y-1">
{serverInfo.tools?.map((subTool) => (
<label
key={subTool.tool_id}
htmlFor={subTool.tool_id}
className={cn(
'border-token-border-light hover:bg-token-surface-secondary flex cursor-pointer items-center rounded-lg border p-2',
'ml-2 mr-1 focus-within:ring-2 focus-within:ring-ring focus-within:ring-offset-2 focus-within:ring-offset-background',
)}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => {
e.stopPropagation();
}}
onMouseEnter={() => setHoveredToolId(subTool.tool_id)}
onMouseLeave={() => setHoveredToolId(null)}
>
<Checkbox
id={subTool.tool_id}
checked={selectedTools.includes(subTool.tool_id)}
onCheckedChange={(_checked) => {
const newSelectedTools = selectedTools.includes(subTool.tool_id)
? selectedTools.filter((t) => t !== subTool.tool_id)
: [...selectedTools, subTool.tool_id];
updateFormTools(newSelectedTools);
}}
onKeyDown={(e) => {
e.stopPropagation();
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
const checkbox = e.currentTarget as HTMLButtonElement;
checkbox.click();
}
}}
onClick={(e) => e.stopPropagation()}
className={cn(
'relative float-left mr-2 inline-flex h-4 w-4 cursor-pointer rounded border border-border-medium transition-[border-color] duration-200 hover:border-border-heavy focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:ring-offset-background',
)}
/>
<span className="text-token-text-primary select-none">
{subTool.metadata.name}
</span>
{subTool.metadata.description && (
<Ariakit.HovercardProvider placement="left-start">
<div className="ml-auto flex h-6 w-6 items-center justify-center">
<Ariakit.HovercardAnchor
render={
<Ariakit.Button
className={cn(
'flex h-5 w-5 cursor-help items-center rounded-full text-text-secondary transition-opacity duration-200',
hoveredToolId === subTool.tool_id ? 'opacity-100' : 'opacity-0',
)}
aria-label={localize('com_ui_tool_info')}
>
<CircleHelpIcon className="h-4 w-4" />
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_info')}
</Ariakit.VisuallyHidden>
</Ariakit.Button>
}
/>
<Ariakit.HovercardDisclosure
className="rounded-full text-text-secondary focus:outline-none focus:ring-2 focus:ring-ring"
aria-label={localize('com_ui_tool_more_info')}
aria-expanded={hoveredToolId === subTool.tool_id}
aria-controls={`tool-description-${subTool.tool_id}`}
>
<Ariakit.VisuallyHidden>
{localize('com_ui_tool_more_info')}
</Ariakit.VisuallyHidden>
<ChevronDown className="h-4 w-4" />
</Ariakit.HovercardDisclosure>
</div>
<Ariakit.Hovercard
id={`tool-description-${subTool.tool_id}`}
gutter={14}
shift={40}
flip={false}
className="z-[999] w-80 scale-95 rounded-2xl border border-border-medium bg-surface-secondary p-4 text-text-primary opacity-0 shadow-md transition-all duration-200 data-[enter]:scale-100 data-[leave]:scale-95 data-[enter]:opacity-100 data-[leave]:opacity-0"
portal={true}
unmountOnHide={true}
role="tooltip"
aria-label={subTool.metadata.description}
>
<div className="space-y-2">
<p className="text-sm text-text-secondary">
{subTool.metadata.description}
</p>
</div>
</Ariakit.Hovercard>
</Ariakit.HovercardProvider>
)}
</label>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(currentServerName),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
</OGDialog>
);
}

View file

@ -0,0 +1,71 @@
import React from 'react';
import UninitializedMCPTool from './UninitializedMCPTool';
import UnconfiguredMCPTool from './UnconfiguredMCPTool';
import { useAgentPanelContext } from '~/Providers';
import { useLocalize } from '~/hooks';
import MCPTool from './MCPTool';
export default function MCPTools({
agentId,
mcpServerNames,
setShowMCPToolDialog,
}: {
agentId: string;
mcpServerNames?: string[];
setShowMCPToolDialog: React.Dispatch<React.SetStateAction<boolean>>;
}) {
const localize = useLocalize();
const { mcpServersMap } = useAgentPanelContext();
return (
<div className="mb-4">
<label className="text-token-text-primary mb-2 block font-medium">
{localize('com_ui_mcp_servers')}
</label>
<div>
<div className="mb-1">
{/* Render servers with selected tools */}
{mcpServerNames?.map((mcpServerName) => {
const serverInfo = mcpServersMap.get(mcpServerName);
if (!serverInfo?.isConfigured) {
return (
<UnconfiguredMCPTool
key={`${mcpServerName}-${agentId}`}
serverName={mcpServerName}
/>
);
}
if (!serverInfo) {
return null;
}
if (serverInfo.isConnected) {
return (
<MCPTool key={`${serverInfo.serverName}-${agentId}`} serverInfo={serverInfo} />
);
}
return (
<UninitializedMCPTool
key={`${serverInfo.serverName}-${agentId}`}
serverInfo={serverInfo}
/>
);
})}
</div>
<div className="mt-2">
<button
type="button"
onClick={() => setShowMCPToolDialog(true)}
className="btn btn-neutral border-token-border-light relative h-9 w-full rounded-lg font-medium"
aria-haspopup="dialog"
>
<div className="flex w-full items-center justify-center gap-2">
{localize('com_assistants_add_mcp_server_tools')}
</div>
</button>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,127 @@
import React, { useState } from 'react';
import { CircleX } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
useToastContext,
OGDialogTrigger,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm } from '~/common';
import { useLocalize } from '~/hooks';
import { cn } from '~/utils';
export default function UnconfiguredMCPTool({ serverName }: { serverName?: string }) {
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
if (!serverName) {
return null;
}
const removeTool = () => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
return (
<OGDialog>
<div
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-surface-primary-alt"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<div className="flex items-center">
<div className="flex h-6 w-6 items-center justify-center rounded p-1">
<CircleX className="h-4 w-4 text-red-500" />
</div>
</div>
<div className="flex grow cursor-not-allowed items-center gap-1 rounded bg-transparent p-0 text-left transition-colors">
<div
className="grow select-none px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{serverName}
<span className="ml-2 text-xs text-text-secondary">
{' - '}
{localize('com_ui_unavailable')}
</span>
</div>
</div>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200 hover:bg-surface-active-alt focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
aria-label={`Delete ${serverName}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>
</OGDialog>
);
}

View file

@ -0,0 +1,183 @@
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { Constants } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import {
Label,
OGDialog,
TrashIcon,
OGDialogTrigger,
useToastContext,
OGDialogTemplate,
} from '@librechat/client';
import type { AgentForm, MCPServerInfo } from '~/common';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useLocalize, useMCPServerManager } from '~/hooks';
import { cn } from '~/utils';
export default function UninitializedMCPTool({ serverInfo }: { serverInfo?: MCPServerInfo }) {
const [isFocused, setIsFocused] = useState(false);
const [isHovering, setIsHovering] = useState(false);
const localize = useLocalize();
const { showToast } = useToastContext();
const updateUserPlugins = useUpdateUserPluginsMutation();
const { getValues, setValue } = useFormContext<AgentForm>();
const { initializeServer, isInitializing, getServerStatusIconProps, getConfigDialogProps } =
useMCPServerManager();
if (!serverInfo) {
return null;
}
const removeTool = (serverName: string) => {
if (!serverName) {
return;
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
showToast({
message: localize('com_ui_delete_tool_error', { error: String(error) }),
status: 'error',
});
},
onSuccess: () => {
const currentTools = getValues('tools');
const remainingToolIds =
currentTools?.filter(
(currentToolId) =>
currentToolId !== serverName &&
!currentToolId.endsWith(`${Constants.mcp_delimiter}${serverName}`),
) || [];
setValue('tools', remainingToolIds);
showToast({ message: localize('com_ui_delete_tool_success'), status: 'success' });
},
},
);
};
const serverName = serverInfo.serverName;
const isServerInitializing = isInitializing(serverName);
const statusIconProps = getServerStatusIconProps(serverName);
const configDialogProps = getConfigDialogProps();
const statusIcon = statusIconProps && (
<div
onClick={(e) => {
e.stopPropagation();
}}
className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary"
>
<MCPServerStatusIcon {...statusIconProps} />
</div>
);
return (
<OGDialog>
<div
className="group relative flex w-full items-center gap-1 rounded-lg p-1 text-sm hover:bg-surface-primary-alt"
onMouseEnter={() => setIsHovering(true)}
onMouseLeave={() => setIsHovering(false)}
onFocus={() => setIsFocused(true)}
onBlur={(e) => {
if (!e.currentTarget.contains(e.relatedTarget)) {
setIsFocused(false);
}
}}
>
<div
className="flex grow cursor-pointer items-center gap-1 rounded bg-transparent p-0 text-left transition-colors"
onClick={(e) => {
if ((e.target as HTMLElement).closest('[data-status-icon]')) {
return;
}
if (!isServerInitializing) {
initializeServer(serverName);
}
}}
role="button"
tabIndex={0}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
if (!isServerInitializing) {
initializeServer(serverName);
}
}
}}
aria-disabled={isServerInitializing}
>
{statusIcon && (
<div className="flex items-center" data-status-icon>
{statusIcon}
</div>
)}
{serverInfo.metadata.icon && (
<div className="flex h-8 w-8 items-center justify-center overflow-hidden rounded-full">
<div
className="flex h-6 w-6 items-center justify-center overflow-hidden rounded-full bg-center bg-no-repeat dark:bg-white/20"
style={{
backgroundImage: `url(${serverInfo.metadata.icon})`,
backgroundSize: 'cover',
}}
/>
</div>
)}
<div
className="grow px-2 py-1.5"
style={{ textOverflow: 'ellipsis', wordBreak: 'break-all', overflow: 'hidden' }}
>
{serverName}
{isServerInitializing && (
<span className="ml-2 text-xs text-text-secondary">
{localize('com_ui_initializing')}
</span>
)}
</div>
</div>
<OGDialogTrigger asChild>
<button
type="button"
className={cn(
'flex h-7 w-7 items-center justify-center rounded transition-all duration-200 hover:bg-surface-active-alt focus:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-1',
isHovering || isFocused ? 'opacity-100' : 'pointer-events-none opacity-0',
)}
aria-label={`Delete ${serverName}`}
tabIndex={0}
onFocus={() => setIsFocused(true)}
>
<TrashIcon className="h-4 w-4" />
</button>
</OGDialogTrigger>
</div>
<OGDialogTemplate
showCloseButton={false}
title={localize('com_ui_delete_tool')}
mainClassName="px-0"
className="max-w-[450px]"
main={
<Label className="text-left text-sm font-medium">
{localize('com_ui_delete_tool_confirm')}
</Label>
}
selection={{
selectHandler: () => removeTool(serverName),
selectClasses:
'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white',
selectText: localize('com_ui_delete'),
}}
/>
{configDialogProps && <MCPConfigDialog {...configDialogProps} />}
</OGDialog>
);
}

View file

@ -6,12 +6,11 @@ import { Constants, QueryKeys } from 'librechat-data-provider';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider';
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
import { useLocalize, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useLocalize } from '~/hooks';
function MCPPanelContent() {
const localize = useLocalize();
@ -19,7 +18,10 @@ function MCPPanelContent() {
const { showToast } = useToastContext();
const { conversationId } = useMCPPanelContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
const { data: connectionStatusData } = useMCPConnectionStatusQuery();
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null,
);
@ -57,11 +59,6 @@ function MCPPanelContent() {
}));
}, [startupConfig?.mcpServers]);
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
);
const handleServerClickToEdit = (serverName: string) => {
setSelectedServerNameForEditing(serverName);
};
@ -125,7 +122,7 @@ function MCPPanelContent() {
);
}
const serverStatus = connectionStatus[selectedServerNameForEditing];
const serverStatus = connectionStatus?.[selectedServerNameForEditing];
return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2">
@ -170,7 +167,7 @@ function MCPPanelContent() {
<div className="h-auto max-w-full overflow-x-hidden py-2">
<div className="space-y-2">
{mcpServerDefinitions.map((server) => {
const serverStatus = connectionStatus[server.serverName];
const serverStatus = connectionStatus?.[server.serverName];
const isConnected = serverStatus?.connectionState === 'connected';
return (