const { logger } = require('@librechat/data-schemas'); const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider'); const { webSearchKeys, MCPOAuthHandler, MCPTokenStorage, normalizeHttpError, extractWebSearchEnvVars, } = require('@librechat/api'); const { getFiles, findToken, updateUser, deleteFiles, deleteConvos, deletePresets, deleteMessages, deleteUserById, deleteAllSharedLinks, deleteAllUserSessions, } = require('~/models'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); const { getAppConfig, clearMCPServerTools } = require('~/server/services/Config'); const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud'); const { processDeleteRequest } = require('~/server/services/Files/process'); const { Transaction, Balance, User, Token } = require('~/db/models'); const { getMCPManager, getFlowStateManager } = require('~/config'); const { deleteToolCalls } = require('~/models/ToolCall'); const { getLogStores } = require('~/cache'); const getUserController = async (req, res) => { const appConfig = await getAppConfig({ role: req.user?.role }); /** @type {IUser} */ const userData = req.user.toObject != null ? req.user.toObject() : { ...req.user }; /** * These fields should not exist due to secure field selection, but deletion * is done in case of alternate database incompatibility with Mongo API * */ delete userData.password; delete userData.totpSecret; delete userData.backupCodes; if (appConfig.fileStrategy === FileSources.s3 && userData.avatar) { const avatarNeedsRefresh = needsRefresh(userData.avatar, 3600); if (!avatarNeedsRefresh) { return res.status(200).send(userData); } const originalAvatar = userData.avatar; try { userData.avatar = await getNewS3URL(userData.avatar); await updateUser(userData.id, { avatar: userData.avatar }); } catch (error) { userData.avatar = originalAvatar; logger.error('Error getting new S3 URL for avatar:', error); } } res.status(200).send(userData); }; const getTermsStatusController = async (req, res) => { try { const user = await User.findById(req.user.id); if (!user) { return res.status(404).json({ message: 'User not found' }); } res.status(200).json({ termsAccepted: !!user.termsAccepted }); } catch (error) { logger.error('Error fetching terms acceptance status:', error); res.status(500).json({ message: 'Error fetching terms acceptance status' }); } }; const acceptTermsController = async (req, res) => { try { const user = await User.findByIdAndUpdate(req.user.id, { termsAccepted: true }, { new: true }); if (!user) { return res.status(404).json({ message: 'User not found' }); } res.status(200).json({ message: 'Terms accepted successfully' }); } catch (error) { logger.error('Error accepting terms:', error); res.status(500).json({ message: 'Error accepting terms' }); } }; const deleteUserFiles = async (req) => { try { const userFiles = await getFiles({ user: req.user.id }); await processDeleteRequest({ req, files: userFiles, }); } catch (error) { logger.error('[deleteUserFiles]', error); } }; const updateUserPluginsController = async (req, res) => { const appConfig = await getAppConfig({ role: req.user?.role }); const { user } = req; const { pluginKey, action, auth, isEntityTool } = req.body; try { if (!isEntityTool) { const userPluginsService = await updateUserPluginsService(user, pluginKey, action); if (userPluginsService instanceof Error) { logger.error('[userPluginsService]', userPluginsService); const { status, message } = normalizeHttpError(userPluginsService); return res.status(status).send({ message }); } } if (auth == null) { return res.status(200).send(); } let keys = Object.keys(auth); const values = Object.values(auth); // Used in 'install' block const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter); // Early exit condition: // If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install) // AND it's not web_search (which has special key handling to populate `keys` for uninstall) // AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth) // THEN return. if ( keys.length === 0 && pluginKey !== Tools.web_search && !(action === 'uninstall' && isMCPTool) ) { return res.status(200).send(); } /** @type {number} */ let status = 200; /** @type {string} */ let message; /** @type {IPluginAuth | Error} */ let authService; if (pluginKey === Tools.web_search) { /** @type {TCustomConfig['webSearch']} */ const webSearchConfig = appConfig?.webSearch; keys = extractWebSearchEnvVars({ keys: action === 'install' ? keys : webSearchKeys, config: webSearchConfig, }); } if (action === 'install') { for (let i = 0; i < keys.length; i++) { authService = await updateUserPluginAuth(user.id, keys[i], pluginKey, values[i]); if (authService instanceof Error) { logger.error('[authService]', authService); ({ status, message } = normalizeHttpError(authService)); } } } else if (action === 'uninstall') { // const isMCPTool was defined earlier if (isMCPTool && keys.length === 0) { // This handles the case where auth: {} is sent for an MCP tool uninstall. // It means "delete all credentials associated with this MCP pluginKey". authService = await deleteUserPluginAuth(user.id, null, true, pluginKey); if (authService instanceof Error) { logger.error( `[authService] Error deleting all auth for MCP tool ${pluginKey}:`, authService, ); ({ status, message } = normalizeHttpError(authService)); } try { // if the MCP server uses OAuth, perform a full cleanup and token revocation await maybeUninstallOAuthMCP(user.id, pluginKey, appConfig); } catch (error) { logger.error( `[updateUserPluginsController] Error uninstalling OAuth MCP for ${pluginKey}:`, error, ); } } else { // This handles: // 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}). // 2. Other tools uninstall (if keys were provided). // 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior). // If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted. for (let i = 0; i < keys.length; i++) { authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name if (authService instanceof Error) { logger.error('[authService] Error deleting specific auth key:', authService); ({ status, message } = normalizeHttpError(authService)); } } } } if (status === 200) { // If auth was updated successfully, disconnect MCP sessions as they might use these credentials if (pluginKey.startsWith(Constants.mcp_prefix)) { try { const mcpManager = getMCPManager(user.id); if (mcpManager) { // Extract server name from pluginKey (format: "mcp_") const serverName = pluginKey.replace(Constants.mcp_prefix, ''); logger.info( `[updateUserPluginsController] Attempting disconnect of MCP server "${serverName}" for user ${user.id} after plugin auth update.`, ); await mcpManager.disconnectUserConnection(user.id, serverName); } } catch (disconnectError) { logger.error( `[updateUserPluginsController] Error disconnecting MCP connection for user ${user.id} after plugin auth update:`, disconnectError, ); // Do not fail the request for this, but log it. } } return res.status(status).send(); } const normalized = normalizeHttpError({ status, message }); return res.status(normalized.status).send({ message: normalized.message }); } catch (err) { logger.error('[updateUserPluginsController]', err); return res.status(500).json({ message: 'Something went wrong.' }); } }; const deleteUserController = async (req, res) => { const { user } = req; try { await deleteMessages({ user: user.id }); // delete user messages await deleteAllUserSessions({ userId: user.id }); // delete user sessions await Transaction.deleteMany({ user: user.id }); // delete user transactions await deleteUserKey({ userId: user.id, all: true }); // delete user keys await Balance.deleteMany({ user: user._id }); // delete user balances await deletePresets(user.id); // delete user presets /* TODO: Delete Assistant Threads */ try { await deleteConvos(user.id); // delete user convos } catch (error) { logger.error('[deleteUserController] Error deleting user convos, likely no convos', error); } await deleteUserPluginAuth(user.id, null, true); // delete user plugin auth await deleteUserById(user.id); // delete user await deleteAllSharedLinks(user.id); // delete user shared links await deleteUserFiles(req); // delete user files await deleteFiles(null, user.id); // delete database files in case of orphaned files from previous steps await deleteToolCalls(user.id); // delete user tool calls /* TODO: queue job for cleaning actions and assistants of non-existant users */ logger.info(`User deleted account. Email: ${user.email} ID: ${user.id}`); res.status(200).send({ message: 'User deleted' }); } catch (err) { logger.error('[deleteUserController]', err); return res.status(500).json({ message: 'Something went wrong.' }); } }; const verifyEmailController = async (req, res) => { try { const verifyEmailService = await verifyEmail(req); if (verifyEmailService instanceof Error) { return res.status(400).json(verifyEmailService); } else { return res.status(200).json(verifyEmailService); } } catch (e) { logger.error('[verifyEmailController]', e); return res.status(500).json({ message: 'Something went wrong.' }); } }; const resendVerificationController = async (req, res) => { try { const result = await resendVerificationEmail(req); if (result instanceof Error) { return res.status(400).json(result); } else { return res.status(200).json(result); } } catch (e) { logger.error('[verifyEmailController]', e); return res.status(500).json({ message: 'Something went wrong.' }); } }; /** * OAuth MCP specific uninstall logic */ const maybeUninstallOAuthMCP = async (userId, pluginKey, appConfig) => { if (!pluginKey.startsWith(Constants.mcp_prefix)) { // this is not an MCP server, so nothing to do here return; } const serverName = pluginKey.replace(Constants.mcp_prefix, ''); const mcpManager = getMCPManager(userId); const serverConfig = mcpManager.getRawConfig(serverName) ?? appConfig?.mcpServers?.[serverName]; if (!mcpManager.getOAuthServers().has(serverName)) { // this server does not use OAuth, so nothing to do here as well return; } // 1. get client info used for revocation (client id, secret) const clientTokenData = await MCPTokenStorage.getClientInfoAndMetadata({ userId, serverName, findToken, }); if (clientTokenData == null) { return; } const { clientInfo, clientMetadata } = clientTokenData; // 2. get decrypted tokens before deletion const tokens = await MCPTokenStorage.getTokens({ userId, serverName, findToken, }); // 3. revoke OAuth tokens at the provider const revocationEndpoint = serverConfig.oauth?.revocation_endpoint ?? clientMetadata.revocation_endpoint; const revocationEndpointAuthMethodsSupported = serverConfig.oauth?.revocation_endpoint_auth_methods_supported ?? clientMetadata.revocation_endpoint_auth_methods_supported; if (tokens?.access_token) { try { await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.access_token, 'access', { serverUrl: serverConfig.url, clientId: clientInfo.client_id, clientSecret: clientInfo.client_secret ?? '', revocationEndpoint, revocationEndpointAuthMethodsSupported, }); } catch (error) { logger.error(`Error revoking OAuth access token for ${serverName}:`, error); } } if (tokens?.refresh_token) { try { await MCPOAuthHandler.revokeOAuthToken(serverName, tokens.refresh_token, 'refresh', { serverUrl: serverConfig.url, clientId: clientInfo.client_id, clientSecret: clientInfo.client_secret ?? '', revocationEndpoint, revocationEndpointAuthMethodsSupported, }); } catch (error) { logger.error(`Error revoking OAuth refresh token for ${serverName}:`, error); } } // 4. delete tokens from the DB after revocation attempts await MCPTokenStorage.deleteUserTokens({ userId, serverName, deleteToken: async (filter) => { await Token.deleteOne(filter); }, }); // 5. clear the flow state for the OAuth tokens const flowsCache = getLogStores(CacheKeys.FLOWS); const flowManager = getFlowStateManager(flowsCache); const flowId = MCPOAuthHandler.generateFlowId(userId, serverName); await flowManager.deleteFlow(flowId, 'mcp_get_tokens'); await flowManager.deleteFlow(flowId, 'mcp_oauth'); // 6. clear the tools cache for the server await clearMCPServerTools({ userId, serverName }); }; module.exports = { getUserController, getTermsStatusController, acceptTermsController, deleteUserController, verifyEmailController, updateUserPluginsController, resendVerificationController, };