diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 522972fe63..a6950b1262 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -312,6 +312,16 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })} continue; } else if (tool && cachedTools && mcpToolPattern.test(tool)) { 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) { const currentMCPGenerator = async (index) => createMCPTools({ diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index 58818e8d36..99230afd00 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -187,7 +187,7 @@ const updateUserPluginsController = async (req, res) => { // Extract server name from pluginKey (format: "mcp_") const serverName = pluginKey.replace(Constants.mcp_prefix, ''); 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); } diff --git a/api/server/controllers/agents/v1.js b/api/server/controllers/agents/v1.js index 33443cd8a0..eb98c5adb0 100644 --- a/api/server/controllers/agents/v1.js +++ b/api/server/controllers/agents/v1.js @@ -5,6 +5,7 @@ const { logger } = require('@librechat/data-schemas'); const { agentCreateSchema, agentUpdateSchema } = require('@librechat/api'); const { Tools, + Constants, SystemRoles, FileSources, ResourceType, @@ -69,9 +70,9 @@ const createAgentHandler = async (req, res) => { for (const tool of tools) { if (availableTools[tool]) { agentData.tools.push(tool); - } - - if (systemTools[tool]) { + } else if (systemTools[tool]) { + agentData.tools.push(tool); + } else if (tool.includes(Constants.mcp_delimiter)) { agentData.tools.push(tool); } } diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 59492f00c1..3521f19abe 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -271,6 +271,7 @@ async function createMCPTool({ availableTools: tools, }) { const [toolName, serverName] = toolKey.split(Constants.mcp_delimiter); + const availableTools = tools ?? (await getCachedTools({ userId: req.user?.id, includeGlobal: true })); /** @type {LCTool | undefined} */ diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 409d8998fb..eb64011b32 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -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 type { MCP, Action, TPlugin, AgentToolType } from 'librechat-data-provider'; -import type { AgentPanelContextType } from '~/common'; -import { useAvailableToolsQuery, useGetActionsQuery } from '~/data-provider'; -import { useLocalize, useGetAgentsConfig } from '~/hooks'; +import type { AgentPanelContextType, MCPServerInfo } from '~/common'; +import { useAvailableToolsQuery, useGetActionsQuery, useGetStartupConfig } from '~/data-provider'; +import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks'; import { Panel } from '~/common'; +type GroupedToolType = AgentToolType & { tools?: AgentToolType[] }; +type GroupedToolsRecord = Record; + const AgentPanelContext = createContext(undefined); export function useAgentPanelContext() { @@ -33,67 +36,116 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) enabled: !!agent_id, }); - const tools = - pluginTools?.map((tool) => ({ - tool_id: tool.pluginKey, - metadata: tool as TPlugin, - agent_id: agent_id || '', - })) || []; + const { data: startupConfig } = useGetStartupConfig(); + const mcpServerNames = useMemo( + () => Object.keys(startupConfig?.mcpServers ?? {}), + [startupConfig], + ); + + const { connectionStatus } = useMCPConnectionStatus({ + enabled: !!agent_id && mcpServerNames.length > 0, + }); + + const processedData = useMemo(() => { + if (!pluginTools) { + return { + tools: [], + groupedTools: {}, + mcpServersMap: new Map(), + }; + } + + const tools: AgentToolType[] = []; + const groupedTools: GroupedToolsRecord = {}; + + const configuredServers = new Set(mcpServerNames); + const mcpServersMap = new Map(); + + 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)) { const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter); - const groupKey = `${serverName.toLowerCase()}`; - if (!acc[groupKey]) { - acc[groupKey] = { - tool_id: groupKey, - metadata: { - name: `${serverName}`, - pluginKey: groupKey, - description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, - icon: tool.metadata.icon || '', - } as TPlugin, - agent_id: agent_id || '', + + if (!mcpServersMap.has(serverName)) { + const metadata = { + name: serverName, + pluginKey: serverName, + description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, + icon: pluginTool.icon || '', + } as TPlugin; + + mcpServersMap.set(serverName, { + serverName, tools: [], - }; + isConfigured: configuredServers.has(serverName), + isConnected: connectionStatus?.[serverName]?.connectionState === 'connected', + metadata, + }); } - acc[groupKey].tools?.push({ - tool_id: tool.tool_id, - metadata: tool.metadata, - agent_id: agent_id || '', - }); + + mcpServersMap.get(serverName)!.tools.push(tool); } else { - acc[tool.tool_id] = { + // Non-MCP tool + groupedTools[tool.tool_id] = { tool_id: tool.tool_id, metadata: tool.metadata, - agent_id: agent_id || '', }; } - return acc; - }, - {} as Record, - ); + } + + 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 value: AgentPanelContextType = { mcp, mcps, - /** Query data for actions and tools */ - tools, action, setMcp, actions, setMcps, agent_id, setAction, + pluginTools, activePanel, - groupedTools, agentsConfig, setActivePanel, endpointsConfig, setCurrentAgentId, + tools: processedData.tools, + groupedTools: processedData.groupedTools, + mcpServersMap: processedData.mcpServersMap, }; return {children}; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index daa56f71cc..3156ccb444 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -216,6 +216,14 @@ export type AgentPanelProps = { agentsConfig?: t.TAgentsEndpoint | null; }; +export interface MCPServerInfo { + serverName: string; + tools: t.AgentToolType[]; + isConfigured: boolean; + isConnected: boolean; + metadata: t.TPlugin; +} + export type AgentPanelContextType = { action?: t.Action; actions?: t.Action[]; @@ -225,13 +233,16 @@ export type AgentPanelContextType = { setMcp: React.Dispatch>; setMcps: React.Dispatch>; groupedTools: Record; - tools: t.AgentToolType[]; activePanel?: string; + tools: t.AgentToolType[]; + pluginTools?: t.TPlugin[]; setActivePanel: React.Dispatch>; setCurrentAgentId: React.Dispatch>; agent_id?: string; agentsConfig?: t.TAgentsEndpoint | null; endpointsConfig?: t.TEndpointsConfig | null; + /** Pre-computed MCP server information indexed by server key */ + mcpServersMap: Map; }; export type AgentModelPanelProps = { diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index 300cb9c585..5ed6a08b83 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -1,9 +1,9 @@ import React, { memo, useCallback } from 'react'; import { MultiSelect, MCPIcon } from '@librechat/client'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; -import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; import { useBadgeRowContext } from '~/Providers'; +import { useMCPServerManager } from '~/hooks'; type MCPSelectProps = { conversationId?: string | null }; diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx index ea40423836..8bd3386d2a 100644 --- a/client/src/components/Chat/Input/MCPSubMenu.tsx +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -3,8 +3,8 @@ import * as Ariakit from '@ariakit/react'; import { ChevronRight } from 'lucide-react'; import { PinIcon, MCPIcon } from '@librechat/client'; import MCPServerStatusIcon from '~/components/MCP/MCPServerStatusIcon'; -import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager'; import MCPConfigDialog from '~/components/MCP/MCPConfigDialog'; +import { useMCPServerManager } from '~/hooks'; import { cn } from '~/utils'; interface MCPSubMenuProps { diff --git a/client/src/components/Chat/Messages/Content/ToolCall.tsx b/client/src/components/Chat/Messages/Content/ToolCall.tsx index 84fc8cdf74..7943dd6f14 100644 --- a/client/src/components/Chat/Messages/Content/ToolCall.tsx +++ b/client/src/components/Chat/Messages/Content/ToolCall.tsx @@ -88,6 +88,10 @@ export default function ToolCall({ const url = new URL(authURL); return url.hostname; } catch (e) { + logger.error( + 'client/src/components/Chat/Messages/Content/ToolCall.tsx - Failed to parse auth URL', + e, + ); return ''; } }, [auth]); diff --git a/client/src/components/MCP/CustomUserVarsSection.tsx b/client/src/components/MCP/CustomUserVarsSection.tsx index 98e392554a..ded7115b15 100644 --- a/client/src/components/MCP/CustomUserVarsSection.tsx +++ b/client/src/components/MCP/CustomUserVarsSection.tsx @@ -16,7 +16,6 @@ interface CustomUserVarsSectionProps { onRevoke: () => void; isSubmitting?: boolean; } - interface AuthFieldProps { name: string; 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_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({ - serverName, fields, onSave, onRevoke, + serverName, isSubmitting = false, }: CustomUserVarsSectionProps) { const localize = useLocalize(); - // Fetch auth value flags for the server const { data: authValuesData } = useMCPAuthValuesQuery(serverName, { enabled: !!serverName, }); const { + reset, control, handleSubmit, - reset, formState: { errors }, } = useForm>({ defaultValues: useMemo(() => { @@ -140,10 +138,20 @@ export default function CustomUserVarsSection({
- -
diff --git a/client/src/components/MCP/ServerInitializationSection.tsx b/client/src/components/MCP/ServerInitializationSection.tsx index 7217faee5e..e84c84a55d 100644 --- a/client/src/components/MCP/ServerInitializationSection.tsx +++ b/client/src/components/MCP/ServerInitializationSection.tsx @@ -1,8 +1,8 @@ import React from 'react'; import { RefreshCw } from 'lucide-react'; import { Button, Spinner } from '@librechat/client'; -import { useMCPServerManager } from '~/hooks/MCP/useMCPServerManager'; -import { useLocalize } from '~/hooks'; +import { useLocalize, useMCPServerManager, useMCPConnectionStatus } from '~/hooks'; +import { useGetStartupConfig } from '~/data-provider'; interface ServerInitializationSectionProps { sidePanel?: boolean; @@ -21,16 +21,15 @@ export default function ServerInitializationSection({ }: ServerInitializationSectionProps) { const localize = useLocalize(); - const { - initializeServer, - connectionStatus, - cancelOAuthFlow, - isInitializing, - isCancellable, - getOAuthUrl, - } = useMCPServerManager({ conversationId }); + const { initializeServer, 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 canCancel = isCancellable(serverName); const isServerInitializing = isInitializing(serverName); diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 886fba3785..86f9cc6dc6 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -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(); 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 { - 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
- {/* // 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
{/* MCP Section */} - {/* */} - + {/* Support Contact (Optional) */}
@@ -477,6 +471,13 @@ export default function AgentConfig({ createMutation }: Pick + ); } diff --git a/client/src/components/SidePanel/Agents/AgentPanel.tsx b/client/src/components/SidePanel/Agents/AgentPanel.tsx index 96fc43d27e..082d91bd45 100644 --- a/client/src/components/SidePanel/Agents/AgentPanel.tsx +++ b/client/src/components/SidePanel/Agents/AgentPanel.tsx @@ -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 || '', ); diff --git a/client/src/components/SidePanel/Agents/MCPTool.tsx b/client/src/components/SidePanel/Agents/MCPTool.tsx new file mode 100644 index 0000000000..c6bfd2b09c --- /dev/null +++ b/client/src/components/SidePanel/Agents/MCPTool.tsx @@ -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(); + const { getServerStatusIconProps, getConfigDialogProps } = useMCPServerManager(); + + const [isFocused, setIsFocused] = useState(false); + const [isHovering, setIsHovering] = useState(false); + const [accordionValue, setAccordionValue] = useState(''); + const [hoveredToolId, setHoveredToolId] = useState(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 && ( +
{ + e.stopPropagation(); + }} + className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary" + > + +
+ ); + + return ( + + + +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false); + } + }} + > + +
+ setAccordionValue((prev) => { + if (prev) { + return ''; + } + return currentServerName; + }) + } + > + {statusIcon &&
{statusIcon}
} + + {serverInfo.metadata.icon && ( +
+
+
+ )} +
+ {currentServerName} +
+
+
+
+
+
e.stopPropagation()} + className="mt-1" + > + 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} + /> +
+ +
+ {/* Caret button for accordion */} + + + + + + + +
+
+
+
+
+
+ +
+ + +
+ {serverInfo.tools?.map((subTool) => ( + + ))} +
+
+ + + + {localize('com_ui_delete_tool_confirm')} + + } + 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 && } + + ); +} diff --git a/client/src/components/SidePanel/Agents/MCPTools.tsx b/client/src/components/SidePanel/Agents/MCPTools.tsx new file mode 100644 index 0000000000..1c087e4288 --- /dev/null +++ b/client/src/components/SidePanel/Agents/MCPTools.tsx @@ -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>; +}) { + const localize = useLocalize(); + const { mcpServersMap } = useAgentPanelContext(); + + return ( +
+ +
+
+ {/* Render servers with selected tools */} + {mcpServerNames?.map((mcpServerName) => { + const serverInfo = mcpServersMap.get(mcpServerName); + if (!serverInfo?.isConfigured) { + return ( + + ); + } + if (!serverInfo) { + return null; + } + + if (serverInfo.isConnected) { + return ( + + ); + } + + return ( + + ); + })} +
+
+ +
+
+
+ ); +} diff --git a/client/src/components/SidePanel/Agents/UnconfiguredMCPTool.tsx b/client/src/components/SidePanel/Agents/UnconfiguredMCPTool.tsx new file mode 100644 index 0000000000..0377243176 --- /dev/null +++ b/client/src/components/SidePanel/Agents/UnconfiguredMCPTool.tsx @@ -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(); + + 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 ( + +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false); + } + }} + > +
+
+ +
+
+ +
+
+ {serverName} + + {' - '} + {localize('com_ui_unavailable')} + +
+
+ + + + +
+ + {localize('com_ui_delete_tool_confirm')} + + } + 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'), + }} + /> +
+ ); +} diff --git a/client/src/components/SidePanel/Agents/UninitializedMCPTool.tsx b/client/src/components/SidePanel/Agents/UninitializedMCPTool.tsx new file mode 100644 index 0000000000..336b56f3d2 --- /dev/null +++ b/client/src/components/SidePanel/Agents/UninitializedMCPTool.tsx @@ -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(); + 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 && ( +
{ + e.stopPropagation(); + }} + className="cursor-pointer rounded p-0.5 hover:bg-surface-secondary" + > + +
+ ); + + return ( + +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsFocused(true)} + onBlur={(e) => { + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false); + } + }} + > +
{ + 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 && ( +
+ {statusIcon} +
+ )} + + {serverInfo.metadata.icon && ( +
+
+
+ )} +
+ {serverName} + {isServerInitializing && ( + + {localize('com_ui_initializing')} + + )} +
+
+ + + + +
+ + {localize('com_ui_delete_tool_confirm')} + + } + 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 && } + + ); +} diff --git a/client/src/components/SidePanel/MCP/MCPPanel.tsx b/client/src/components/SidePanel/MCP/MCPPanel.tsx index a7d73c78ea..16e30e7526 100644 --- a/client/src/components/SidePanel/MCP/MCPPanel.tsx +++ b/client/src/components/SidePanel/MCP/MCPPanel.tsx @@ -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( 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 (
@@ -170,7 +167,7 @@ function MCPPanelContent() {
{mcpServerDefinitions.map((server) => { - const serverStatus = connectionStatus[server.serverName]; + const serverStatus = connectionStatus?.[server.serverName]; const isConnected = serverStatus?.connectionState === 'connected'; return ( diff --git a/client/src/components/Tools/MCPToolItem.tsx b/client/src/components/Tools/MCPToolItem.tsx new file mode 100644 index 0000000000..9fdf01eb08 --- /dev/null +++ b/client/src/components/Tools/MCPToolItem.tsx @@ -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: , + 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: , + className: 'btn btn-primary relative', + disabled: false, + }; + } + + if (isInitializing) { + return { + text: localize('com_ui_initializing'), + icon: , + className: 'btn btn-primary relative opacity-75 cursor-not-allowed', + disabled: true, + }; + } + + return { + text: localize('com_ui_add'), + icon: , + className: 'btn btn-primary relative', + disabled: false, + }; + }; + + const buttonState = getButtonState(); + + return ( +
+
+
+
+ {icon ? ( + {localize('com_ui_logo', + ) : ( +
+ +
+ )} +
+
+
+
+
+ {name} +
+ +
+
+
{description}
+
+ ); +} + +export default MCPToolItem; diff --git a/client/src/components/Tools/MCPToolSelectDialog.tsx b/client/src/components/Tools/MCPToolSelectDialog.tsx new file mode 100644 index 0000000000..268cd38641 --- /dev/null +++ b/client/src/components/Tools/MCPToolSelectDialog.tsx @@ -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(); + const { refetch: refetchAvailableTools } = useAvailableToolsQuery(EModelEndpoint.agents); + + const [isInitializing, setIsInitializing] = useState(null); + const [configuringServer, setConfiguringServer] = useState(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) => { + 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 ( + { + setIsOpen(false); + setCurrentPage(1); + setSearchValue(''); + setConfiguringServer(null); + setIsInitializing(null); + }} + className="relative z-[102]" + > +
+
+ +
+
+
+ + {localize('com_nav_tool_dialog_mcp_server_tools')} + + + {localize('com_nav_tool_dialog_description')} + +
+
+
+ +
+
+ + {error && ( +
+ {localize('com_nav_plugin_auth_error')} {errorMessage} +
+ )} + + {configuringServer && ( +
+
+

+ {localize('com_ui_mcp_configure_server_description', { 0: configuringServer })} +

+
+ handleSaveCustomVars(configuringServer, authData)} + onRevoke={() => handleRevokeCustomVars(configuringServer)} + isSubmitting={updateUserPlugins.isLoading} + /> +
+ )} + +
+
+
setConfiguringServer(null)} + > + + +
+ +
+ {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 ( + onAddTool(serverInfo.serverName)} + onRemoveTool={() => onRemoveTool(serverInfo.serverName)} + /> + ); + })} +
+
+ +
+ {maxPage > 0 ? ( + + ) : ( +
+ )} +
+
+
+
+
+ ); +} + +export default MCPToolSelectDialog; diff --git a/client/src/components/Tools/ToolSelectDialog.tsx b/client/src/components/Tools/ToolSelectDialog.tsx index 0d380fefbb..cdd70b9075 100644 --- a/client/src/components/Tools/ToolSelectDialog.tsx +++ b/client/src/components/Tools/ToolSelectDialog.tsx @@ -1,7 +1,7 @@ import { useEffect } from 'react'; import { Search, X } from 'lucide-react'; 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 { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import type { @@ -15,7 +15,6 @@ import type { AgentForm, TPluginStoreDialogProps } from '~/common'; import { PluginPagination, PluginAuthForm } from '~/components/Plugins/Store'; import { useAgentPanelContext } from '~/Providers/AgentPanelContext'; import { useLocalize, usePluginDialogHelpers } from '~/hooks'; -import { useAvailableToolsQuery } from '~/data-provider'; import ToolItem from './ToolItem'; function ToolSelectDialog({ @@ -26,10 +25,9 @@ function ToolSelectDialog({ endpoint: AssistantsEndpoint | EModelEndpoint.agents; }) { const localize = useLocalize(); - const { getValues, setValue } = useFormContext(); - const { data: tools } = useAvailableToolsQuery(endpoint); - const { groupedTools } = useAgentPanelContext(); const isAgentTools = isAgentsEndpoint(endpoint); + const { getValues, setValue } = useFormContext(); + const { groupedTools, pluginTools } = useAgentPanelContext(); const { maxPage, @@ -121,38 +119,28 @@ function ToolSelectDialog({ const onAddTool = (pluginKey: string) => { setShowPluginAuthForm(false); - const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey); - setSelectedPlugin(getAvailablePluginFromKey); + const availablePluginFromKey = pluginTools?.find((p) => p.pluginKey === pluginKey); + setSelectedPlugin(availablePluginFromKey); - const isMCPTool = pluginKey.includes(Constants.mcp_delimiter); - - 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: {} }); + const { authConfig, authenticated = false } = availablePluginFromKey ?? {}; + if (authConfig && authConfig.length > 0 && !authenticated) { + setShowPluginAuthForm(true); } else { - const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {}; - if (authConfig && authConfig.length > 0 && !authenticated) { - setShowPluginAuthForm(true); - } else { - handleInstall({ - pluginKey, - action: 'install', - auth: {}, - }); - } + handleInstall({ + pluginKey, + action: 'install', + auth: {}, + }); } }; const filteredTools = Object.values(groupedTools || {}).filter( - (tool: AgentToolType & { tools?: AgentToolType[] }) => { - // Check if the parent tool matches - if (tool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) { + (currentTool: AgentToolType & { tools?: AgentToolType[] }) => { + if (currentTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) { return true; } - // Check if any child tools match - if (tool.tools) { - return tool.tools.some((childTool) => + if (currentTool.tools) { + return currentTool.tools.some((childTool) => childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()), ); } @@ -169,9 +157,9 @@ function ToolSelectDialog({ } } }, [ - tools, - itemsPerPage, + pluginTools, searchValue, + itemsPerPage, filteredTools, searchChanged, setMaxPage, diff --git a/client/src/components/Tools/index.ts b/client/src/components/Tools/index.ts index 24ce8e939f..cb3f0b464e 100644 --- a/client/src/components/Tools/index.ts +++ b/client/src/components/Tools/index.ts @@ -1,2 +1,3 @@ +export { default as MCPToolSelectDialog } from './MCPToolSelectDialog'; export { default as ToolSelectDialog } from './ToolSelectDialog'; export { default as ToolItem } from './ToolItem'; diff --git a/client/src/hooks/MCP/index.ts b/client/src/hooks/MCP/index.ts index a9583ec497..761f61b266 100644 --- a/client/src/hooks/MCP/index.ts +++ b/client/src/hooks/MCP/index.ts @@ -1,3 +1,5 @@ -export * from './useMCPSelect'; export * from './useGetMCPTools'; +export * from './useMCPConnectionStatus'; +export * from './useMCPSelect'; +export * from './useVisibleTools'; export { useMCPServerManager } from './useMCPServerManager'; diff --git a/client/src/hooks/MCP/useMCPConnectionStatus.ts b/client/src/hooks/MCP/useMCPConnectionStatus.ts new file mode 100644 index 0000000000..72ba26041c --- /dev/null +++ b/client/src/hooks/MCP/useMCPConnectionStatus.ts @@ -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, + }; +} diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index e021e272aa..e3a0ea2e9a 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -9,8 +9,7 @@ import { } from 'librechat-data-provider/react-query'; import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider'; import type { ConfigFieldDetail } from '~/common'; -import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries'; -import { useLocalize, useMCPSelect, useGetMCPTools } from '~/hooks'; +import { useLocalize, useMCPSelect, useGetMCPTools, useMCPConnectionStatus } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; interface ServerState { @@ -21,7 +20,7 @@ interface ServerState { pollInterval: NodeJS.Timeout | null; } -export function useMCPServerManager({ conversationId }: { conversationId?: string | null }) { +export function useMCPServerManager({ conversationId }: { conversationId?: string | null } = {}) { const localize = useLocalize(); const queryClient = useQueryClient(); const { showToast } = useToastContext(); @@ -83,13 +82,9 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin return initialStates; }); - const { data: connectionStatusData } = useMCPConnectionStatusQuery({ + const { connectionStatus } = useMCPConnectionStatus({ 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 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); useEffect(() => { - if (!connectionStatusData || Object.keys(connectionStatus).length === 0) { + if (!connectionStatus || Object.keys(connectionStatus).length === 0) { return; } @@ -115,7 +110,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin if (connectedSelected.length !== mcpValues.length) { setMCPValues(connectedSelected); } - }, [connectionStatus, connectionStatusData, mcpValues, setMCPValues]); + }, [connectionStatus, mcpValues, setMCPValues]); const updateServerState = useCallback((serverName: string, updates: Partial) => { setServerStates((prev) => { @@ -229,46 +224,46 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const initializeServer = useCallback( async (serverName: string, autoOpenOAuth: boolean = true) => { updateServerState(serverName, { isInitializing: true }); - try { const response = await reinitializeMutation.mutateAsync(serverName); - - if (response.success) { - if (response.oauthRequired && response.oauthUrl) { - updateServerState(serverName, { - oauthUrl: response.oauthUrl, - oauthStartTime: Date.now(), - isCancellable: true, - isInitializing: true, - }); - - if (autoOpenOAuth) { - window.open(response.oauthUrl, '_blank', 'noopener,noreferrer'); - } - - startServerPolling(serverName); - } else { - await queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]); - - showToast({ - message: localize('com_ui_mcp_initialized_success', { 0: serverName }), - status: 'success', - }); - - const currentValues = mcpValues ?? []; - if (!currentValues.includes(serverName)) { - setMCPValues([...currentValues, serverName]); - } - - cleanupServerState(serverName); - } - } else { + if (!response.success) { showToast({ message: localize('com_ui_mcp_init_failed', { 0: serverName }), status: 'error', }); cleanupServerState(serverName); + return response; } + + if (response.oauthRequired && response.oauthUrl) { + updateServerState(serverName, { + oauthUrl: response.oauthUrl, + oauthStartTime: Date.now(), + isCancellable: true, + isInitializing: true, + }); + + if (autoOpenOAuth) { + window.open(response.oauthUrl, '_blank', 'noopener,noreferrer'); + } + + startServerPolling(serverName); + } else { + await queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); + + showToast({ + message: localize('com_ui_mcp_initialized_success', { 0: serverName }), + status: 'success', + }); + + const currentValues = mcpValues ?? []; + if (!currentValues.includes(serverName)) { + setMCPValues([...currentValues, serverName]); + } + + cleanupServerState(serverName); + } + return response; } catch (error) { console.error(`[MCP Manager] Failed to initialize ${serverName}:`, error); showToast({ @@ -351,7 +346,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin return; } - const serverStatus = connectionStatus[serverName]; + const serverStatus = connectionStatus?.[serverName]; if (serverStatus?.connectionState === 'connected') { connectedServers.push(serverName); } else { @@ -381,7 +376,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const filteredValues = currentValues.filter((name) => name !== serverName); setMCPValues(filteredValues); } else { - const serverStatus = connectionStatus[serverName]; + const serverStatus = connectionStatus?.[serverName]; if (serverStatus?.connectionState === 'connected') { setMCPValues([...currentValues, serverName]); } else { @@ -455,7 +450,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const getServerStatusIconProps = useCallback( (serverName: string) => { const tool = mcpToolDetails?.find((t) => t.name === serverName); - const serverStatus = connectionStatus[serverName]; + const serverStatus = connectionStatus?.[serverName]; const serverConfig = startupConfig?.mcpServers?.[serverName]; const handleConfigClick = (e: React.MouseEvent) => { @@ -532,7 +527,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin return { serverName: selectedToolForConfig.name, - serverStatus: connectionStatus[selectedToolForConfig.name], + serverStatus: connectionStatus?.[selectedToolForConfig.name], isOpen: isConfigModalOpen, onOpenChange: handleDialogOpenChange, fieldsSchema, @@ -553,7 +548,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin return { configuredServers, - connectionStatus, initializeServer, cancelOAuthFlow, isInitializing, diff --git a/client/src/hooks/MCP/useVisibleTools.ts b/client/src/hooks/MCP/useVisibleTools.ts new file mode 100644 index 0000000000..acb48bf111 --- /dev/null +++ b/client/src/hooks/MCP/useVisibleTools.ts @@ -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; + +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, +): VisibleToolsResult { + return useMemo(() => { + const mcpServers = new Set(); + const selectedSet = new Set(); + const regularToolIds = new Set(); + + 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]); +} diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 70ed6dc2bb..a3b4622eaa 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -104,6 +104,7 @@ "com_assistants_actions_info": "Let your Assistant retrieve information or take actions via API's", "com_assistants_add_actions": "Add Actions", "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_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.", @@ -579,7 +580,8 @@ "com_nav_theme_system": "System", "com_nav_tool_dialog": "Assistant 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_search": "Search tools", "com_nav_user": "USER", @@ -769,6 +771,7 @@ "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_change": "Confirm Change", + "com_ui_confirm": "Confirm", "com_ui_connecting": "Connecting", "com_ui_context": "Context", "com_ui_continue": "Continue", @@ -830,6 +833,8 @@ "com_ui_delete_success": "Successfully deleted", "com_ui_delete_tool": "Delete 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_deleting_file": "Deleting file...", "com_ui_descending": "Desc", @@ -947,6 +952,7 @@ "com_ui_image_gen": "Image Gen", "com_ui_import": "Import", "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_info": "Import conversations from a JSON file", "com_ui_import_conversation_success": "Conversations imported successfully", @@ -1202,6 +1208,7 @@ "com_ui_unarchive": "Unarchive", "com_ui_unarchive_error": "Failed to unarchive conversation", "com_ui_unknown": "Unknown", + "com_ui_unavailable": "Unavailable", "com_ui_unset": "Unset", "com_ui_untitled": "Untitled", "com_ui_update": "Update", @@ -1265,5 +1272,7 @@ "com_ui_x_selected": "{{0}} selected", "com_ui_yes": "Yes", "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" } diff --git a/packages/client/src/components/Tooltip.tsx b/packages/client/src/components/Tooltip.tsx index 2f7675d10f..b9bc87fd54 100644 --- a/packages/client/src/components/Tooltip.tsx +++ b/packages/client/src/components/Tooltip.tsx @@ -6,11 +6,11 @@ import { cn } from '~/utils'; import './Tooltip.css'; interface TooltipAnchorProps extends Ariakit.TooltipAnchorProps { - description: string; - side?: 'top' | 'bottom' | 'left' | 'right'; - className?: string; role?: string; + className?: string; + description: string; enableHTML?: boolean; + side?: 'top' | 'bottom' | 'left' | 'right'; } export const TooltipAnchor = forwardRef(function TooltipAnchor( diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index b3763d5260..66b6170cd9 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -1559,8 +1559,13 @@ export enum Constants { mcp_delimiter = '_mcp_', /** Prefix for MCP plugins */ 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', + /** + * 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 */ EPHEMERAL_AGENT_ID = 'ephemeral', } diff --git a/packages/data-provider/src/types/agents.ts b/packages/data-provider/src/types/agents.ts index ff286c21f4..d8cbbbfa94 100644 --- a/packages/data-provider/src/types/agents.ts +++ b/packages/data-provider/src/types/agents.ts @@ -335,7 +335,7 @@ export type ActionMetadataRuntime = ActionMetadata & { export type MCP = { mcp_id: string; 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 & { name?: string; @@ -352,6 +352,6 @@ export type MCPAuth = ActionAuth; export type AgentToolType = { tool_id: string; 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;