diff --git a/client/src/Providers/BadgeRowContext.tsx b/client/src/Providers/BadgeRowContext.tsx index 26d677491..4117a4234 100644 --- a/client/src/Providers/BadgeRowContext.tsx +++ b/client/src/Providers/BadgeRowContext.tsx @@ -1,12 +1,19 @@ -import React, { createContext, useContext, useEffect, useRef } from 'react'; +import React, { createContext, useContext, useEffect, useMemo, useRef } from 'react'; import { useSetRecoilState } from 'recoil'; import { Tools, Constants, LocalStorageKeys, AgentCapabilities } from 'librechat-data-provider'; import type { TAgentsEndpoint } from 'librechat-data-provider'; -import { useSearchApiKeyForm, useGetAgentsConfig, useCodeApiKeyForm, useToolToggle } from '~/hooks'; +import { + useSearchApiKeyForm, + useGetAgentsConfig, + useCodeApiKeyForm, + useGetMCPTools, + useToolToggle, +} from '~/hooks'; import { ephemeralAgentByConvoId } from '~/store'; interface BadgeRowContextType { conversationId?: string | null; + mcpServerNames?: string[] | null; agentsConfig?: TAgentsEndpoint | null; webSearch: ReturnType; artifacts: ReturnType; @@ -37,10 +44,12 @@ export default function BadgeRowProvider({ isSubmitting, conversationId, }: BadgeRowProviderProps) { - const hasInitializedRef = useRef(false); const lastKeyRef = useRef(''); + const hasInitializedRef = useRef(false); + const { mcpToolDetails } = useGetMCPTools(); const { agentsConfig } = useGetAgentsConfig(); const key = conversationId ?? Constants.NEW_CONVO; + const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(key)); /** Initialize ephemeralAgent from localStorage on mount and when conversation changes */ @@ -156,11 +165,16 @@ export default function BadgeRowProvider({ isAuthenticated: true, }); + const mcpServerNames = useMemo(() => { + return (mcpToolDetails ?? []).map((tool) => tool.name); + }, [mcpToolDetails]); + const value: BadgeRowContextType = { webSearch, artifacts, fileSearch, agentsConfig, + mcpServerNames, conversationId, codeApiKeyForm, codeInterpreter, diff --git a/client/src/Providers/MCPPanelContext.tsx b/client/src/Providers/MCPPanelContext.tsx new file mode 100644 index 000000000..948be0ad6 --- /dev/null +++ b/client/src/Providers/MCPPanelContext.tsx @@ -0,0 +1,31 @@ +import React, { createContext, useContext, useMemo } from 'react'; +import { Constants } from 'librechat-data-provider'; +import { useChatContext } from './ChatContext'; + +interface MCPPanelContextValue { + conversationId: string; +} + +const MCPPanelContext = createContext(undefined); + +export function MCPPanelProvider({ children }: { children: React.ReactNode }) { + const { conversation } = useChatContext(); + + /** Context value only created when conversationId changes */ + const contextValue = useMemo( + () => ({ + conversationId: conversation?.conversationId ?? Constants.NEW_CONVO, + }), + [conversation?.conversationId], + ); + + return {children}; +} + +export function useMCPPanelContext() { + const context = useContext(MCPPanelContext); + if (!context) { + throw new Error('useMCPPanelContext must be used within MCPPanelProvider'); + } + return context; +} diff --git a/client/src/Providers/index.ts b/client/src/Providers/index.ts index 581e3cd55..6e4384a74 100644 --- a/client/src/Providers/index.ts +++ b/client/src/Providers/index.ts @@ -23,6 +23,7 @@ export * from './SetConvoContext'; export * from './SearchContext'; export * from './BadgeRowContext'; export * from './SidePanelContext'; +export * from './MCPPanelContext'; export * from './ArtifactsContext'; export * from './PromptGroupsContext'; export { default as BadgeRowProvider } from './BadgeRowContext'; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 80fdf593c..9df024276 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -8,6 +8,11 @@ import type * as t from 'librechat-data-provider'; import type { LucideIcon } from 'lucide-react'; import type { TranslationKeys } from '~/hooks'; +export interface ConfigFieldDetail { + title: string; + description: string; +} + export type CodeBarProps = { lang: string; error?: boolean; diff --git a/client/src/components/Chat/Input/BadgeRow.tsx b/client/src/components/Chat/Input/BadgeRow.tsx index 5036dcd5e..5c45c6b4b 100644 --- a/client/src/components/Chat/Input/BadgeRow.tsx +++ b/client/src/components/Chat/Input/BadgeRow.tsx @@ -368,7 +368,7 @@ function BadgeRow({ - + )} {ghostBadge && ( diff --git a/client/src/components/Chat/Input/MCPConfigDialog.tsx b/client/src/components/Chat/Input/MCPConfigDialog.tsx index 5cb217086..3a8f77a8d 100644 --- a/client/src/components/Chat/Input/MCPConfigDialog.tsx +++ b/client/src/components/Chat/Input/MCPConfigDialog.tsx @@ -1,13 +1,9 @@ import React, { useEffect } from 'react'; import { useForm, Controller } from 'react-hook-form'; import { Button, Input, Label, OGDialog, OGDialogTemplate } from '@librechat/client'; +import type { ConfigFieldDetail } from '~/common'; import { useLocalize } from '~/hooks'; -export interface ConfigFieldDetail { - title: string; - description: string; -} - interface MCPConfigDialogProps { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; @@ -34,7 +30,7 @@ export default function MCPConfigDialog({ control, handleSubmit, reset, - formState: { errors, _ }, + formState: { errors }, } = useForm>({ defaultValues: initialValues, }); @@ -56,14 +52,12 @@ export default function MCPConfigDialog({ }; const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName }); - const dialogDescription = localize('com_ui_mcp_dialog_desc'); return ( diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index e3d1878ff..300cb9c58 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -3,8 +3,11 @@ 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'; -function MCPSelect() { +type MCPSelectProps = { conversationId?: string | null }; + +function MCPSelectContent({ conversationId }: MCPSelectProps) { const { configuredServers, mcpValues, @@ -15,7 +18,7 @@ function MCPSelect() { getConfigDialogProps, isInitializing, localize, - } = useMCPServerManager(); + } = useMCPServerManager({ conversationId }); const renderSelectedValues = useCallback( (values: string[], placeholder?: string) => { @@ -93,9 +96,17 @@ function MCPSelect() { selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10" selectClassName="group relative inline-flex items-center justify-center md:justify-start gap-1.5 rounded-full border border-border-medium text-sm font-medium transition-all md:w-full size-9 p-2 md:p-3 bg-transparent shadow-sm hover:bg-surface-hover hover:shadow-md active:shadow-inner" /> - {configDialogProps && } + {configDialogProps && ( + + )} ); } +function MCPSelect(props: MCPSelectProps) { + const { mcpServerNames } = useBadgeRowContext(); + if ((mcpServerNames?.length ?? 0) === 0) return null; + return ; +} + export default memo(MCPSelect); diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx index fc690089d..ea4042383 100644 --- a/client/src/components/Chat/Input/MCPSubMenu.tsx +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -9,10 +9,11 @@ import { cn } from '~/utils'; interface MCPSubMenuProps { placeholder?: string; + conversationId?: string | null; } const MCPSubMenu = React.forwardRef( - ({ placeholder, ...props }, ref) => { + ({ placeholder, conversationId, ...props }, ref) => { const { configuredServers, mcpValues, @@ -23,7 +24,7 @@ const MCPSubMenu = React.forwardRef( getServerStatusIconProps, getConfigDialogProps, isInitializing, - } = useMCPServerManager(); + } = useMCPServerManager({ conversationId }); const menuStore = Ariakit.useMenuStore({ focusLoop: true, diff --git a/client/src/components/Chat/Input/ToolsDropdown.tsx b/client/src/components/Chat/Input/ToolsDropdown.tsx index 3517df6c6..1041233de 100644 --- a/client/src/components/Chat/Input/ToolsDropdown.tsx +++ b/client/src/components/Chat/Input/ToolsDropdown.tsx @@ -10,12 +10,12 @@ import { PermissionTypes, defaultAgentCapabilities, } from 'librechat-data-provider'; -import { useLocalize, useHasAccess, useAgentCapabilities, useMCPSelect } from '~/hooks'; +import { useLocalize, useHasAccess, useAgentCapabilities } from '~/hooks'; import ArtifactsSubMenu from '~/components/Chat/Input/ArtifactsSubMenu'; import MCPSubMenu from '~/components/Chat/Input/MCPSubMenu'; +import { useGetStartupConfig } from '~/data-provider'; import { useBadgeRowContext } from '~/Providers'; import { cn } from '~/utils'; -import { useGetStartupConfig } from '~/data-provider'; interface ToolsDropdownProps { disabled?: boolean; @@ -30,11 +30,12 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { artifacts, fileSearch, agentsConfig, + mcpServerNames, + conversationId, codeApiKeyForm, codeInterpreter, searchApiKeyForm, } = useBadgeRowContext(); - const mcpSelect = useMCPSelect(); const { data: startupConfig } = useGetStartupConfig(); const { codeEnabled, webSearchEnabled, artifactsEnabled, fileSearchEnabled } = @@ -56,7 +57,6 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { } = codeInterpreter; const { isPinned: isFileSearchPinned, setIsPinned: setIsFileSearchPinned } = fileSearch; const { isPinned: isArtifactsPinned, setIsPinned: setIsArtifactsPinned } = artifacts; - const { mcpServerNames } = mcpSelect; const canUseWebSearch = useHasAccess({ permissionType: PermissionTypes.WEB_SEARCH, @@ -290,7 +290,9 @@ const ToolsDropdown = ({ disabled }: ToolsDropdownProps) => { if (mcpServerNames && mcpServerNames.length > 0) { dropdownItems.push({ hideOnClick: false, - render: (props) => , + render: (props) => ( + + ), }); } diff --git a/client/src/components/MCP/MCPConfigDialog.tsx b/client/src/components/MCP/MCPConfigDialog.tsx index 7c4c86fce..3569c1b35 100644 --- a/client/src/components/MCP/MCPConfigDialog.tsx +++ b/client/src/components/MCP/MCPConfigDialog.tsx @@ -8,15 +8,11 @@ import { OGDialogContent, } from '@librechat/client'; import type { MCPServerStatus } from 'librechat-data-provider'; +import type { ConfigFieldDetail } from '~/common'; import ServerInitializationSection from './ServerInitializationSection'; import CustomUserVarsSection from './CustomUserVarsSection'; import { useLocalize } from '~/hooks'; -export interface ConfigFieldDetail { - title: string; - description: string; -} - interface MCPConfigDialogProps { isOpen: boolean; onOpenChange: (isOpen: boolean) => void; @@ -27,6 +23,7 @@ interface MCPConfigDialogProps { onRevoke?: () => void; serverName: string; serverStatus?: MCPServerStatus; + conversationId?: string | null; } export default function MCPConfigDialog({ @@ -38,6 +35,7 @@ export default function MCPConfigDialog({ onRevoke, serverName, serverStatus, + conversationId, }: MCPConfigDialogProps) { const localize = useLocalize(); @@ -126,6 +124,7 @@ export default function MCPConfigDialog({ {/* Server Initialization Section */} 0} /> diff --git a/client/src/components/MCP/ServerInitializationSection.tsx b/client/src/components/MCP/ServerInitializationSection.tsx index 5d793921e..7217faee5 100644 --- a/client/src/components/MCP/ServerInitializationSection.tsx +++ b/client/src/components/MCP/ServerInitializationSection.tsx @@ -9,12 +9,14 @@ interface ServerInitializationSectionProps { serverName: string; requiresOAuth: boolean; hasCustomUserVars?: boolean; + conversationId?: string | null; } export default function ServerInitializationSection({ - sidePanel = false, serverName, requiresOAuth, + conversationId, + sidePanel = false, hasCustomUserVars = false, }: ServerInitializationSectionProps) { const localize = useLocalize(); @@ -26,7 +28,7 @@ export default function ServerInitializationSection({ isInitializing, isCancellable, getOAuthUrl, - } = useMCPServerManager(); + } = useMCPServerManager({ conversationId }); const serverStatus = connectionStatus[serverName]; const isConnected = serverStatus?.connectionState === 'connected'; @@ -69,13 +71,18 @@ export default function ServerInitializationSection({ const isReinit = shouldShowReinit; const outerClass = isReinit ? 'flex justify-start' : 'flex justify-end'; const buttonVariant = isReinit ? undefined : 'default'; - const buttonText = isServerInitializing - ? localize('com_ui_loading') - : isReinit - ? localize('com_ui_reinitialize') - : requiresOAuth - ? localize('com_ui_authenticate') - : localize('com_ui_mcp_initialize'); + + let buttonText = ''; + if (isServerInitializing) { + buttonText = localize('com_ui_loading'); + } else if (isReinit) { + buttonText = localize('com_ui_reinitialize'); + } else if (requiresOAuth) { + buttonText = localize('com_ui_authenticate'); + } else { + buttonText = localize('com_ui_mcp_initialize'); + } + const icon = isServerInitializing ? ( ) : ( diff --git a/client/src/components/SidePanel/MCP/MCPPanel.tsx b/client/src/components/SidePanel/MCP/MCPPanel.tsx index b90efa197..a7d73c78e 100644 --- a/client/src/components/SidePanel/MCP/MCPPanel.tsx +++ b/client/src/components/SidePanel/MCP/MCPPanel.tsx @@ -8,15 +8,16 @@ 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 BadgeRowProvider from '~/Providers/BadgeRowContext'; +import { MCPPanelProvider, useMCPPanelContext } from '~/Providers'; import { useGetStartupConfig } from '~/data-provider'; import MCPPanelSkeleton from './MCPPanelSkeleton'; import { useLocalize } from '~/hooks'; function MCPPanelContent() { const localize = useLocalize(); - const { showToast } = useToastContext(); const queryClient = useQueryClient(); + const { showToast } = useToastContext(); + const { conversationId } = useMCPPanelContext(); const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig(); const { data: connectionStatusData } = useMCPConnectionStatusQuery(); const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState( @@ -153,6 +154,7 @@ function MCPPanelContent() { + - + ); } diff --git a/client/src/hooks/MCP/index.ts b/client/src/hooks/MCP/index.ts index 9104c294a..a9583ec49 100644 --- a/client/src/hooks/MCP/index.ts +++ b/client/src/hooks/MCP/index.ts @@ -1 +1,3 @@ +export * from './useMCPSelect'; +export * from './useGetMCPTools'; export { useMCPServerManager } from './useMCPServerManager'; diff --git a/client/src/hooks/MCP/useGetMCPTools.ts b/client/src/hooks/MCP/useGetMCPTools.ts new file mode 100644 index 000000000..adffb963e --- /dev/null +++ b/client/src/hooks/MCP/useGetMCPTools.ts @@ -0,0 +1,43 @@ +import { useMemo } from 'react'; +import { Constants, EModelEndpoint } from 'librechat-data-provider'; +import type { TPlugin } from 'librechat-data-provider'; +import { useAvailableToolsQuery, useGetStartupConfig } from '~/data-provider'; + +export function useGetMCPTools() { + const { data: startupConfig } = useGetStartupConfig(); + const { data: rawMcpTools } = useAvailableToolsQuery(EModelEndpoint.agents, { + select: (data: TPlugin[]) => { + const mcpToolsMap = new Map(); + data.forEach((tool) => { + const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter); + if (isMCP) { + const parts = tool.pluginKey.split(Constants.mcp_delimiter); + const serverName = parts[parts.length - 1]; + if (!mcpToolsMap.has(serverName)) { + mcpToolsMap.set(serverName, { + name: serverName, + pluginKey: tool.pluginKey, + authConfig: tool.authConfig, + authenticated: tool.authenticated, + }); + } + } + }); + return Array.from(mcpToolsMap.values()); + }, + }); + + const mcpToolDetails = useMemo(() => { + if (!rawMcpTools || !startupConfig?.mcpServers) { + return rawMcpTools; + } + return rawMcpTools.filter((tool) => { + const serverConfig = startupConfig?.mcpServers?.[tool.name]; + return serverConfig?.chatMenu !== false; + }); + }, [rawMcpTools, startupConfig?.mcpServers]); + + return { + mcpToolDetails, + }; +} diff --git a/client/src/hooks/MCP/useMCPSelect.ts b/client/src/hooks/MCP/useMCPSelect.ts new file mode 100644 index 000000000..fd730cb72 --- /dev/null +++ b/client/src/hooks/MCP/useMCPSelect.ts @@ -0,0 +1,72 @@ +import { useRef, useCallback, useMemo } from 'react'; +import { useRecoilState } from 'recoil'; +import { Constants, LocalStorageKeys } from 'librechat-data-provider'; +import useLocalStorage from '~/hooks/useLocalStorageAlt'; +import { ephemeralAgentByConvoId } from '~/store'; + +const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { + if (rawCurrentValue) { + try { + const currentValue = rawCurrentValue?.trim() ?? ''; + if (currentValue.length > 2) { + return true; + } + } catch (e) { + console.error(e); + } + } + return Array.isArray(value) && value.length > 0; +}; + +export function useMCPSelect({ conversationId }: { conversationId?: string | null }) { + const key = conversationId ?? Constants.NEW_CONVO; + const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); + + const storageKey = `${LocalStorageKeys.LAST_MCP_}${key}`; + const mcpState = useMemo(() => { + return ephemeralAgent?.mcp ?? []; + }, [ephemeralAgent?.mcp]); + + const setSelectedValues = useCallback( + (values: string[] | null | undefined) => { + if (!values) { + return; + } + if (!Array.isArray(values)) { + return; + } + setEphemeralAgent((prev) => ({ + ...prev, + mcp: values, + })); + }, + [setEphemeralAgent], + ); + + const [mcpValues, setMCPValuesRaw] = useLocalStorage( + storageKey, + mcpState, + setSelectedValues, + storageCondition, + ); + + const setMCPValuesRawRef = useRef(setMCPValuesRaw); + setMCPValuesRawRef.current = setMCPValuesRaw; + + /** Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop */ + const setMCPValues = useCallback((value: string[]) => { + setMCPValuesRawRef.current(value); + }, []); + + const [isPinned, setIsPinned] = useLocalStorage( + `${LocalStorageKeys.PIN_MCP_}${key}`, + true, + ); + + return { + isPinned, + mcpValues, + setIsPinned, + setMCPValues, + }; +} diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 6023bd2a2..e021e272a 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -8,10 +8,10 @@ import { useReinitializeMCPServerMutation, } from 'librechat-data-provider/react-query'; import type { TUpdateUserPlugins, TPlugin } from 'librechat-data-provider'; -import type { ConfigFieldDetail } from '~/components/MCP/MCPConfigDialog'; +import type { ConfigFieldDetail } from '~/common'; import { useMCPConnectionStatusQuery } from '~/data-provider/Tools/queries'; +import { useLocalize, useMCPSelect, useGetMCPTools } from '~/hooks'; import { useGetStartupConfig } from '~/data-provider'; -import { useLocalize, useMCPSelect } from '~/hooks'; interface ServerState { isInitializing: boolean; @@ -21,13 +21,14 @@ interface ServerState { pollInterval: NodeJS.Timeout | null; } -export function useMCPServerManager() { +export function useMCPServerManager({ conversationId }: { conversationId?: string | null }) { const localize = useLocalize(); - const { showToast } = useToastContext(); - const mcpSelect = useMCPSelect(); - const { data: startupConfig } = useGetStartupConfig(); - const { mcpValues, setMCPValues, mcpToolDetails, isPinned, setIsPinned } = mcpSelect; const queryClient = useQueryClient(); + const { showToast } = useToastContext(); + const { mcpToolDetails } = useGetMCPTools(); + const mcpSelect = useMCPSelect({ conversationId }); + const { data: startupConfig } = useGetStartupConfig(); + const { mcpValues, setMCPValues, isPinned, setIsPinned } = mcpSelect; const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [selectedToolForConfig, setSelectedToolForConfig] = useState(null); @@ -90,7 +91,21 @@ export function useMCPServerManager() { [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 + */ + const hasInitialLoadCompleted = useRef(false); + useEffect(() => { + if (!connectionStatusData || Object.keys(connectionStatus).length === 0) { + return; + } + + if (!hasInitialLoadCompleted.current) { + hasInitialLoadCompleted.current = true; + return; + } + if (!mcpValues?.length) return; const connectedSelected = mcpValues.filter( @@ -100,7 +115,7 @@ export function useMCPServerManager() { if (connectedSelected.length !== mcpValues.length) { setMCPValues(connectedSelected); } - }, [connectionStatus, mcpValues, setMCPValues]); + }, [connectionStatus, connectionStatusData, mcpValues, setMCPValues]); const updateServerState = useCallback((serverName: string, updates: Partial) => { setServerStates((prev) => { @@ -486,12 +501,12 @@ export function useMCPServerManager() { }; }, [ + isCancellable, mcpToolDetails, + isInitializing, + cancelOAuthFlow, connectionStatus, startupConfig?.mcpServers, - isInitializing, - isCancellable, - cancelOAuthFlow, ], ); @@ -547,7 +562,6 @@ export function useMCPServerManager() { mcpValues, setMCPValues, - mcpToolDetails, isPinned, setIsPinned, placeholderText, diff --git a/client/src/hooks/Plugins/index.ts b/client/src/hooks/Plugins/index.ts index 85b6c7186..17644f6af 100644 --- a/client/src/hooks/Plugins/index.ts +++ b/client/src/hooks/Plugins/index.ts @@ -1,4 +1,3 @@ -export * from './useMCPSelect'; export * from './useToolToggle'; export { default as useAuthCodeTool } from './useAuthCodeTool'; export { default as usePluginInstall } from './usePluginInstall'; diff --git a/client/src/hooks/Plugins/useMCPSelect.ts b/client/src/hooks/Plugins/useMCPSelect.ts deleted file mode 100644 index 89cf2d257..000000000 --- a/client/src/hooks/Plugins/useMCPSelect.ts +++ /dev/null @@ -1,136 +0,0 @@ -import { useRef, useEffect, useCallback, useMemo } from 'react'; -import { useRecoilState } from 'recoil'; -import { Constants, LocalStorageKeys, EModelEndpoint } from 'librechat-data-provider'; -import type { TPlugin } from 'librechat-data-provider'; -import { useAvailableToolsQuery, useGetStartupConfig } from '~/data-provider'; -import useLocalStorage from '~/hooks/useLocalStorageAlt'; -import { ephemeralAgentByConvoId } from '~/store'; -import { useChatContext } from '~/Providers'; - -const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { - if (rawCurrentValue) { - try { - const currentValue = rawCurrentValue?.trim() ?? ''; - if (currentValue.length > 2) { - return true; - } - } catch (e) { - console.error(e); - } - } - return Array.isArray(value) && value.length > 0; -}; - -export function useMCPSelect() { - const { conversation } = useChatContext(); - - const key = useMemo( - () => conversation?.conversationId ?? Constants.NEW_CONVO, - [conversation?.conversationId], - ); - - const hasSetFetched = useRef(null); - const [ephemeralAgent, setEphemeralAgent] = useRecoilState(ephemeralAgentByConvoId(key)); - const { data: startupConfig } = useGetStartupConfig(); - const { data: rawMcpTools, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, { - select: (data: TPlugin[]) => { - const mcpToolsMap = new Map(); - data.forEach((tool) => { - const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter); - if (isMCP) { - const parts = tool.pluginKey.split(Constants.mcp_delimiter); - const serverName = parts[parts.length - 1]; - if (!mcpToolsMap.has(serverName)) { - mcpToolsMap.set(serverName, { - name: serverName, - pluginKey: tool.pluginKey, - authConfig: tool.authConfig, - authenticated: tool.authenticated, - }); - } - } - }); - return Array.from(mcpToolsMap.values()); - }, - }); - - const mcpToolDetails = useMemo(() => { - if (!rawMcpTools || !startupConfig?.mcpServers) { - return rawMcpTools; - } - return rawMcpTools.filter((tool) => { - const serverConfig = startupConfig?.mcpServers?.[tool.name]; - return serverConfig?.chatMenu !== false; - }); - }, [rawMcpTools, startupConfig?.mcpServers]); - - const mcpState = useMemo(() => { - return ephemeralAgent?.mcp ?? []; - }, [ephemeralAgent?.mcp]); - - const setSelectedValues = useCallback( - (values: string[] | null | undefined) => { - if (!values) { - return; - } - if (!Array.isArray(values)) { - return; - } - setEphemeralAgent((prev) => ({ - ...prev, - mcp: values, - })); - }, - [setEphemeralAgent], - ); - - const [mcpValues, setMCPValuesRaw] = useLocalStorage( - `${LocalStorageKeys.LAST_MCP_}${key}`, - mcpState, - setSelectedValues, - storageCondition, - ); - - const setMCPValuesRawRef = useRef(setMCPValuesRaw); - setMCPValuesRawRef.current = setMCPValuesRaw; - - // Create a stable memoized setter to avoid re-creating it on every render and causing an infinite render loop - const setMCPValues = useCallback((value: string[]) => { - setMCPValuesRawRef.current(value); - }, []); - - const [isPinned, setIsPinned] = useLocalStorage( - `${LocalStorageKeys.PIN_MCP_}${key}`, - true, - ); - - useEffect(() => { - if (hasSetFetched.current === key) { - return; - } - if (!isFetched) { - return; - } - hasSetFetched.current = key; - if ((mcpToolDetails?.length ?? 0) > 0) { - setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp))); - return; - } - setMCPValues([]); - }, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]); - - const mcpServerNames = useMemo(() => { - return (mcpToolDetails ?? []).map((tool) => tool.name); - }, [mcpToolDetails]); - - return { - isPinned, - mcpValues, - setIsPinned, - setMCPValues, - mcpServerNames, - ephemeralAgent, - mcpToolDetails, - setEphemeralAgent, - }; -} diff --git a/client/src/hooks/useLocalStorageAlt.tsx b/client/src/hooks/useLocalStorageAlt.tsx index 81b3e637e..1b0d1eaa7 100644 --- a/client/src/hooks/useLocalStorageAlt.tsx +++ b/client/src/hooks/useLocalStorageAlt.tsx @@ -5,7 +5,7 @@ * - Also value will be updated everywhere, when value updated (via `storage` event) */ -import { useEffect, useState } from 'react'; +import { useEffect, useState, useCallback } from 'react'; export default function useLocalStorage( key: string, @@ -47,23 +47,26 @@ export default function useLocalStorage( // eslint-disable-next-line react-hooks/exhaustive-deps }, [key, globalSetState]); - const setValueWrap = (value: T) => { - try { - setValue(value); - const storeLocal = () => { - localStorage.setItem(key, JSON.stringify(value)); - window?.dispatchEvent(new StorageEvent('storage', { key })); - }; - if (!storageCondition) { - storeLocal(); - } else if (storageCondition(value, localStorage.getItem(key))) { - storeLocal(); + const setValueWrap = useCallback( + (value: T) => { + try { + setValue(value); + const storeLocal = () => { + localStorage.setItem(key, JSON.stringify(value)); + window?.dispatchEvent(new StorageEvent('storage', { key })); + }; + if (!storageCondition) { + storeLocal(); + } else if (storageCondition(value, localStorage.getItem(key))) { + storeLocal(); + } + globalSetState?.(value); + } catch (e) { + console.error(e); } - globalSetState?.(value); - } catch (e) { - console.error(e); - } - }; + }, + [key, globalSetState, storageCondition], + ); return [value, setValueWrap]; }