✂️ 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

@ -312,6 +312,16 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
continue; continue;
} else if (tool && cachedTools && mcpToolPattern.test(tool)) { } else if (tool && cachedTools && mcpToolPattern.test(tool)) {
const [toolName, serverName] = tool.split(Constants.mcp_delimiter); const [toolName, serverName] = tool.split(Constants.mcp_delimiter);
if (toolName === Constants.mcp_server) {
/** Placeholder used for UI purposes */
continue;
}
if (serverName && options.req?.config?.mcpConfig?.[serverName] == null) {
logger.warn(
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
);
continue;
}
if (toolName === Constants.mcp_all) { if (toolName === Constants.mcp_all) {
const currentMCPGenerator = async (index) => const currentMCPGenerator = async (index) =>
createMCPTools({ createMCPTools({

View file

@ -187,7 +187,7 @@ const updateUserPluginsController = async (req, res) => {
// Extract server name from pluginKey (format: "mcp_<serverName>") // Extract server name from pluginKey (format: "mcp_<serverName>")
const serverName = pluginKey.replace(Constants.mcp_prefix, ''); const serverName = pluginKey.replace(Constants.mcp_prefix, '');
logger.info( logger.info(
`[updateUserPluginsController] Disconnecting MCP server ${serverName} for user ${user.id} after plugin auth update for ${pluginKey}.`, `[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`,
); );
await mcpManager.disconnectUserConnection(user.id, serverName); await mcpManager.disconnectUserConnection(user.id, serverName);
} }

View file

@ -5,6 +5,7 @@ const { logger } = require('@librechat/data-schemas');
const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api'); const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api');
const { const {
Tools, Tools,
Constants,
SystemRoles, SystemRoles,
FileSources, FileSources,
ResourceType, ResourceType,
@ -69,9 +70,9 @@ const createAgentHandler = async (req, res) => {
for (const tool of tools) { for (const tool of tools) {
if (availableTools[tool]) { if (availableTools[tool]) {
agentData.tools.push(tool); agentData.tools.push(tool);
} } else if (systemTools[tool]) {
agentData.tools.push(tool);
if (systemTools[tool]) { } else if (tool.includes(Constants.mcp_delimiter)) {
agentData.tools.push(tool); agentData.tools.push(tool);
} }
} }

View file

@ -271,6 +271,7 @@ async function createMCPTool({
availableTools: tools, availableTools: tools,
}) { }) {
const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter); const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter);
const availableTools = const availableTools =
tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true })); tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true }));
/** @type {LCTool | undefined} */ /** @type {LCTool | undefined} */

View file

@ -1,11 +1,14 @@
import React, { createContext, useContext, useState } from 'react'; import React, { createContext, useContext, useState, useMemo } from 'react';
import { Constants, EModelEndpoint } from 'librechat-data-provider'; import { Constants, EModelEndpoint } from 'librechat-data-provider';
import type { MCP, Action, TPlugin, AgentToolType } from 'librechat-data-provider'; import type { MCP, Action, TPlugin, AgentToolType } from 'librechat-data-provider';
import type { AgentPanelContextType } from '~/common'; import type { AgentPanelContextType, MCPServerInfo } from '~/common';
import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider'; import { useAvailableToolsQuery, useGetActionsQuery, useGetStartupConfig } from '~/data-provider';
import { useLocalize, useGetAgentsConfig } from '~/hooks'; import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks';
import { Panel } from '~/common'; import { Panel } from '~/common';
type GroupedToolType = AgentToolType & { tools?: AgentToolType[] };
type GroupedToolsRecord = Record<string, GroupedToolType>;
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined); const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
export function useAgentPanelContext() { export function useAgentPanelContext() {
@ -33,67 +36,116 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
enabled: !!agent_id, enabled: !!agent_id,
}); });
const tools = const { data: startupConfig } = useGetStartupConfig();
pluginTools?.map((tool) => ({ const mcpServerNames = useMemo(
tool_id: tool.pluginKey, () => Object.keys(startupConfig?.mcpServers ?? {}),
metadata: tool as TPlugin, [startupConfig],
agent_id: agent_id || '', );
})) || [];
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!agent_id && mcpServerNames.length > 0,
});
const processedData = useMemo(() => {
if (!pluginTools) {
return {
tools: [],
groupedTools: {},
mcpServersMap: new Map<string, MCPServerInfo>(),
};
}
const tools: AgentToolType[] = [];
const groupedTools: GroupedToolsRecord = {};
const configuredServers = new Set(mcpServerNames);
const mcpServersMap = new Map<string, MCPServerInfo>();
for (const pluginTool of pluginTools) {
const tool: AgentToolType = {
tool_id: pluginTool.pluginKey,
metadata: pluginTool as TPlugin,
};
tools.push(tool);
const groupedTools = tools?.reduce(
(acc, tool) => {
if (tool.tool_id.includes(Constants.mcp_delimiter)) { if (tool.tool_id.includes(Constants.mcp_delimiter)) {
const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter); const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter);
const groupKey = `${serverName.toLowerCase()}`;
if (!acc[groupKey]) { if (!mcpServersMap.has(serverName)) {
acc[groupKey] = { const metadata = {
tool_id: groupKey, name: serverName,
metadata: { pluginKey: serverName,
name: `${serverName}`,
pluginKey: groupKey,
description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`,
icon: tool.metadata.icon || '', icon: pluginTool.icon || '',
} as TPlugin, } as TPlugin;
agent_id: agent_id || '',
mcpServersMap.set(serverName, {
serverName,
tools: [], tools: [],
}; isConfigured: configuredServers.has(serverName),
} isConnected: connectionStatus?.[serverName]?.connectionState === 'connected',
acc[groupKey].tools?.push({ metadata,
tool_id: tool.tool_id,
metadata: tool.metadata,
agent_id: agent_id || '',
}); });
}
mcpServersMap.get(serverName)!.tools.push(tool);
} else { } else {
acc[tool.tool_id] = { // Non-MCP tool
groupedTools[tool.tool_id] = {
tool_id: tool.tool_id, tool_id: tool.tool_id,
metadata: tool.metadata, metadata: tool.metadata,
agent_id: agent_id || '',
}; };
} }
return acc; }
},
{} as Record<string, AgentToolType & { tools?: AgentToolType[] }>, for (const mcpServerName of mcpServerNames) {
); if (mcpServersMap.has(mcpServerName)) {
continue;
}
const metadata = {
icon: '',
name: mcpServerName,
pluginKey: mcpServerName,
description: `${localize('com_ui_tool_collection_prefix')} ${mcpServerName}`,
} as TPlugin;
mcpServersMap.set(mcpServerName, {
tools: [],
metadata,
isConfigured: true,
serverName: mcpServerName,
isConnected: connectionStatus?.[mcpServerName]?.connectionState === 'connected',
});
}
return {
tools,
groupedTools,
mcpServersMap,
};
}, [pluginTools, localize, mcpServerNames, connectionStatus]);
const { agentsConfig, endpointsConfig } = useGetAgentsConfig(); const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
const value: AgentPanelContextType = { const value: AgentPanelContextType = {
mcp, mcp,
mcps, mcps,
/** Query data for actions and tools */
tools,
action, action,
setMcp, setMcp,
actions, actions,
setMcps, setMcps,
agent_id, agent_id,
setAction, setAction,
pluginTools,
activePanel, activePanel,
groupedTools,
agentsConfig, agentsConfig,
setActivePanel, setActivePanel,
endpointsConfig, endpointsConfig,
setCurrentAgentId, setCurrentAgentId,
tools: processedData.tools,
groupedTools: processedData.groupedTools,
mcpServersMap: processedData.mcpServersMap,
}; };
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>; return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;

View file

@ -216,6 +216,14 @@ export type AgentPanelProps = {
agentsConfig?: t.TAgentsEndpoint | null; agentsConfig?: t.TAgentsEndpoint | null;
}; };
export interface MCPServerInfo {
serverName: string;
tools: t.AgentToolType[];
isConfigured: boolean;
isConnected: boolean;
metadata: t.TPlugin;
}
export type AgentPanelContextType = { export type AgentPanelContextType = {
action?: t.Action; action?: t.Action;
actions?: t.Action[]; actions?: t.Action[];
@ -225,13 +233,16 @@ export type AgentPanelContextType = {
setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>; setMcp: React.Dispatch<React.SetStateAction<t.MCP | undefined>>;
setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>; setMcps: React.Dispatch<React.SetStateAction<t.MCP[] | undefined>>;
groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>; groupedTools: Record<string, t.AgentToolType & { tools?: t.AgentToolType[] }>;
tools: t.AgentToolType[];
activePanel?: string; activePanel?: string;
tools: t.AgentToolType[];
pluginTools?: t.TPlugin[];
setActivePanel: React.Dispatch<React.SetStateAction<Panel>>; setActivePanel: React.Dispatch<React.SetStateAction<Panel>>;
setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>; setCurrentAgentId: React.Dispatch<React.SetStateAction<string | undefined>>;
agent_id?: string; agent_id?: string;
agentsConfig?: t.TAgentsEndpoint | null; agentsConfig?: t.TAgentsEndpoint | null;
endpointsConfig?: t.TEndpointsConfig | null; endpointsConfig?: t.TEndpointsConfig | null;
/** Pre-computed MCP server information indexed by server key */
mcpServersMap: Map<string, MCPServerInfo>;
}; };
export type AgentModelPanelProps = { export type AgentModelPanelProps = {

View file

@ -1,9 +1,9 @@
import React, { memo, useCallback } from 'react'; import React, { memo, useCallback } from 'react';
import { MultiSelect, MCPIcon } from '@librechat/client'; import { MultiSelect, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useBadgeRowContext } from '~/Providers'; import { useBadgeRowContext } from '~/Providers';
import { useMCPServerManager } from '~/hooks';
type MCPSelectProps = { conversationId?: string | null }; type MCPSelectProps = { conversationId?: string | null };

View file

@ -3,8 +3,8 @@ import * as Ariakit from '@ariakit/react';
import { ChevronRight } from 'lucide-react'; import { ChevronRight } from 'lucide-react';
import { PinIcon, MCPIcon } from '@librechat/client'; import { PinIcon, MCPIcon } from '@librechat/client';
import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager';
import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog';
import { useMCPServerManager } from '~/hooks';
import { cn } from '~/utils'; import { cn } from '~/utils';
interface MCPSubMenuProps { interface MCPSubMenuProps {

View file

@ -88,6 +88,10 @@ export default function ToolCall({
const url = new URL(authURL); const url = new URL(authURL);
return url.hostname; return url.hostname;
} catch (e) { } catch (e) {
logger.error(
'client/src/components/Chat/Messages/Content/ToolCall.tsx - Failed to parse auth URL',
e,
);
return ''; return '';
} }
}, [auth]); }, [auth]);

View file

@ -16,7 +16,6 @@ interface CustomUserVarsSectionProps {
onRevoke: () => void; onRevoke: () => void;
isSubmitting?: boolean; isSubmitting?: boolean;
} }
interface AuthFieldProps { interface AuthFieldProps {
name: string; name: string;
config: CustomUserVarConfig; config: CustomUserVarConfig;
@ -69,7 +68,7 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
? localize('com_ui_mcp_update_var', { 0: config.title }) ? localize('com_ui_mcp_update_var', { 0: config.title })
: localize('com_ui_mcp_enter_var', { 0: config.title }) : localize('com_ui_mcp_enter_var', { 0: config.title })
} }
className="w-full shadow-sm sm:text-sm" className="w-full rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary placeholder:text-text-secondary focus:outline-none sm:text-sm"
/> />
)} )}
/> />
@ -79,23 +78,22 @@ function AuthField({ name, config, hasValue, control, errors }: AuthFieldProps)
} }
export default function CustomUserVarsSection({ export default function CustomUserVarsSection({
serverName,
fields, fields,
onSave, onSave,
onRevoke, onRevoke,
serverName,
isSubmitting = false, isSubmitting = false,
}: CustomUserVarsSectionProps) { }: CustomUserVarsSectionProps) {
const localize = useLocalize(); const localize = useLocalize();
// Fetch auth value flags for the server
const { data: authValuesData } = useMCPAuthValuesQuery(serverName, { const { data: authValuesData } = useMCPAuthValuesQuery(serverName, {
enabled: !!serverName, enabled: !!serverName,
}); });
const { const {
reset,
control, control,
handleSubmit, handleSubmit,
reset,
formState: { errors }, formState: { errors },
} = useForm<Record<string, string>>({ } = useForm<Record<string, string>>({
defaultValues: useMemo(() => { defaultValues: useMemo(() => {
@ -140,10 +138,20 @@ export default function CustomUserVarsSection({
</form> </form>
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button onClick={handleRevokeClick} variant="destructive" disabled={isSubmitting}> <Button
type="button"
variant="destructive"
disabled={isSubmitting}
onClick={handleRevokeClick}
>
{localize('com_ui_revoke')} {localize('com_ui_revoke')}
</Button> </Button>
<Button onClick={handleSubmit(onFormSubmit)} variant="submit" disabled={isSubmitting}> <Button
type="button"
variant="submit"
disabled={isSubmitting}
onClick={handleSubmit(onFormSubmit)}
>
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')} {isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
</Button> </Button>
</div> </div>

View file

@ -1,8 +1,8 @@
import React from 'react'; import React from 'react';
import { RefreshCw } from 'lucide-react'; import { RefreshCw } from 'lucide-react';
import { Button, Spinner } from '@librechat/client'; import { Button, Spinner } from '@librechat/client';
import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager'; import { useLocalize, useMCPServerManager, useMCPConnectionStatus } from '~/hooks';
import { useLocalize } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider';
interface ServerInitializationSectionProps { interface ServerInitializationSectionProps {
sidePanel?: boolean; sidePanel?: boolean;
@ -21,16 +21,15 @@ export default function ServerInitializationSection({
}: ServerInitializationSectionProps) { }: ServerInitializationSectionProps) {
const localize = useLocalize(); const localize = useLocalize();
const { const { initializeServer, cancelOAuthFlow, isInitializing, isCancellable, getOAuthUrl } =
initializeServer, useMCPServerManager({ conversationId });
connectionStatus,
cancelOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
} = useMCPServerManager({ conversationId });
const serverStatus = connectionStatus[serverName]; const { data: startupConfig } = useGetStartupConfig();
const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
});
const serverStatus = connectionStatus?.[serverName];
const isConnected = serverStatus?.connectionState === 'connected'; const isConnected = serverStatus?.connectionState === 'connected';
const canCancel = isCancellable(serverName); const canCancel = isCancellable(serverName);
const isServerInitializing = isInitializing(serverName); const isServerInitializing = isInitializing(serverName);

View file

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

View file

@ -7,6 +7,7 @@ import {
Tools, Tools,
Constants, Constants,
SystemRoles, SystemRoles,
ResourceType,
EModelEndpoint, EModelEndpoint,
PermissionBits, PermissionBits,
isAssistantsEndpoint, isAssistantsEndpoint,
@ -53,7 +54,7 @@ export default function AgentPanel() {
}); });
const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions( const { hasPermission, isLoading: permissionsLoading } = useResourcePermissions(
'agent', ResourceType.AGENT,
basicAgentQuery.data?._id || '', 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 { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider'; import type { TUpdateUserPlugins } from 'librechat-data-provider';
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection'; import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection'; import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers'; import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
import { useLocalize, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton'; import MCPPanelSkeleton from './MCPPanelSkeleton';
import { useLocalize } from '~/hooks';
function MCPPanelContent() { function MCPPanelContent() {
const localize = useLocalize(); const localize = useLocalize();
@ -19,7 +18,10 @@ function MCPPanelContent() {
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const { conversationId } = useMCPPanelContext(); const { conversationId } = useMCPPanelContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig(); 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>( const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null, null,
); );
@ -57,11 +59,6 @@ function MCPPanelContent() {
})); }));
}, [startupConfig?.mcpServers]); }, [startupConfig?.mcpServers]);
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
);
const handleServerClickToEdit = (serverName: string) => { const handleServerClickToEdit = (serverName: string) => {
setSelectedServerNameForEditing(serverName); setSelectedServerNameForEditing(serverName);
}; };
@ -125,7 +122,7 @@ function MCPPanelContent() {
); );
} }
const serverStatus = connectionStatus[selectedServerNameForEditing]; const serverStatus = connectionStatus?.[selectedServerNameForEditing];
return ( return (
<div className="h-auto max-w-full space-y-4 overflow-x-hidden py-2"> <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="h-auto max-w-full overflow-x-hidden py-2">
<div className="space-y-2"> <div className="space-y-2">
{mcpServerDefinitions.map((server) => { {mcpServerDefinitions.map((server) => {
const serverStatus = connectionStatus[server.serverName]; const serverStatus = connectionStatus?.[server.serverName];
const isConnected = serverStatus?.connectionState === 'connected'; const isConnected = serverStatus?.connectionState === 'connected';
return ( return (

View file

@ -0,0 +1,116 @@
import { XCircle, PlusCircleIcon, Wrench } from 'lucide-react';
import type { AgentToolType } from 'librechat-data-provider';
import { useLocalize } from '~/hooks';
type MCPToolItemProps = {
tool: AgentToolType;
onAddTool: () => void;
onRemoveTool: () => void;
isInstalled?: boolean;
isConfiguring?: boolean;
isInitializing?: boolean;
};
function MCPToolItem({
tool,
onAddTool,
onRemoveTool,
isInstalled = false,
isConfiguring = false,
isInitializing = false,
}: MCPToolItemProps) {
const localize = useLocalize();
const handleClick = () => {
if (isInstalled) {
onRemoveTool();
} else {
onAddTool();
}
};
const name = tool.metadata?.name || tool.tool_id;
const description = tool.metadata?.description || '';
const icon = tool.metadata?.icon;
// Determine button state and text
const getButtonState = () => {
if (isInstalled) {
return {
text: localize('com_nav_tool_remove'),
icon: <XCircle className="flex h-4 w-4 items-center stroke-2" />,
className:
'btn relative bg-gray-300 hover:bg-gray-400 dark:bg-gray-50 dark:hover:bg-gray-200',
disabled: false,
};
}
if (isConfiguring) {
return {
text: localize('com_ui_confirm'),
icon: <PlusCircleIcon className="flex h-4 w-4 items-center stroke-2" />,
className: 'btn btn-primary relative',
disabled: false,
};
}
if (isInitializing) {
return {
text: localize('com_ui_initializing'),
icon: <Wrench className="flex h-4 w-4 items-center stroke-2" />,
className: 'btn btn-primary relative opacity-75 cursor-not-allowed',
disabled: true,
};
}
return {
text: localize('com_ui_add'),
icon: <PlusCircleIcon className="flex h-4 w-4 items-center stroke-2" />,
className: 'btn btn-primary relative',
disabled: false,
};
};
const buttonState = getButtonState();
return (
<div className="flex flex-col gap-4 rounded border border-border-medium bg-transparent p-6">
<div className="flex gap-4">
<div className="h-[70px] w-[70px] shrink-0">
<div className="relative h-full w-full">
{icon ? (
<img
src={icon}
alt={localize('com_ui_logo', { 0: name })}
className="h-full w-full rounded-[5px] bg-white"
/>
) : (
<div className="flex h-full w-full items-center justify-center rounded-[5px] border border-border-medium bg-transparent">
<Wrench className="h-8 w-8 text-text-secondary" />
</div>
)}
<div className="absolute inset-0 rounded-[5px] ring-1 ring-inset ring-black/10"></div>
</div>
</div>
<div className="flex min-w-0 flex-col items-start justify-between">
<div className="mb-2 line-clamp-1 max-w-full text-lg leading-5 text-text-primary">
{name}
</div>
<button
className={buttonState.className}
aria-label={`${buttonState.text} ${name}`}
onClick={handleClick}
disabled={buttonState.disabled}
>
<div className="flex w-full items-center justify-center gap-2">
{buttonState.text}
{buttonState.icon}
</div>
</button>
</div>
</div>
<div className="line-clamp-3 h-[60px] text-sm text-text-secondary">{description}</div>
</div>
);
}
export default MCPToolItem;

View file

@ -0,0 +1,370 @@
import { useEffect, useState, useMemo } from 'react';
import { Search, X } from 'lucide-react';
import { useFormContext } from 'react-hook-form';
import { Constants, EModelEndpoint } from 'librechat-data-provider';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { TError, AgentToolType } from 'librechat-data-provider';
import type { AgentForm, TPluginStoreDialogProps } from '~/common';
import { useLocalize, usePluginDialogHelpers, useMCPServerManager } from '~/hooks';
import { useGetStartupConfig, useAvailableToolsQuery } from '~/data-provider';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { PluginPagination } from '~/components/Plugins/Store';
import { useAgentPanelContext } from '~/Providers';
import MCPToolItem from './MCPToolItem';
function MCPToolSelectDialog({
isOpen,
agentId,
setIsOpen,
mcpServerNames,
}: TPluginStoreDialogProps & {
agentId: string;
mcpServerNames?: string[];
endpoint: EModelEndpoint.agents;
}) {
const localize = useLocalize();
const { mcpServersMap } = useAgentPanelContext();
const { initializeServer } = useMCPServerManager();
const { data: startupConfig } = useGetStartupConfig();
const { getValues, setValue } = useFormContext<AgentForm>();
const { refetch: refetchAvailableTools } = useAvailableToolsQuery(EModelEndpoint.agents);
const [isInitializing, setIsInitializing] = useState<string | null>(null);
const [configuringServer, setConfiguringServer] = useState<string | null>(null);
const {
maxPage,
setMaxPage,
currentPage,
setCurrentPage,
itemsPerPage,
searchChanged,
setSearchChanged,
searchValue,
setSearchValue,
gridRef,
handleSearch,
handleChangePage,
error,
setError,
errorMessage,
setErrorMessage,
} = usePluginDialogHelpers();
const updateUserPlugins = useUpdateUserPluginsMutation();
const handleInstallError = (error: TError) => {
setError(true);
const errorMessage = error.response?.data?.message ?? '';
if (errorMessage) {
setErrorMessage(errorMessage);
}
setTimeout(() => {
setError(false);
setErrorMessage('');
}, 5000);
};
const handleDirectAdd = async (serverName: string) => {
try {
setIsInitializing(serverName);
const serverInfo = mcpServersMap.get(serverName);
if (!serverInfo?.isConnected) {
const result = await initializeServer(serverName);
if (result?.success && result.oauthRequired && result.oauthUrl) {
setIsInitializing(null);
return;
}
}
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'install',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => {
handleInstallError(error as TError);
setIsInitializing(null);
},
onSuccess: async () => {
const { data: updatedAvailableTools } = await refetchAvailableTools();
const currentTools = getValues('tools') || [];
const toolsToAdd: string[] = [
`${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`,
];
if (updatedAvailableTools) {
updatedAvailableTools.forEach((tool) => {
if (tool.pluginKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) {
toolsToAdd.push(tool.pluginKey);
}
});
}
const newTools = toolsToAdd.filter((tool) => !currentTools.includes(tool));
if (newTools.length > 0) {
setValue('tools', [...currentTools, ...newTools]);
}
setIsInitializing(null);
},
},
);
} catch (error) {
console.error('Error adding MCP server:', error);
}
};
const handleSaveCustomVars = async (serverName: string, authData: Record<string, string>) => {
try {
await updateUserPlugins.mutateAsync({
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'install',
auth: authData,
isEntityTool: true,
});
await handleDirectAdd(serverName);
setConfiguringServer(null);
} catch (error) {
console.error('Error saving custom vars:', error);
}
};
const handleRevokeCustomVars = (serverName: string) => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => handleInstallError(error as TError),
onSuccess: () => {
setConfiguringServer(null);
},
},
);
};
const onAddTool = async (serverName: string) => {
if (configuringServer === serverName) {
setConfiguringServer(null);
await handleDirectAdd(serverName);
return;
}
const serverConfig = startupConfig?.mcpServers?.[serverName];
const hasCustomUserVars =
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
if (hasCustomUserVars) {
setConfiguringServer(serverName);
} else {
await handleDirectAdd(serverName);
}
};
const onRemoveTool = (serverName: string) => {
updateUserPlugins.mutate(
{
pluginKey: `${Constants.mcp_prefix}${serverName}`,
action: 'uninstall',
auth: {},
isEntityTool: true,
},
{
onError: (error: unknown) => handleInstallError(error as TError),
onSuccess: () => {
const currentTools = getValues('tools') || [];
const remainingTools = currentTools.filter(
(tool) =>
tool !== serverName && !tool.endsWith(`${Constants.mcp_delimiter}${serverName}`),
);
setValue('tools', remainingTools);
},
},
);
};
const installedToolsSet = useMemo(() => {
return new Set(mcpServerNames);
}, [mcpServerNames]);
const mcpServers = useMemo(() => {
const servers = Array.from(mcpServersMap.values());
return servers.sort((a, b) => a.serverName.localeCompare(b.serverName));
}, [mcpServersMap]);
const filteredServers = useMemo(() => {
if (!searchValue) {
return mcpServers;
}
return mcpServers.filter((serverInfo) =>
serverInfo.serverName.toLowerCase().includes(searchValue.toLowerCase()),
);
}, [mcpServers, searchValue]);
useEffect(() => {
setMaxPage(Math.ceil(filteredServers.length / itemsPerPage));
if (searchChanged) {
setCurrentPage(1);
setSearchChanged(false);
}
}, [
setMaxPage,
itemsPerPage,
searchChanged,
setCurrentPage,
setSearchChanged,
filteredServers.length,
]);
return (
<Dialog
open={isOpen}
onClose={() => {
setIsOpen(false);
setCurrentPage(1);
setSearchValue('');
setConfiguringServer(null);
setIsInitializing(null);
}}
className="relative z-[102]"
>
<div className="fixed inset-0 bg-surface-primary opacity-60 transition-opacity dark:opacity-80" />
<div className="fixed inset-0 flex items-center justify-center p-4">
<DialogPanel
className="relative max-h-[90vh] w-full transform overflow-hidden overflow-y-auto rounded-lg bg-surface-secondary text-left shadow-xl transition-all max-sm:h-full sm:mx-7 sm:my-8 sm:max-w-2xl lg:max-w-5xl xl:max-w-7xl"
style={{ minHeight: '610px' }}
>
<div className="flex items-center justify-between border-b-[1px] border-border-medium px-4 pb-4 pt-5 sm:p-6">
<div className="flex items-center">
<div className="text-center sm:text-left">
<DialogTitle className="text-lg font-medium leading-6 text-text-primary">
{localize('com_nav_tool_dialog_mcp_server_tools')}
</DialogTitle>
<Description className="text-sm text-text-secondary">
{localize('com_nav_tool_dialog_description')}
</Description>
</div>
</div>
<div>
<button
onClick={() => {
setIsOpen(false);
setCurrentPage(1);
setConfiguringServer(null);
setIsInitializing(null);
}}
className="inline-block rounded-full text-text-secondary transition-colors hover:text-text-primary"
aria-label="Close dialog"
type="button"
>
<X aria-hidden="true" />
</button>
</div>
</div>
{error && (
<div
className="relative m-4 rounded border border-red-400 bg-red-100 px-4 py-3 text-red-700"
role="alert"
>
{localize('com_nav_plugin_auth_error')} {errorMessage}
</div>
)}
{configuringServer && (
<div className="p-4 sm:p-6 sm:pt-4">
<div className="mb-4">
<p className="text-sm text-text-secondary">
{localize('com_ui_mcp_configure_server_description', { 0: configuringServer })}
</p>
</div>
<CustomUserVarsSection
serverName={configuringServer}
fields={startupConfig?.mcpServers?.[configuringServer]?.customUserVars || {}}
onSave={(authData) => handleSaveCustomVars(configuringServer, authData)}
onRevoke={() => handleRevokeCustomVars(configuringServer)}
isSubmitting={updateUserPlugins.isLoading}
/>
</div>
)}
<div className="p-4 sm:p-6 sm:pt-4">
<div className="mt-4 flex flex-col gap-4">
<div
className="flex items-center justify-center space-x-4"
onClick={() => setConfiguringServer(null)}
>
<Search className="h-6 w-6 text-text-tertiary" />
<input
type="text"
value={searchValue}
onChange={handleSearch}
placeholder={localize('com_nav_tool_search')}
className="w-64 rounded border border-border-medium bg-transparent px-2 py-1 text-text-primary focus:outline-none"
/>
</div>
<div
ref={gridRef}
className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4"
style={{ minHeight: '410px' }}
>
{filteredServers
.slice((currentPage - 1) * itemsPerPage, currentPage * itemsPerPage)
.map((serverInfo) => {
const isInstalled = installedToolsSet.has(serverInfo.serverName);
const isConfiguring = configuringServer === serverInfo.serverName;
const isServerInitializing = isInitializing === serverInfo.serverName;
const tool: AgentToolType = {
agent_id: agentId,
tool_id: serverInfo.serverName,
metadata: {
...serverInfo.metadata,
description: `${localize('com_ui_tool_collection_prefix')} ${serverInfo.serverName}`,
},
};
return (
<MCPToolItem
tool={tool}
isInstalled={isInstalled}
key={serverInfo.serverName}
isConfiguring={isConfiguring}
isInitializing={isServerInitializing}
onAddTool={() => onAddTool(serverInfo.serverName)}
onRemoveTool={() => onRemoveTool(serverInfo.serverName)}
/>
);
})}
</div>
</div>
<div className="mt-2 flex flex-col items-center gap-2 sm:flex-row sm:justify-between">
{maxPage > 0 ? (
<PluginPagination
currentPage={currentPage}
maxPage={maxPage}
onChangePage={handleChangePage}
/>
) : (
<div style={{ height: '21px' }}></div>
)}
</div>
</div>
</DialogPanel>
</div>
</Dialog>
);
}
export default MCPToolSelectDialog;

View file

@ -1,7 +1,7 @@
import { useEffect } from 'react'; import { useEffect } from 'react';
import { Search, X } from 'lucide-react'; import { Search, X } from 'lucide-react';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { Constants, isAgentsEndpoint } from 'librechat-data-provider'; import { isAgentsEndpoint } from 'librechat-data-provider';
import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react'; import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
import type { import type {
@ -15,7 +15,6 @@ import type { AgentForm, TPluginStoreDialogProps } from '~/common';
import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store'; import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store';
import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; import { useAgentPanelContext } from '~/Providers/AgentPanelContext';
import { useLocalize, usePluginDialogHelpers } from '~/hooks'; import { useLocalize, usePluginDialogHelpers } from '~/hooks';
import { useAvailableToolsQuery } from '~/data-provider';
import ToolItem from './ToolItem'; import ToolItem from './ToolItem';
function ToolSelectDialog({ function ToolSelectDialog({
@ -26,10 +25,9 @@ function ToolSelectDialog({
endpoint: AssistantsEndpoint | EModelEndpoint.agents; endpoint: AssistantsEndpoint | EModelEndpoint.agents;
}) { }) {
const localize = useLocalize(); const localize = useLocalize();
const { getValues, setValue } = useFormContext<AgentForm>();
const { data: tools } = useAvailableToolsQuery(endpoint);
const { groupedTools } = useAgentPanelContext();
const isAgentTools = isAgentsEndpoint(endpoint); const isAgentTools = isAgentsEndpoint(endpoint);
const { getValues, setValue } = useFormContext<AgentForm>();
const { groupedTools, pluginTools } = useAgentPanelContext();
const { const {
maxPage, maxPage,
@ -121,17 +119,10 @@ function ToolSelectDialog({
const onAddTool = (pluginKey: string) => { const onAddTool = (pluginKey: string) => {
setShowPluginAuthForm(false); setShowPluginAuthForm(false);
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey); const availablePluginFromKey = pluginTools?.find((p) => p.pluginKey === pluginKey);
setSelectedPlugin(getAvailablePluginFromKey); setSelectedPlugin(availablePluginFromKey);
const isMCPTool = pluginKey.includes(Constants.mcp_delimiter); const { authConfig, authenticated = false } = availablePluginFromKey ?? {};
if (isMCPTool) {
// MCP tools have their variables configured elsewhere (e.g., MCPPanel or MCPSelect),
// so we directly proceed to install without showing the auth form.
handleInstall({ pluginKey, action: 'install', auth: {} });
} else {
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
if (authConfig && authConfig.length > 0 && !authenticated) { if (authConfig && authConfig.length > 0 && !authenticated) {
setShowPluginAuthForm(true); setShowPluginAuthForm(true);
} else { } else {
@ -141,18 +132,15 @@ function ToolSelectDialog({
auth: {}, auth: {},
}); });
} }
}
}; };
const filteredTools = Object.values(groupedTools || {}).filter( const filteredTools = Object.values(groupedTools || {}).filter(
(tool: AgentToolType & { tools?: AgentToolType[] }) => { (currentTool: AgentToolType & { tools?: AgentToolType[] }) => {
// Check if the parent tool matches if (currentTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
if (tool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) {
return true; return true;
} }
// Check if any child tools match if (currentTool.tools) {
if (tool.tools) { return currentTool.tools.some((childTool) =>
return tool.tools.some((childTool) =>
childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()), childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()),
); );
} }
@ -169,9 +157,9 @@ function ToolSelectDialog({
} }
} }
}, [ }, [
tools, pluginTools,
itemsPerPage,
searchValue, searchValue,
itemsPerPage,
filteredTools, filteredTools,
searchChanged, searchChanged,
setMaxPage, setMaxPage,

View file

@ -1,2 +1,3 @@
export { default as MCPToolSelectDialog } from './MCPToolSelectDialog';
export { default as ToolSelectDialog } from './ToolSelectDialog'; export { default as ToolSelectDialog } from './ToolSelectDialog';
export { default as ToolItem } from './ToolItem'; export { default as ToolItem } from './ToolItem';

View file

@ -1,3 +1,5 @@
export * from './useMCPSelect';
export * from './useGetMCPTools'; export * from './useGetMCPTools';
export * from './useMCPConnectionStatus';
export * from './useMCPSelect';
export * from './useVisibleTools';
export { useMCPServerManager } from './useMCPServerManager'; export { useMCPServerManager } from './useMCPServerManager';

View file

@ -0,0 +1,11 @@
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries';
export function useMCPConnectionStatus({ enabled }: { enabled?: boolean } = {}) {
const { data } = useMCPConnectionStatusQuery({
enabled,
});
return {
connectionStatus: data?.connectionStatus,
};
}

View file

@ -9,8 +9,7 @@ import {
} from 'librechat-data-provider/react-query'; } from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider'; import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider';
import type { ConfigFieldDetail } from '~/common'; import type { ConfigFieldDetail } from '~/common';
import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries'; import { useLocalize, useMCPSelect, useGetMCPTools, useMCPConnectionStatus } from '~/hooks';
import { useLocalize, useMCPSelect, useGetMCPTools } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
interface ServerState { interface ServerState {
@ -21,7 +20,7 @@ interface ServerState {
pollInterval: NodeJS.Timeout | null; pollInterval: NodeJS.Timeout | null;
} }
export function useMCPServerManager({ conversationId }: { conversationId?: string | null }) { export function useMCPServerManager({ conversationId }: { conversationId?: string | null } = {}) {
const localize = useLocalize(); const localize = useLocalize();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
@ -83,13 +82,9 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return initialStates; return initialStates;
}); });
const { data: connectionStatusData } = useMCPConnectionStatusQuery({ const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0, enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
}); });
const connectionStatus = useMemo(
() => connectionStatusData?.connectionStatus || {},
[connectionStatusData?.connectionStatus],
);
/** Filter disconnected servers when values change, but only after initial load /** Filter disconnected servers when values change, but only after initial load
This prevents clearing selections on page refresh when servers haven't connected yet This prevents clearing selections on page refresh when servers haven't connected yet
@ -97,7 +92,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const hasInitialLoadCompleted = useRef(false); const hasInitialLoadCompleted = useRef(false);
useEffect(() => { useEffect(() => {
if (!connectionStatusData || Object.keys(connectionStatus).length === 0) { if (!connectionStatus || Object.keys(connectionStatus).length === 0) {
return; return;
} }
@ -115,7 +110,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
if (connectedSelected.length !== mcpValues.length) { if (connectedSelected.length !== mcpValues.length) {
setMCPValues(connectedSelected); setMCPValues(connectedSelected);
} }
}, [connectionStatus, connectionStatusData, mcpValues, setMCPValues]); }, [connectionStatus, mcpValues, setMCPValues]);
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => { const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
setServerStates((prev) => { setServerStates((prev) => {
@ -229,11 +224,17 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const initializeServer = useCallback( const initializeServer = useCallback(
async (serverName: string, autoOpenOAuth: boolean = true) => { async (serverName: string, autoOpenOAuth: boolean = true) => {
updateServerState(serverName, { isInitializing: true }); updateServerState(serverName, { isInitializing: true });
try { try {
const response = await reinitializeMutation.mutateAsync(serverName); const response = await reinitializeMutation.mutateAsync(serverName);
if (!response.success) {
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
cleanupServerState(serverName);
return response;
}
if (response.success) {
if (response.oauthRequired && response.oauthUrl) { if (response.oauthRequired && response.oauthUrl) {
updateServerState(serverName, { updateServerState(serverName, {
oauthUrl: response.oauthUrl, oauthUrl: response.oauthUrl,
@ -248,7 +249,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
startServerPolling(serverName); startServerPolling(serverName);
} else { } else {
await queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]); await queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]);
showToast({ showToast({
message: localize('com_ui_mcp_initialized_success', { 0: serverName }), message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
@ -262,13 +263,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
cleanupServerState(serverName); cleanupServerState(serverName);
} }
} else { return response;
showToast({
message: localize('com_ui_mcp_init_failed', { 0: serverName }),
status: 'error',
});
cleanupServerState(serverName);
}
} catch (error) { } catch (error) {
console.error(`[MCP Manager] Failed to initialize ${serverName}:`, error); console.error(`[MCP Manager] Failed to initialize ${serverName}:`, error);
showToast({ showToast({
@ -351,7 +346,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return; return;
} }
const serverStatus = connectionStatus[serverName]; const serverStatus = connectionStatus?.[serverName];
if (serverStatus?.connectionState === 'connected') { if (serverStatus?.connectionState === 'connected') {
connectedServers.push(serverName); connectedServers.push(serverName);
} else { } else {
@ -381,7 +376,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const filteredValues = currentValues.filter((name) => name !== serverName); const filteredValues = currentValues.filter((name) => name !== serverName);
setMCPValues(filteredValues); setMCPValues(filteredValues);
} else { } else {
const serverStatus = connectionStatus[serverName]; const serverStatus = connectionStatus?.[serverName];
if (serverStatus?.connectionState === 'connected') { if (serverStatus?.connectionState === 'connected') {
setMCPValues([...currentValues, serverName]); setMCPValues([...currentValues, serverName]);
} else { } else {
@ -455,7 +450,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const getServerStatusIconProps = useCallback( const getServerStatusIconProps = useCallback(
(serverName: string) => { (serverName: string) => {
const tool = mcpToolDetails?.find((t) => t.name === serverName); const tool = mcpToolDetails?.find((t) => t.name === serverName);
const serverStatus = connectionStatus[serverName]; const serverStatus = connectionStatus?.[serverName];
const serverConfig = startupConfig?.mcpServers?.[serverName]; const serverConfig = startupConfig?.mcpServers?.[serverName];
const handleConfigClick = (e: React.MouseEvent) => { const handleConfigClick = (e: React.MouseEvent) => {
@ -532,7 +527,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return { return {
serverName: selectedToolForConfig.name, serverName: selectedToolForConfig.name,
serverStatus: connectionStatus[selectedToolForConfig.name], serverStatus: connectionStatus?.[selectedToolForConfig.name],
isOpen: isConfigModalOpen, isOpen: isConfigModalOpen,
onOpenChange: handleDialogOpenChange, onOpenChange: handleDialogOpenChange,
fieldsSchema, fieldsSchema,
@ -553,7 +548,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
return { return {
configuredServers, configuredServers,
connectionStatus,
initializeServer, initializeServer,
cancelOAuthFlow, cancelOAuthFlow,
isInitializing, isInitializing,

View file

@ -0,0 +1,79 @@
import { useMemo } from 'react';
import { Constants } from 'librechat-data-provider';
import type { AgentToolType } from 'librechat-data-provider';
import type { MCPServerInfo } from '~/common';
type GroupedToolType = AgentToolType & { tools?: AgentToolType[] };
type GroupedToolsRecord = Record<string, GroupedToolType>;
interface VisibleToolsResult {
toolIds: string[];
mcpServerNames: string[];
}
/**
* Custom hook to calculate visible tool IDs based on selected tools and their parent groups.
* If any subtool of a group is selected, the parent group tool is also made visible.
*
* @param selectedToolIds - Array of selected tool IDs
* @param allTools - Record of all available tools
* @param mcpServersMap - Map of all MCP servers
* @returns Object containing separate arrays of visible tool IDs for regular and MCP tools
*/
export function useVisibleTools(
selectedToolIds: string[] | undefined,
allTools: GroupedToolsRecord | undefined,
mcpServersMap: Map<string, MCPServerInfo>,
): VisibleToolsResult {
return useMemo(() => {
const mcpServers = new Set<string>();
const selectedSet = new Set<string>();
const regularToolIds = new Set<string>();
for (const toolId of selectedToolIds ?? []) {
if (!toolId.includes(Constants.mcp_delimiter)) {
selectedSet.add(toolId);
continue;
}
const serverName = toolId.split(Constants.mcp_delimiter)[1];
if (!serverName) {
continue;
}
mcpServers.add(serverName);
}
if (allTools) {
for (const [toolId, toolObj] of Object.entries(allTools)) {
if (selectedSet.has(toolId)) {
regularToolIds.add(toolId);
}
if (toolObj.tools?.length) {
for (const subtool of toolObj.tools) {
if (selectedSet.has(subtool.tool_id)) {
regularToolIds.add(toolId);
break;
}
}
}
}
}
if (mcpServersMap) {
for (const [mcpServerName] of mcpServersMap) {
if (mcpServers.has(mcpServerName)) {
continue;
}
/** Legacy check */
if (selectedSet.has(mcpServerName)) {
mcpServers.add(mcpServerName);
}
}
}
return {
toolIds: Array.from(regularToolIds).sort((a, b) => a.localeCompare(b)),
mcpServerNames: Array.from(mcpServers).sort((a, b) => a.localeCompare(b)),
};
}, [allTools, mcpServersMap, selectedToolIds]);
}

View file

@ -104,6 +104,7 @@
"com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's", "com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's",
"com_assistants_add_actions": "Add Actions", "com_assistants_add_actions": "Add Actions",
"com_assistants_add_tools": "Add Tools", "com_assistants_add_tools": "Add Tools",
"com_assistants_add_mcp_server_tools": "Add MCP Server Tools",
"com_assistants_allow_sites_you_trust": "Only allow sites you trust.", "com_assistants_allow_sites_you_trust": "Only allow sites you trust.",
"com_assistants_append_date": "Append Current Date & Time", "com_assistants_append_date": "Append Current Date & Time",
"com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.", "com_assistants_append_date_tooltip": "When enabled, the current client date and time will be appended to the assistant system instructions.",
@ -579,7 +580,8 @@
"com_nav_theme_system": "System", "com_nav_theme_system": "System",
"com_nav_tool_dialog": "Assistant Tools", "com_nav_tool_dialog": "Assistant Tools",
"com_nav_tool_dialog_agents": "Agent Tools", "com_nav_tool_dialog_agents": "Agent Tools",
"com_nav_tool_dialog_description": "Assistant must be saved to persist tool selections.", "com_nav_tool_dialog_mcp_server_tools": "MCP Server Tools",
"com_nav_tool_dialog_description": "Agent must be saved to persist tool selections.",
"com_nav_tool_remove": "Remove", "com_nav_tool_remove": "Remove",
"com_nav_tool_search": "Search tools", "com_nav_tool_search": "Search tools",
"com_nav_user": "USER", "com_nav_user": "USER",
@ -769,6 +771,7 @@
"com_ui_confirm_action": "Confirm Action", "com_ui_confirm_action": "Confirm Action",
"com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?", "com_ui_confirm_admin_use_change": "Changing this setting will block access for admins, including yourself. Are you sure you want to proceed?",
"com_ui_confirm_change": "Confirm Change", "com_ui_confirm_change": "Confirm Change",
"com_ui_confirm": "Confirm",
"com_ui_connecting": "Connecting", "com_ui_connecting": "Connecting",
"com_ui_context": "Context", "com_ui_context": "Context",
"com_ui_continue": "Continue", "com_ui_continue": "Continue",
@ -830,6 +833,8 @@
"com_ui_delete_success": "Successfully deleted", "com_ui_delete_success": "Successfully deleted",
"com_ui_delete_tool": "Delete Tool", "com_ui_delete_tool": "Delete Tool",
"com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?", "com_ui_delete_tool_confirm": "Are you sure you want to delete this tool?",
"com_ui_delete_tool_error": "Error while deleting the tool: {{error}}",
"com_ui_delete_tool_success": "Tool deleted successfully",
"com_ui_deleted": "Deleted", "com_ui_deleted": "Deleted",
"com_ui_deleting_file": "Deleting file...", "com_ui_deleting_file": "Deleting file...",
"com_ui_descending": "Desc", "com_ui_descending": "Desc",
@ -947,6 +952,7 @@
"com_ui_image_gen": "Image Gen", "com_ui_image_gen": "Image Gen",
"com_ui_import": "Import", "com_ui_import": "Import",
"com_ui_import_conversation_error": "There was an error importing your conversations", "com_ui_import_conversation_error": "There was an error importing your conversations",
"com_ui_initializing": "Initializing...",
"com_ui_import_conversation_file_type_error": "Unsupported import type", "com_ui_import_conversation_file_type_error": "Unsupported import type",
"com_ui_import_conversation_info": "Import conversations from a JSON file", "com_ui_import_conversation_info": "Import conversations from a JSON file",
"com_ui_import_conversation_success": "Conversations imported successfully", "com_ui_import_conversation_success": "Conversations imported successfully",
@ -1202,6 +1208,7 @@
"com_ui_unarchive": "Unarchive", "com_ui_unarchive": "Unarchive",
"com_ui_unarchive_error": "Failed to unarchive conversation", "com_ui_unarchive_error": "Failed to unarchive conversation",
"com_ui_unknown": "Unknown", "com_ui_unknown": "Unknown",
"com_ui_unavailable": "Unavailable",
"com_ui_unset": "Unset", "com_ui_unset": "Unset",
"com_ui_untitled": "Untitled", "com_ui_untitled": "Untitled",
"com_ui_update": "Update", "com_ui_update": "Update",
@ -1265,5 +1272,7 @@
"com_ui_x_selected": "{{0}} selected", "com_ui_x_selected": "{{0}} selected",
"com_ui_yes": "Yes", "com_ui_yes": "Yes",
"com_ui_zoom": "Zoom", "com_ui_zoom": "Zoom",
"com_ui_mcp_configure_server": "Configure {{0}}",
"com_ui_mcp_configure_server_description": "Configure custom variables for {{0}}",
"com_user_message": "You" "com_user_message": "You"
} }

View file

@ -6,11 +6,11 @@ import { cn } from '~/utils';
import './Tooltip.css'; import './Tooltip.css';
interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps { interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps {
description: string;
side?: 'top' | 'bottom' | 'left' | 'right';
className?: string;
role?: string; role?: string;
className?: string;
description: string;
enableHTML?: boolean; enableHTML?: boolean;
side?: 'top' | 'bottom' | 'left' | 'right';
} }
export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor( export const TooltipAnchor = forwardRef<HTMLDivElement, TooltipAnchorProps>(function TooltipAnchor(

View file

@ -1559,8 +1559,13 @@ export enum Constants {
mcp_delimiter = '_mcp_', mcp_delimiter = '_mcp_',
/** Prefix for MCP plugins */ /** Prefix for MCP plugins */
mcp_prefix = 'mcp_', mcp_prefix = 'mcp_',
/** Unique value to indicate all MCP servers */ /** Unique value to indicate all MCP servers. For backend use only. */
mcp_all = 'sys__all__sys', mcp_all = 'sys__all__sys',
/**
* Unique value to indicate the MCP tool was added to an agent.
* This helps inform the UI if the mcp server was previously added.
* */
mcp_server = 'sys__server__sys',
/** Placeholder Agent ID for Ephemeral Agents */ /** Placeholder Agent ID for Ephemeral Agents */
EPHEMERAL_AGENT_ID = 'ephemeral', EPHEMERAL_AGENT_ID = 'ephemeral',
} }

View file

@ -335,7 +335,7 @@ export type ActionMetadataRuntime = ActionMetadata & {
export type MCP = { export type MCP = {
mcp_id: string; mcp_id: string;
metadata: MCPMetadata; metadata: MCPMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string }); } & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id?: string });
export type MCPMetadata = Omit<ActionMetadata, 'auth'> & { export type MCPMetadata = Omit<ActionMetadata, 'auth'> & {
name?: string; name?: string;
@ -352,6 +352,6 @@ export type MCPAuth = ActionAuth;
export type AgentToolType = { export type AgentToolType = {
tool_id: string; tool_id: string;
metadata: ToolMetadata; metadata: ToolMetadata;
} & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id: string }); } & ({ assistant_id: string; agent_id?: never } | { assistant_id?: never; agent_id?: string });
export type ToolMetadata = TPlugin; export type ToolMetadata = TPlugin;