| import { useCallback, useState, useMemo, useRef, useEffect } from 'react'; |
| import { useToastContext } from '@librechat/client'; |
| import { useQueryClient } from '@tanstack/react-query'; |
| import { Constants, QueryKeys } from 'librechat-data-provider'; |
| import { |
| useCancelMCPOAuthMutation, |
| useUpdateUserPluginsMutation, |
| useReinitializeMCPServerMutation, |
| } from 'librechat-data-provider/react-query'; |
| import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider'; |
| import type { ConfigFieldDetail } from '~/common'; |
| import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks'; |
| import { useGetStartupConfig } from '~/data-provider'; |
|
|
| interface ServerState { |
| isInitializing: boolean; |
| oauthUrl: string | null; |
| oauthStartTime: number | null; |
| isCancellable: boolean; |
| pollInterval: NodeJS.Timeout | null; |
| } |
|
|
| export function useMCPServerManager({ conversationId }: { conversationId?: string | null } = {}) { |
| const localize = useLocalize(); |
| const queryClient = useQueryClient(); |
| const { showToast } = useToastContext(); |
| const { data: startupConfig } = useGetStartupConfig(); |
| const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId }); |
|
|
| const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); |
| const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null); |
| const previousFocusRef = useRef<HTMLElement | null>(null); |
| const mcpValuesRef = useRef(mcpValues); |
|
|
| |
| useEffect(() => { |
| mcpValuesRef.current = mcpValues; |
| }, [mcpValues]); |
|
|
| const configuredServers = useMemo(() => { |
| if (!startupConfig?.mcpServers) return []; |
| return Object.entries(startupConfig.mcpServers) |
| .filter(([, config]) => config.chatMenu !== false) |
| .map(([serverName]) => serverName); |
| }, [startupConfig?.mcpServers]); |
|
|
| const reinitializeMutation = useReinitializeMCPServerMutation(); |
| const cancelOAuthMutation = useCancelMCPOAuthMutation(); |
|
|
| const updateUserPluginsMutation = useUpdateUserPluginsMutation({ |
| onSuccess: async () => { |
| showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); |
|
|
| await Promise.all([ |
| queryClient.invalidateQueries([QueryKeys.mcpTools]), |
| queryClient.invalidateQueries([QueryKeys.mcpAuthValues]), |
| queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]), |
| ]); |
| }, |
| onError: (error: unknown) => { |
| console.error('Error updating MCP auth:', error); |
| showToast({ |
| message: localize('com_nav_mcp_vars_update_error'), |
| status: 'error', |
| }); |
| }, |
| }); |
|
|
| const [serverStates, setServerStates] = useState<Record<string, ServerState>>(() => { |
| const initialStates: Record<string, ServerState> = {}; |
| configuredServers.forEach((serverName) => { |
| initialStates[serverName] = { |
| isInitializing: false, |
| oauthUrl: null, |
| oauthStartTime: null, |
| isCancellable: false, |
| pollInterval: null, |
| }; |
| }); |
| return initialStates; |
| }); |
|
|
| const { connectionStatus } = useMCPConnectionStatus({ |
| enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0, |
| }); |
|
|
| const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => { |
| setServerStates((prev) => { |
| const newStates = { ...prev }; |
| const currentState = newStates[serverName] || { |
| isInitializing: false, |
| oauthUrl: null, |
| oauthStartTime: null, |
| isCancellable: false, |
| pollInterval: null, |
| }; |
| newStates[serverName] = { ...currentState, ...updates }; |
| return newStates; |
| }); |
| }, []); |
|
|
| const cleanupServerState = useCallback( |
| (serverName: string) => { |
| const state = serverStates[serverName]; |
| if (state?.pollInterval) { |
| clearTimeout(state.pollInterval); |
| } |
| updateServerState(serverName, { |
| isInitializing: false, |
| oauthUrl: null, |
| oauthStartTime: null, |
| isCancellable: false, |
| pollInterval: null, |
| }); |
| }, |
| [serverStates, updateServerState], |
| ); |
|
|
| const startServerPolling = useCallback( |
| (serverName: string) => { |
| |
| const existingState = serverStates[serverName]; |
| if (existingState?.pollInterval) { |
| console.debug(`[MCP Manager] Polling already active for ${serverName}, skipping duplicate`); |
| return; |
| } |
|
|
| let pollAttempts = 0; |
| let timeoutId: NodeJS.Timeout | null = null; |
|
|
| |
| |
| |
| const getPollInterval = (attempt: number): number => { |
| if (attempt < 12) return 5000; |
| if (attempt < 22) return 6000; |
| return 7500; |
| }; |
|
|
| const maxAttempts = 30; |
| const OAUTH_TIMEOUT_MS = 180000; |
|
|
| const pollOnce = async () => { |
| try { |
| pollAttempts++; |
| const state = serverStates[serverName]; |
|
|
| |
| const elapsedTime = state?.oauthStartTime |
| ? Date.now() - state.oauthStartTime |
| : pollAttempts * 5000; |
|
|
| if (pollAttempts > maxAttempts || elapsedTime > OAUTH_TIMEOUT_MS) { |
| console.warn( |
| `[MCP Manager] OAuth timeout for ${serverName} after ${(elapsedTime / 1000).toFixed(0)}s (attempt ${pollAttempts})`, |
| ); |
| showToast({ |
| message: localize('com_ui_mcp_oauth_timeout', { 0: serverName }), |
| status: 'error', |
| }); |
| if (timeoutId) { |
| clearTimeout(timeoutId); |
| } |
| cleanupServerState(serverName); |
| return; |
| } |
|
|
| await queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]); |
|
|
| const freshConnectionData = queryClient.getQueryData([ |
| QueryKeys.mcpConnectionStatus, |
| ]) as any; |
| const freshConnectionStatus = freshConnectionData?.connectionStatus || {}; |
|
|
| const serverStatus = freshConnectionStatus[serverName]; |
|
|
| if (serverStatus?.connectionState === 'connected') { |
| if (timeoutId) { |
| clearTimeout(timeoutId); |
| } |
|
|
| showToast({ |
| message: localize('com_ui_mcp_authenticated_success', { 0: serverName }), |
| status: 'success', |
| }); |
|
|
| const currentValues = mcpValuesRef.current ?? []; |
| if (!currentValues.includes(serverName)) { |
| setMCPValues([...currentValues, serverName]); |
| } |
|
|
| await queryClient.invalidateQueries([QueryKeys.mcpTools]); |
|
|
| |
| |
| setTimeout(() => { |
| cleanupServerState(serverName); |
| }, 1000); |
| return; |
| } |
|
|
| |
| if (state?.oauthStartTime && Date.now() - state.oauthStartTime > OAUTH_TIMEOUT_MS) { |
| showToast({ |
| message: localize('com_ui_mcp_oauth_timeout', { 0: serverName }), |
| status: 'error', |
| }); |
| if (timeoutId) { |
| clearTimeout(timeoutId); |
| } |
| cleanupServerState(serverName); |
| return; |
| } |
|
|
| if (serverStatus?.connectionState === 'error') { |
| showToast({ |
| message: localize('com_ui_mcp_init_failed'), |
| status: 'error', |
| }); |
| if (timeoutId) { |
| clearTimeout(timeoutId); |
| } |
| cleanupServerState(serverName); |
| return; |
| } |
|
|
| |
| const nextInterval = getPollInterval(pollAttempts); |
|
|
| |
| if (pollAttempts % 5 === 0 || pollAttempts <= 2) { |
| console.debug( |
| `[MCP Manager] Polling ${serverName} attempt ${pollAttempts}/${maxAttempts}, next in ${nextInterval / 1000}s`, |
| ); |
| } |
|
|
| timeoutId = setTimeout(pollOnce, nextInterval); |
| updateServerState(serverName, { pollInterval: timeoutId }); |
| } catch (error) { |
| console.error(`[MCP Manager] Error polling server ${serverName}:`, error); |
| if (timeoutId) { |
| clearTimeout(timeoutId); |
| } |
| cleanupServerState(serverName); |
| return; |
| } |
| }; |
|
|
| |
| timeoutId = setTimeout(pollOnce, getPollInterval(0)); |
| updateServerState(serverName, { pollInterval: timeoutId }); |
| }, |
| [ |
| queryClient, |
| serverStates, |
| showToast, |
| localize, |
| setMCPValues, |
| cleanupServerState, |
| updateServerState, |
| ], |
| ); |
|
|
| const initializeServer = useCallback( |
| async (serverName: string, autoOpenOAuth: boolean = true) => { |
| updateServerState(serverName, { isInitializing: true }); |
| try { |
| 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.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({ |
| message: localize('com_ui_mcp_init_failed', { 0: serverName }), |
| status: 'error', |
| }); |
| cleanupServerState(serverName); |
| } |
| }, |
| [ |
| updateServerState, |
| reinitializeMutation, |
| startServerPolling, |
| queryClient, |
| showToast, |
| localize, |
| mcpValues, |
| cleanupServerState, |
| setMCPValues, |
| ], |
| ); |
|
|
| const cancelOAuthFlow = useCallback( |
| (serverName: string) => { |
| cancelOAuthMutation.mutate(serverName, { |
| onSuccess: () => { |
| cleanupServerState(serverName); |
| queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); |
|
|
| showToast({ |
| message: localize('com_ui_mcp_oauth_cancelled', { 0: serverName }), |
| status: 'warning', |
| }); |
| }, |
| onError: (error) => { |
| console.error(`[MCP Manager] Failed to cancel OAuth for ${serverName}:`, error); |
| showToast({ |
| message: localize('com_ui_mcp_init_failed', { 0: serverName }), |
| status: 'error', |
| }); |
| }, |
| }); |
| }, |
| [queryClient, cleanupServerState, showToast, localize, cancelOAuthMutation], |
| ); |
|
|
| const isInitializing = useCallback( |
| (serverName: string) => { |
| return serverStates[serverName]?.isInitializing || false; |
| }, |
| [serverStates], |
| ); |
|
|
| const isCancellable = useCallback( |
| (serverName: string) => { |
| return serverStates[serverName]?.isCancellable || false; |
| }, |
| [serverStates], |
| ); |
|
|
| const getOAuthUrl = useCallback( |
| (serverName: string) => { |
| return serverStates[serverName]?.oauthUrl || null; |
| }, |
| [serverStates], |
| ); |
|
|
| const placeholderText = useMemo( |
| () => startupConfig?.interface?.mcpServers?.placeholder || localize('com_ui_mcp_servers'), |
| [startupConfig?.interface?.mcpServers?.placeholder, localize], |
| ); |
|
|
| const batchToggleServers = useCallback( |
| (serverNames: string[]) => { |
| const connectedServers: string[] = []; |
| const disconnectedServers: string[] = []; |
|
|
| serverNames.forEach((serverName) => { |
| if (isInitializing(serverName)) { |
| return; |
| } |
|
|
| const serverStatus = connectionStatus?.[serverName]; |
| if (serverStatus?.connectionState === 'connected') { |
| connectedServers.push(serverName); |
| } else { |
| disconnectedServers.push(serverName); |
| } |
| }); |
|
|
| setMCPValues(connectedServers); |
|
|
| disconnectedServers.forEach((serverName) => { |
| initializeServer(serverName); |
| }); |
| }, |
| [connectionStatus, setMCPValues, initializeServer, isInitializing], |
| ); |
|
|
| const toggleServerSelection = useCallback( |
| (serverName: string) => { |
| if (isInitializing(serverName)) { |
| return; |
| } |
|
|
| const currentValues = mcpValues ?? []; |
| const isCurrentlySelected = currentValues.includes(serverName); |
|
|
| if (isCurrentlySelected) { |
| const filteredValues = currentValues.filter((name) => name !== serverName); |
| setMCPValues(filteredValues); |
| } else { |
| const serverStatus = connectionStatus?.[serverName]; |
| if (serverStatus?.connectionState === 'connected') { |
| setMCPValues([...currentValues, serverName]); |
| } else { |
| initializeServer(serverName); |
| } |
| } |
| }, |
| [mcpValues, setMCPValues, connectionStatus, initializeServer, isInitializing], |
| ); |
|
|
| const handleConfigSave = useCallback( |
| (targetName: string, authData: Record<string, string>) => { |
| if (selectedToolForConfig && selectedToolForConfig.name === targetName) { |
| const payload: TUpdateUserPlugins = { |
| pluginKey: `${Constants.mcp_prefix}${targetName}`, |
| action: 'install', |
| auth: authData, |
| }; |
| updateUserPluginsMutation.mutate(payload); |
| } |
| }, |
| [selectedToolForConfig, updateUserPluginsMutation], |
| ); |
|
|
| const handleConfigRevoke = useCallback( |
| (targetName: string) => { |
| if (selectedToolForConfig && selectedToolForConfig.name === targetName) { |
| const payload: TUpdateUserPlugins = { |
| pluginKey: `${Constants.mcp_prefix}${targetName}`, |
| action: 'uninstall', |
| auth: {}, |
| }; |
| updateUserPluginsMutation.mutate(payload); |
|
|
| const currentValues = mcpValues ?? []; |
| const filteredValues = currentValues.filter((name) => name !== targetName); |
| setMCPValues(filteredValues); |
| } |
| }, |
| [selectedToolForConfig, updateUserPluginsMutation, mcpValues, setMCPValues], |
| ); |
|
|
| const handleSave = useCallback( |
| (authData: Record<string, string>) => { |
| if (selectedToolForConfig) { |
| handleConfigSave(selectedToolForConfig.name, authData); |
| } |
| }, |
| [selectedToolForConfig, handleConfigSave], |
| ); |
|
|
| const handleRevoke = useCallback(() => { |
| if (selectedToolForConfig) { |
| handleConfigRevoke(selectedToolForConfig.name); |
| } |
| }, [selectedToolForConfig, handleConfigRevoke]); |
|
|
| const handleDialogOpenChange = useCallback((open: boolean) => { |
| setIsConfigModalOpen(open); |
|
|
| if (!open && previousFocusRef.current) { |
| setTimeout(() => { |
| if (previousFocusRef.current && typeof previousFocusRef.current.focus === 'function') { |
| previousFocusRef.current.focus(); |
| } |
| previousFocusRef.current = null; |
| }, 0); |
| } |
| }, []); |
|
|
| const getServerStatusIconProps = useCallback( |
| (serverName: string) => { |
| const mcpData = queryClient.getQueryData<MCPServersResponse | undefined>([ |
| QueryKeys.mcpTools, |
| ]); |
| const serverData = mcpData?.servers?.[serverName]; |
| const serverStatus = connectionStatus?.[serverName]; |
| const serverConfig = startupConfig?.mcpServers?.[serverName]; |
|
|
| const handleConfigClick = (e: React.MouseEvent) => { |
| e.stopPropagation(); |
| e.preventDefault(); |
|
|
| previousFocusRef.current = document.activeElement as HTMLElement; |
|
|
| |
| const configTool: TPlugin = { |
| name: serverName, |
| pluginKey: `${Constants.mcp_prefix}${serverName}`, |
| authConfig: |
| serverData?.authConfig || |
| (serverConfig?.customUserVars |
| ? Object.entries(serverConfig.customUserVars).map(([key, config]) => ({ |
| authField: key, |
| label: config.title, |
| description: config.description, |
| })) |
| : []), |
| authenticated: serverData?.authenticated ?? false, |
| }; |
| setSelectedToolForConfig(configTool); |
| setIsConfigModalOpen(true); |
| }; |
|
|
| const handleCancelClick = (e: React.MouseEvent) => { |
| e.stopPropagation(); |
| e.preventDefault(); |
| cancelOAuthFlow(serverName); |
| }; |
|
|
| const hasCustomUserVars = |
| serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0; |
|
|
| return { |
| serverName, |
| serverStatus, |
| tool: serverData |
| ? ({ |
| name: serverName, |
| pluginKey: `${Constants.mcp_prefix}${serverName}`, |
| icon: serverData.icon, |
| authenticated: serverData.authenticated, |
| } as TPlugin) |
| : undefined, |
| onConfigClick: handleConfigClick, |
| isInitializing: isInitializing(serverName), |
| canCancel: isCancellable(serverName), |
| onCancel: handleCancelClick, |
| hasCustomUserVars, |
| }; |
| }, |
| [ |
| queryClient, |
| isCancellable, |
| isInitializing, |
| cancelOAuthFlow, |
| connectionStatus, |
| startupConfig?.mcpServers, |
| ], |
| ); |
|
|
| const getConfigDialogProps = useCallback(() => { |
| if (!selectedToolForConfig) return null; |
|
|
| const fieldsSchema: Record<string, ConfigFieldDetail> = {}; |
| if (selectedToolForConfig?.authConfig) { |
| selectedToolForConfig.authConfig.forEach((field) => { |
| fieldsSchema[field.authField] = { |
| title: field.label || field.authField, |
| description: field.description, |
| }; |
| }); |
| } |
|
|
| const initialValues: Record<string, string> = {}; |
| if (selectedToolForConfig?.authConfig) { |
| selectedToolForConfig.authConfig.forEach((field) => { |
| initialValues[field.authField] = ''; |
| }); |
| } |
|
|
| return { |
| serverName: selectedToolForConfig.name, |
| serverStatus: connectionStatus?.[selectedToolForConfig.name], |
| isOpen: isConfigModalOpen, |
| onOpenChange: handleDialogOpenChange, |
| fieldsSchema, |
| initialValues, |
| onSave: handleSave, |
| onRevoke: handleRevoke, |
| isSubmitting: updateUserPluginsMutation.isLoading, |
| }; |
| }, [ |
| selectedToolForConfig, |
| connectionStatus, |
| isConfigModalOpen, |
| handleDialogOpenChange, |
| handleSave, |
| handleRevoke, |
| updateUserPluginsMutation.isLoading, |
| ]); |
|
|
| return { |
| configuredServers, |
| initializeServer, |
| cancelOAuthFlow, |
| isInitializing, |
| isCancellable, |
| getOAuthUrl, |
| mcpValues, |
| setMCPValues, |
|
|
| isPinned, |
| setIsPinned, |
| placeholderText, |
| batchToggleServers, |
| toggleServerSelection, |
| localize, |
|
|
| isConfigModalOpen, |
| handleDialogOpenChange, |
| selectedToolForConfig, |
| setSelectedToolForConfig, |
| handleSave, |
| handleRevoke, |
| getServerStatusIconProps, |
| getConfigDialogProps, |
| }; |
| } |
|
|