diff --git a/.gitignore b/.gitignore index f49594afd..c9658f17e 100644 --- a/.gitignore +++ b/.gitignore @@ -55,6 +55,7 @@ bower_components/ # AI .clineignore .cursor +.aider* # Floobits .floo diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index b5a40fc4a..c233c0f76 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -1,15 +1,14 @@ +const { mcpToolPattern } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { SerpAPI } = require('@langchain/community/tools/serpapi'); const { Calculator } = require('@langchain/community/tools/calculator'); const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents'); const { Tools, - Constants, EToolResources, loadWebSearchAuth, replaceSpecialVars, } = require('librechat-data-provider'); -const { getUserPluginAuthValue } = require('~/server/services/PluginService'); const { availableTools, manifestToolMap, @@ -29,12 +28,11 @@ const { } = require('../'); const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process'); const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch'); +const { getUserPluginAuthValue } = require('~/server/services/PluginService'); const { loadAuthValues } = require('~/server/services/Tools/credentials'); const { getCachedTools } = require('~/server/services/Config'); const { createMCPTool } = require('~/server/services/MCP'); -const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`); - /** * Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values. * Tools without required authentication or with valid authentication are considered valid. @@ -94,7 +92,7 @@ const validateTools = async (user, tools = []) => { return Array.from(validToolsSet.values()); } catch (err) { logger.error('[validateTools] There was a problem validating tools', err); - throw new Error('There was a problem validating tools'); + throw new Error(err); } }; diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index 98e9cbfc4..f7aad84ae 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -5,6 +5,7 @@ const { getToolkitKey } = require('~/server/services/ToolService'); const { getMCPManager, getFlowStateManager } = require('~/config'); const { availableTools } = require('~/app/clients/tools'); const { getLogStores } = require('~/cache'); +const { Constants } = require('librechat-data-provider'); /** * Filters out duplicate plugins from the list of plugins. @@ -173,16 +174,56 @@ const getAvailableTools = async (req, res) => { }); const toolDefinitions = await getCachedTools({ includeGlobal: true }); - const tools = authenticatedPlugins.filter( - (plugin) => - toolDefinitions[plugin.pluginKey] !== undefined || - (plugin.toolkit === true && - Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)), - ); - await cache.set(CacheKeys.TOOLS, tools); - res.status(200).json(tools); + const toolsOutput = []; + for (const plugin of authenticatedPlugins) { + const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined; + const isToolkit = + plugin.toolkit === true && + Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey); + + if (!isToolDefined && !isToolkit) { + continue; + } + + const toolToAdd = { ...plugin }; + + if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) { + toolsOutput.push(toolToAdd); + continue; + } + + const parts = plugin.pluginKey.split(Constants.mcp_delimiter); + const serverName = parts[parts.length - 1]; + const serverConfig = customConfig?.mcpServers?.[serverName]; + + if (!serverConfig?.customUserVars) { + toolsOutput.push(toolToAdd); + continue; + } + + const customVarKeys = Object.keys(serverConfig.customUserVars); + + if (customVarKeys.length === 0) { + toolToAdd.authConfig = []; + toolToAdd.authenticated = true; + } else { + toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({ + authField: key, + label: value.title || key, + description: value.description || '', + })); + toolToAdd.authenticated = false; + } + + toolsOutput.push(toolToAdd); + } + + const finalTools = filterUniquePlugins(toolsOutput); + await cache.set(CacheKeys.TOOLS, finalTools); + res.status(200).json(finalTools); } catch (error) { + logger.error('[getAvailableTools]', error); res.status(500).json({ message: error.message }); } }; diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index bcffb2189..69791dd7a 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -1,5 +1,6 @@ const { Tools, + Constants, FileSources, webSearchKeys, extractWebSearchEnvVars, @@ -23,6 +24,7 @@ const { processDeleteRequest } = require('~/server/services/Files/process'); const { Transaction, Balance, User } = require('~/db/models'); const { deleteToolCalls } = require('~/models/ToolCall'); const { deleteAllSharedLinks } = require('~/models'); +const { getMCPManager } = require('~/config'); const getUserController = async (req, res) => { /** @type {MongoUser} */ @@ -102,10 +104,22 @@ const updateUserPluginsController = async (req, res) => { } let keys = Object.keys(auth); - if (keys.length === 0 && pluginKey !== Tools.web_search) { + 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(); } - const values = Object.values(auth); /** @type {number} */ let status = 200; @@ -132,16 +146,53 @@ const updateUserPluginsController = async (req, res) => { } } } else if (action === 'uninstall') { - for (let i = 0; i < keys.length; i++) { - authService = await deleteUserPluginAuth(user.id, keys[i]); + // 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]', authService); + logger.error( + `[authService] Error deleting all auth for MCP tool ${pluginKey}:`, + authService, + ); ({ status, message } = authService); } + } 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 } = 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) { + logger.info( + `[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`, + ); + await mcpManager.disconnectUserConnections(user.id); + } + } catch (disconnectError) { + logger.error( + `[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`, + disconnectError, + ); + // Do not fail the request for this, but log it. + } + } return res.status(status).send(); } diff --git a/api/server/controllers/agents/client.js b/api/server/controllers/agents/client.js index 41e457e5b..6769348d9 100644 --- a/api/server/controllers/agents/client.js +++ b/api/server/controllers/agents/client.js @@ -31,11 +31,15 @@ const { } = require('librechat-data-provider'); const { DynamicStructuredTool } = require('@langchain/core/tools'); const { getBufferString, HumanMessage } = require('@langchain/core/messages'); -const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config'); +const { + getCustomEndpointConfig, + createGetMCPAuthMap, + checkCapability, +} = require('~/server/services/Config'); const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts'); const { initializeAgent } = require('~/server/services/Endpoints/agents/agent'); const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens'); -const { setMemory, deleteMemory, getFormattedMemories } = require('~/models'); +const { getFormattedMemories, deleteMemory, setMemory } = require('~/models'); const { encodeAndFormat } = require('~/server/services/Files/images/encode'); const initOpenAI = require('~/server/services/Endpoints/openAI/initialize'); const { checkAccess } = require('~/server/middleware/roles/access'); @@ -679,6 +683,8 @@ class AgentClient extends BaseClient { version: 'v2', }; + const getUserMCPAuthMap = await createGetMCPAuthMap(); + const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name)); let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages( payload, @@ -798,6 +804,20 @@ class AgentClient extends BaseClient { run.Graph.contentData = contentData; } + try { + if (getUserMCPAuthMap) { + config.configurable.userMCPAuthMap = await getUserMCPAuthMap({ + tools: agent.tools, + userId: this.options.req.user.id, + }); + } + } catch (err) { + logger.error( + `[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`, + err, + ); + } + await run.processStream({ messages }, config, { keepContent: i !== 0, tokenCounter: createTokenCounter(this.getEncoding()), diff --git a/api/server/routes/config.js b/api/server/routes/config.js index a53a636d0..e50fb9f45 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,10 +1,11 @@ const express = require('express'); +const { logger } = require('@librechat/data-schemas'); const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider'); +const { getCustomConfig } = require('~/server/services/Config/getCustomConfig'); const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getProjectByName } = require('~/models/Project'); const { isEnabled } = require('~/server/utils'); const { getLogStores } = require('~/cache'); -const { logger } = require('~/config'); const router = express.Router(); const emailLoginEnabled = @@ -21,12 +22,15 @@ const publicSharedLinksEnabled = router.get('/', async function (req, res) { const cache = getLogStores(CacheKeys.CONFIG_STORE); + const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG); if (cachedStartupConfig) { res.send(cachedStartupConfig); return; } + const config = await getCustomConfig(); + const isBirthday = () => { const today = new Date(); return today.getMonth() === 1 && today.getDate() === 11; @@ -96,6 +100,17 @@ router.get('/', async function (req, res) { bundlerURL: process.env.SANDPACK_BUNDLER_URL, staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL, }; + + payload.mcpServers = {}; + if (config.mcpServers) { + for (const serverName in config.mcpServers) { + const serverConfig = config.mcpServers[serverName]; + payload.mcpServers[serverName] = { + customUserVars: serverConfig?.customUserVars || {}, + }; + } + } + /** @type {TCustomConfig['webSearch']} */ const webSearchConfig = req.app.locals.webSearch; if ( diff --git a/api/server/services/Config/getCustomConfig.js b/api/server/services/Config/getCustomConfig.js index 74828789f..0851b89a4 100644 --- a/api/server/services/Config/getCustomConfig.js +++ b/api/server/services/Config/getCustomConfig.js @@ -1,6 +1,10 @@ +const { logger } = require('@librechat/data-schemas'); +const { getUserMCPAuthMap } = require('@librechat/api'); const { CacheKeys, EModelEndpoint } = require('librechat-data-provider'); const { normalizeEndpointName, isEnabled } = require('~/server/utils'); const loadCustomConfig = require('./loadCustomConfig'); +const { getCachedTools } = require('./getCachedTools'); +const { findPluginAuthsByKeys } = require('~/models'); const getLogStores = require('~/cache/getLogStores'); /** @@ -50,4 +54,46 @@ const getCustomEndpointConfig = async (endpoint) => { ); }; -module.exports = { getCustomConfig, getBalanceConfig, getCustomEndpointConfig }; +async function createGetMCPAuthMap() { + const customConfig = await getCustomConfig(); + const mcpServers = customConfig?.mcpServers; + const hasCustomUserVars = Object.values(mcpServers).some((server) => server.customUserVars); + if (!hasCustomUserVars) { + return; + } + + /** + * @param {Object} params + * @param {GenericTool[]} [params.tools] + * @param {string} params.userId + * @returns {Promise> | undefined>} + */ + return async function ({ tools, userId }) { + try { + if (!tools || tools.length === 0) { + return; + } + const appTools = await getCachedTools({ + userId, + }); + return await getUserMCPAuthMap({ + tools, + userId, + appTools, + findPluginAuthsByKeys, + }); + } catch (err) { + logger.error( + `[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`, + err, + ); + } + }; +} + +module.exports = { + getCustomConfig, + getBalanceConfig, + createGetMCPAuthMap, + getCustomEndpointConfig, +}; diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index 972030566..527fe2d51 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -168,6 +168,9 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) { derivedSignal.addEventListener('abort', abortHandler, { once: true }); } + const customUserVars = + config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`]; + const result = await mcpManager.callTool({ serverName, toolName, @@ -175,8 +178,9 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) { toolArguments, options: { signal: derivedSignal, - user: config?.configurable?.user, }, + user: config?.configurable?.user, + customUserVars, flowManager, tokenMethods: { findToken, diff --git a/api/server/services/PluginService.js b/api/server/services/PluginService.js index 04c5abb32..af42e0471 100644 --- a/api/server/services/PluginService.js +++ b/api/server/services/PluginService.js @@ -1,6 +1,6 @@ const { logger } = require('@librechat/data-schemas'); const { encrypt, decrypt } = require('@librechat/api'); -const { PluginAuth } = require('~/db/models'); +const { findOnePluginAuth, updatePluginAuth, deletePluginAuth } = require('~/models'); /** * Asynchronously retrieves and decrypts the authentication value for a user's plugin, based on a specified authentication field. @@ -25,7 +25,7 @@ const { PluginAuth } = require('~/db/models'); */ const getUserPluginAuthValue = async (userId, authField, throwError = true) => { try { - const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean(); + const pluginAuth = await findOnePluginAuth({ userId, authField }); if (!pluginAuth) { throw new Error(`No plugin auth ${authField} found for user ${userId}`); } @@ -79,23 +79,12 @@ const getUserPluginAuthValue = async (userId, authField, throwError = true) => { const updateUserPluginAuth = async (userId, authField, pluginKey, value) => { try { const encryptedValue = await encrypt(value); - const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean(); - if (pluginAuth) { - return await PluginAuth.findOneAndUpdate( - { userId, authField }, - { $set: { value: encryptedValue } }, - { new: true, upsert: true }, - ).lean(); - } else { - const newPluginAuth = await new PluginAuth({ - userId, - authField, - value: encryptedValue, - pluginKey, - }); - await newPluginAuth.save(); - return newPluginAuth.toObject(); - } + return await updatePluginAuth({ + userId, + authField, + pluginKey, + value: encryptedValue, + }); } catch (err) { logger.error('[updateUserPluginAuth]', err); return err; @@ -105,26 +94,25 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => { /** * @async * @param {string} userId - * @param {string} authField - * @param {boolean} [all] + * @param {string | null} authField - The specific authField to delete, or null if `all` is true. + * @param {boolean} [all=false] - Whether to delete all auths for the user (or for a specific pluginKey if provided). + * @param {string} [pluginKey] - Optional. If `all` is true and `pluginKey` is provided, delete all auths for this user and pluginKey. * @returns {Promise} * @throws {Error} */ -const deleteUserPluginAuth = async (userId, authField, all = false) => { - if (all) { - try { - const response = await PluginAuth.deleteMany({ userId }); - return response; - } catch (err) { - logger.error('[deleteUserPluginAuth]', err); - return err; - } - } - +const deleteUserPluginAuth = async (userId, authField, all = false, pluginKey) => { try { - return await PluginAuth.deleteOne({ userId, authField }); + return await deletePluginAuth({ + userId, + authField, + pluginKey, + all, + }); } catch (err) { - logger.error('[deleteUserPluginAuth]', err); + logger.error( + `[deleteUserPluginAuth] Error deleting ${all ? 'all' : 'single'} auth(s) for userId: ${userId}${pluginKey ? ` and pluginKey: ${pluginKey}` : ''}`, + err, + ); return err; } }; diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index 0cb0206bc..ebe56c802 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -1,13 +1,31 @@ -import React, { memo, useRef, useMemo, useEffect, useCallback } from 'react'; +import React, { memo, useRef, useMemo, useEffect, useCallback, useState } from 'react'; import { useRecoilState } from 'recoil'; +import { Settings2 } from 'lucide-react'; +import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import { Constants, EModelEndpoint, LocalStorageKeys } from 'librechat-data-provider'; +import type { TPlugin, TPluginAuthConfig, TUpdateUserPlugins } from 'librechat-data-provider'; +import MCPConfigDialog, { type ConfigFieldDetail } from '~/components/ui/MCPConfigDialog'; import { useAvailableToolsQuery } from '~/data-provider'; import useLocalStorage from '~/hooks/useLocalStorageAlt'; import MultiSelect from '~/components/ui/MultiSelect'; import { ephemeralAgentByConvoId } from '~/store'; +import { useToastContext } from '~/Providers'; import MCPIcon from '~/components/ui/MCPIcon'; import { useLocalize } from '~/hooks'; +interface McpServerInfo { + name: string; + pluginKey: string; + authConfig?: TPluginAuthConfig[]; + authenticated?: boolean; +} + +// Helper function to extract mcp_serverName from a full pluginKey like action_mcp_serverName +const getBaseMCPPluginKey = (fullPluginKey: string): string => { + const parts = fullPluginKey.split(Constants.mcp_delimiter); + return Constants.mcp_prefix + parts[parts.length - 1]; +}; + const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { if (rawCurrentValue) { try { @@ -24,20 +42,45 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => { function MCPSelect({ conversationId }: { conversationId?: string | null }) { const localize = useLocalize(); + const { showToast } = useToastContext(); const key = conversationId ?? Constants.NEW_CONVO; const hasSetFetched = useRef(null); + const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); + const [selectedToolForConfig, setSelectedToolForConfig] = useState(null); - const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, { - select: (data) => { - const serverNames = new Set(); + const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, { + select: (data: TPlugin[]) => { + const mcpToolsMap = new Map(); data.forEach((tool) => { const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter); if (isMCP && tool.chatMenu !== false) { const parts = tool.pluginKey.split(Constants.mcp_delimiter); - serverNames.add(parts[parts.length - 1]); + 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 serverNames; + return Array.from(mcpToolsMap.values()); + }, + }); + + const updateUserPluginsMutation = useUpdateUserPluginsMutation({ + onSuccess: () => { + setIsConfigModalOpen(false); + showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); + }, + onError: (error: unknown) => { + console.error('Error updating MCP auth:', error); + showToast({ + message: localize('com_nav_mcp_vars_update_error'), + status: 'error', + }); }, }); @@ -76,12 +119,12 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) { return; } hasSetFetched.current = key; - if ((mcpServerSet?.size ?? 0) > 0) { - setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp))); + if ((mcpToolDetails?.length ?? 0) > 0) { + setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp))); return; } setMCPValues([]); - }, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]); + }, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]); const renderSelectedValues = useCallback( (values: string[], placeholder?: string) => { @@ -96,28 +139,140 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) { [localize], ); - const mcpServers = useMemo(() => { - return Array.from(mcpServerSet ?? []); - }, [mcpServerSet]); + const mcpServerNames = useMemo(() => { + return (mcpToolDetails ?? []).map((tool) => tool.name); + }, [mcpToolDetails]); - if (!mcpServerSet || mcpServerSet.size === 0) { + const handleConfigSave = useCallback( + (targetName: string, authData: Record) => { + if (selectedToolForConfig && selectedToolForConfig.name === targetName) { + const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey); + + const payload: TUpdateUserPlugins = { + pluginKey: basePluginKey, + action: 'install', + auth: authData, + }; + updateUserPluginsMutation.mutate(payload); + } + }, + [selectedToolForConfig, updateUserPluginsMutation], + ); + + const handleConfigRevoke = useCallback( + (targetName: string) => { + if (selectedToolForConfig && selectedToolForConfig.name === targetName) { + const basePluginKey = getBaseMCPPluginKey(selectedToolForConfig.pluginKey); + + const payload: TUpdateUserPlugins = { + pluginKey: basePluginKey, + action: 'uninstall', + auth: {}, + }; + updateUserPluginsMutation.mutate(payload); + } + }, + [selectedToolForConfig, updateUserPluginsMutation], + ); + + const renderItemContent = useCallback( + (serverName: string, defaultContent: React.ReactNode) => { + const tool = mcpToolDetails?.find((t) => t.name === serverName); + const hasAuthConfig = tool?.authConfig && tool.authConfig.length > 0; + + // Common wrapper for the main content (check mark + text) + // Ensures Check & Text are adjacent and the group takes available space. + const mainContentWrapper = ( +
{defaultContent}
+ ); + + if (tool && hasAuthConfig) { + return ( +
+ {mainContentWrapper} + +
+ ); + } + // For items without a settings icon, return the consistently wrapped main content. + return mainContentWrapper; + }, + [mcpToolDetails, setSelectedToolForConfig, setIsConfigModalOpen], + ); + + if (!mcpToolDetails || mcpToolDetails.length === 0) { return null; } return ( - } - 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" - /> + <> + } + 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" + /> + {selectedToolForConfig && ( + { + const schema: Record = {}; + if (selectedToolForConfig?.authConfig) { + selectedToolForConfig.authConfig.forEach((field) => { + schema[field.authField] = { + title: field.label, + description: field.description, + }; + }); + } + return schema; + })()} + initialValues={(() => { + const initial: Record = {}; + // Note: Actual initial values might need to be fetched if they are stored user-specifically + if (selectedToolForConfig?.authConfig) { + selectedToolForConfig.authConfig.forEach((field) => { + initial[field.authField] = ''; // Or fetched value + }); + } + return initial; + })()} + onSave={(authData) => { + if (selectedToolForConfig) { + handleConfigSave(selectedToolForConfig.name, authData); + } + }} + onRevoke={() => { + if (selectedToolForConfig) { + handleConfigRevoke(selectedToolForConfig.name); + } + }} + isSubmitting={updateUserPluginsMutation.isLoading} + /> + )} + ); } diff --git a/client/src/components/SidePanel/MCP/MCPPanel.tsx b/client/src/components/SidePanel/MCP/MCPPanel.tsx new file mode 100644 index 000000000..aa2bf7211 --- /dev/null +++ b/client/src/components/SidePanel/MCP/MCPPanel.tsx @@ -0,0 +1,253 @@ +import React, { useState, useCallback, useMemo, useEffect } from 'react'; +import { ChevronLeft } from 'lucide-react'; +import { Constants } from 'librechat-data-provider'; +import { useForm, Controller } from 'react-hook-form'; +import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; +import type { TUpdateUserPlugins } from 'librechat-data-provider'; +import { Button, Input, Label } from '~/components/ui'; +import { useGetStartupConfig } from '~/data-provider'; +import MCPPanelSkeleton from './MCPPanelSkeleton'; +import { useToastContext } from '~/Providers'; +import { useLocalize } from '~/hooks'; + +interface ServerConfigWithVars { + serverName: string; + config: { + customUserVars: Record; + }; +} + +export default function MCPPanel() { + const localize = useLocalize(); + const { showToast } = useToastContext(); + const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig(); + const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState( + null, + ); + + const mcpServerDefinitions = useMemo(() => { + if (!startupConfig?.mcpServers) { + return []; + } + return Object.entries(startupConfig.mcpServers) + .filter( + ([, serverConfig]) => + serverConfig.customUserVars && Object.keys(serverConfig.customUserVars).length > 0, + ) + .map(([serverName, config]) => ({ + serverName, + iconPath: null, + config: { + ...config, + customUserVars: config.customUserVars ?? {}, + }, + })); + }, [startupConfig?.mcpServers]); + + const updateUserPluginsMutation = useUpdateUserPluginsMutation({ + onSuccess: () => { + showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); + }, + onError: (error) => { + console.error('Error updating MCP custom user variables:', error); + showToast({ + message: localize('com_nav_mcp_vars_update_error'), + status: 'error', + }); + }, + }); + + const handleSaveServerVars = useCallback( + (serverName: string, updatedValues: Record) => { + const payload: TUpdateUserPlugins = { + pluginKey: `${Constants.mcp_prefix}${serverName}`, + action: 'install', // 'install' action is used to set/update credentials/variables + auth: updatedValues, + }; + updateUserPluginsMutation.mutate(payload); + }, + [updateUserPluginsMutation], + ); + + const handleRevokeServerVars = useCallback( + (serverName: string) => { + const payload: TUpdateUserPlugins = { + pluginKey: `${Constants.mcp_prefix}${serverName}`, + action: 'uninstall', // 'uninstall' action clears the variables + auth: {}, // Empty auth for uninstall + }; + updateUserPluginsMutation.mutate(payload); + }, + [updateUserPluginsMutation], + ); + + const handleServerClickToEdit = (serverName: string) => { + setSelectedServerNameForEditing(serverName); + }; + + const handleGoBackToList = () => { + setSelectedServerNameForEditing(null); + }; + + if (startupConfigLoading) { + return ; + } + + if (mcpServerDefinitions.length === 0) { + return ( +
+ {localize('com_sidepanel_mcp_no_servers_with_vars')} +
+ ); + } + + if (selectedServerNameForEditing) { + // Editing View + const serverBeingEdited = mcpServerDefinitions.find( + (s) => s.serverName === selectedServerNameForEditing, + ); + + if (!serverBeingEdited) { + // Fallback to list view if server not found + setSelectedServerNameForEditing(null); + return ( +
+ {localize('com_ui_error')}: {localize('com_ui_mcp_server_not_found')} +
+ ); + } + + return ( +
+ +

+ {localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })} +

+ +
+ ); + } else { + // Server List View + return ( +
+
+ {mcpServerDefinitions.map((server) => ( + + ))} +
+
+ ); + } +} + +// Inner component for the form - remains the same +interface MCPVariableEditorProps { + server: ServerConfigWithVars; + onSave: (serverName: string, updatedValues: Record) => void; + onRevoke: (serverName: string) => void; + isSubmitting: boolean; +} + +function MCPVariableEditor({ server, onSave, onRevoke, isSubmitting }: MCPVariableEditorProps) { + const localize = useLocalize(); + + const { + control, + handleSubmit, + reset, + formState: { errors, isDirty }, + } = useForm>({ + defaultValues: {}, // Initialize empty, will be reset by useEffect + }); + + useEffect(() => { + // Always initialize with empty strings based on the schema + const initialFormValues = Object.keys(server.config.customUserVars).reduce( + (acc, key) => { + acc[key] = ''; + return acc; + }, + {} as Record, + ); + reset(initialFormValues); + }, [reset, server.config.customUserVars]); + + const onFormSubmit = (data: Record) => { + onSave(server.serverName, data); + }; + + const handleRevokeClick = () => { + onRevoke(server.serverName); + }; + + return ( +
+ {Object.entries(server.config.customUserVars).map(([key, details]) => ( +
+ + ( + + )} + /> + {details.description && ( +

+ )} + {errors[key] &&

{errors[key]?.message}

} +
+ ))} +
+ {Object.keys(server.config.customUserVars).length > 0 && ( + + )} + +
+
+ ); +} diff --git a/client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx b/client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx new file mode 100644 index 000000000..61afbfcc2 --- /dev/null +++ b/client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx @@ -0,0 +1,21 @@ +import React from 'react'; +import { Skeleton } from '~/components/ui'; + +export default function MCPPanelSkeleton() { + return ( +
+ {[1, 2].map((serverIdx) => ( +
+ {/* Server Name */} + {[1, 2].map((varIdx) => ( +
+ {/* Variable Title */} + {/* Input Field */} + {/* Description */} +
+ ))} +
+ ))} +
+ ); +} diff --git a/client/src/components/Tools/ToolSelectDialog.tsx b/client/src/components/Tools/ToolSelectDialog.tsx index b3bc55840..cf8c95892 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 { isAgentsEndpoint } from 'librechat-data-provider'; +import { Constants, isAgentsEndpoint } from 'librechat-data-provider'; import { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react'; import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import type { @@ -125,16 +125,23 @@ function ToolSelectDialog({ const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey); setSelectedPlugin(getAvailablePluginFromKey); - const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {}; + const isMCPTool = pluginKey.includes(Constants.mcp_delimiter); - if (authConfig && authConfig.length > 0 && !authenticated) { - setShowPluginAuthForm(true); + if (isMCPTool) { + // MCP tools have their variables configured elsewhere (e.g., MCPPanel or MCPSelect), + // so we directly proceed to install without showing the auth form. + handleInstall({ pluginKey, action: 'install', auth: {} }); } else { - handleInstall({ - pluginKey, - action: 'install', - auth: {}, - }); + const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {}; + if (authConfig && authConfig.length > 0 && !authenticated) { + setShowPluginAuthForm(true); + } else { + handleInstall({ + pluginKey, + action: 'install', + auth: {}, + }); + } } }; diff --git a/client/src/components/ui/MCPConfigDialog.tsx b/client/src/components/ui/MCPConfigDialog.tsx new file mode 100644 index 000000000..d1a53bd90 --- /dev/null +++ b/client/src/components/ui/MCPConfigDialog.tsx @@ -0,0 +1,122 @@ +import React, { useEffect } from 'react'; +import { useForm, Controller } from 'react-hook-form'; +import { Input, Label, OGDialog, Button } from '~/components/ui'; +import OGDialogTemplate from '~/components/ui/OGDialogTemplate'; +import { useLocalize } from '~/hooks'; + +export interface ConfigFieldDetail { + title: string; + description: string; +} + +interface MCPConfigDialogProps { + isOpen: boolean; + onOpenChange: (isOpen: boolean) => void; + fieldsSchema: Record; + initialValues: Record; + onSave: (updatedValues: Record) => void; + isSubmitting?: boolean; + onRevoke?: () => void; + serverName: string; +} + +export default function MCPConfigDialog({ + isOpen, + onOpenChange, + fieldsSchema, + initialValues, + onSave, + isSubmitting = false, + onRevoke, + serverName, +}: MCPConfigDialogProps) { + const localize = useLocalize(); + const { + control, + handleSubmit, + reset, + formState: { errors, _ }, + } = useForm>({ + defaultValues: initialValues, + }); + + useEffect(() => { + if (isOpen) { + reset(initialValues); + } + }, [isOpen, initialValues, reset]); + + const onFormSubmit = (data: Record) => { + onSave(data); + }; + + const handleRevoke = () => { + if (onRevoke) { + onRevoke(); + } + }; + + const dialogTitle = localize('com_ui_configure_mcp_variables_for', { 0: serverName }); + const dialogDescription = localize('com_ui_mcp_dialog_desc'); + + return ( + + + {Object.entries(fieldsSchema).map(([key, details]) => ( +
+ + ( + + )} + /> + {details.description && ( +

+ )} + {errors[key] &&

{errors[key]?.message}

} +
+ ))} + + } + selection={{ + selectHandler: handleSubmit(onFormSubmit), + selectClasses: 'bg-green-500 hover:bg-green-600 text-white', + selectText: isSubmitting ? localize('com_ui_saving') : localize('com_ui_save'), + }} + buttons={ + onRevoke && ( + + ) + } + footerClassName="flex justify-end gap-2 px-6 pb-6 pt-2" + showCancelButton={true} + /> +
+ ); +} diff --git a/client/src/components/ui/MultiSelect.tsx b/client/src/components/ui/MultiSelect.tsx index ddbd5c90a..e0b74a457 100644 --- a/client/src/components/ui/MultiSelect.tsx +++ b/client/src/components/ui/MultiSelect.tsx @@ -26,6 +26,11 @@ interface MultiSelectProps { selectItemsClassName?: string; selectedValues: T[]; setSelectedValues: (values: T[]) => void; + renderItemContent?: ( + value: T, + defaultContent: React.ReactNode, + isSelected: boolean, + ) => React.ReactNode; } function defaultRender(values: T[], placeholder?: string) { @@ -54,9 +59,9 @@ export default function MultiSelect({ selectItemsClassName, selectedValues = [], setSelectedValues, + renderItemContent, }: MultiSelectProps) { const selectRef = useRef(null); - // const [selectedValues, setSelectedValues] = React.useState(defaultSelectedValues); const handleValueChange = (values: T[]) => { setSelectedValues(values); @@ -105,23 +110,33 @@ export default function MultiSelect({ popoverClassName, )} > - {items.map((value) => ( - - - {value} - - ))} + {items.map((value) => { + const defaultContent = ( + <> + + {value} + + ); + const isCurrentItemSelected = selectedValues.includes(value); + return ( + + {renderItemContent + ? renderItemContent(value, defaultContent, isCurrentItemSelected) + : defaultContent} + + ); + })} diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index 822bde3ac..abc4688f7 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -17,7 +17,10 @@ import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch'; import PromptsAccordion from '~/components/Prompts/PromptsAccordion'; import Parameters from '~/components/SidePanel/Parameters/Panel'; import FilesPanel from '~/components/SidePanel/Files/Panel'; +import MCPPanel from '~/components/SidePanel/MCP/MCPPanel'; import { Blocks, AttachmentIcon } from '~/components/svg'; +import { useGetStartupConfig } from '~/data-provider'; +import MCPIcon from '~/components/ui/MCPIcon'; import { useHasAccess } from '~/hooks'; export default function useSideNavLinks({ @@ -59,6 +62,7 @@ export default function useSideNavLinks({ permissionType: PermissionTypes.AGENTS, permission: Permissions.CREATE, }); + const { data: startupConfig } = useGetStartupConfig(); const Links = useMemo(() => { const links: NavLink[] = []; @@ -149,6 +153,21 @@ export default function useSideNavLinks({ }); } + if ( + startupConfig?.mcpServers && + Object.values(startupConfig.mcpServers).some( + (server) => server.customUserVars && Object.keys(server.customUserVars).length > 0, + ) + ) { + links.push({ + title: 'com_nav_setting_mcp', + label: '', + icon: MCPIcon, + id: 'mcp-settings', + Component: MCPPanel, + }); + } + links.push({ title: 'com_sidepanel_hide_panel', label: '', @@ -171,6 +190,7 @@ export default function useSideNavLinks({ hasAccessToBookmarks, hasAccessToCreateAgents, hidePanel, + startupConfig, ]); return Links; diff --git a/client/src/locales/en/translation.json b/client/src/locales/en/translation.json index 0d6d40f39..5bc7a38f6 100644 --- a/client/src/locales/en/translation.json +++ b/client/src/locales/en/translation.json @@ -423,6 +423,8 @@ "com_nav_log_out": "Log out", "com_nav_long_audio_warning": "Longer texts will take longer to process.", "com_nav_maximize_chat_space": "Maximize chat space", + "com_nav_mcp_vars_update_error": "Error updating MCP custom user variables: {{0}}", + "com_nav_mcp_vars_updated": "MCP custom user variables updated successfully.", "com_nav_modular_chat": "Enable switching Endpoints mid-conversation", "com_nav_my_files": "My Files", "com_nav_not_supported": "Not Supported", @@ -447,6 +449,7 @@ "com_nav_setting_chat": "Chat", "com_nav_setting_data": "Data controls", "com_nav_setting_general": "General", + "com_nav_setting_mcp": "MCP Settings", "com_nav_setting_personalization": "Personalization", "com_nav_setting_speech": "Speech", "com_nav_settings": "Settings", @@ -480,8 +483,15 @@ "com_sidepanel_conversation_tags": "Bookmarks", "com_sidepanel_hide_panel": "Hide Panel", "com_sidepanel_manage_files": "Manage Files", + "com_sidepanel_mcp_enter_value": "Enter value for {{0}}", + "com_sidepanel_mcp_no_servers_with_vars": "No MCP servers with configurable variables.", + "com_sidepanel_mcp_variables_for": "MCP Variables for {{0}}", "com_sidepanel_parameters": "Parameters", "com_sources_image_alt": "Search result image", + "com_ui_configure_mcp_variables_for": "Configure Variables for {{0}}", + "com_ui_mcp_dialog_desc": "Please enter the necessary information below.", + "com_ui_mcp_enter_var": "Enter value for {{0}}", + "com_ui_saving": "Saving...", "com_sources_more_sources": "+{{count}} sources", "com_sources_tab_all": "All", "com_sources_tab_images": "Images", @@ -570,6 +580,7 @@ "com_ui_authentication_type": "Authentication Type", "com_ui_avatar": "Avatar", "com_ui_azure": "Azure", + "com_ui_back": "Back", "com_ui_back_to_chat": "Back to Chat", "com_ui_back_to_prompts": "Back to Prompts", "com_ui_backup_codes": "Backup Codes", @@ -795,6 +806,7 @@ "com_ui_manage": "Manage", "com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.", "com_ui_mcp_servers": "MCP Servers", + "com_ui_mcp_server_not_found": "Server not found.", "com_ui_memories": "Memories", "com_ui_memories_allow_create": "Allow creating Memories", "com_ui_memories_allow_opt_out": "Allow users to opt out of Memories", @@ -1020,6 +1032,7 @@ "com_user_message": "You", "com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.", "com_ui_add_mcp": "Add MCP", + "com_ui_add_mcp": "Add MCP", "com_ui_add_mcp_server": "Add MCP Server", "com_ui_edit_mcp_server": "Edit MCP Server", "com_agents_mcps_disabled": "You need to create an agent before adding MCPs.", diff --git a/package-lock.json b/package-lock.json index 75ce0ea9f..a7384d0f9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -46322,7 +46322,7 @@ }, "packages/data-provider": { "name": "librechat-data-provider", - "version": "0.7.87", + "version": "0.7.88", "license": "ISC", "dependencies": { "axios": "^1.8.2", diff --git a/packages/api/src/agents/auth.ts b/packages/api/src/agents/auth.ts new file mode 100644 index 000000000..564ef84b5 --- /dev/null +++ b/packages/api/src/agents/auth.ts @@ -0,0 +1,93 @@ +import { logger } from '@librechat/data-schemas'; +import type { IPluginAuth, PluginAuthMethods } from '@librechat/data-schemas'; +import { decrypt } from '../crypto/encryption'; + +export interface GetPluginAuthMapParams { + userId: string; + pluginKeys: string[]; + throwError?: boolean; + findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys']; +} + +export type PluginAuthMap = Record>; + +/** + * Retrieves and decrypts authentication values for multiple plugins + * @returns A map where keys are pluginKeys and values are objects of authField:decryptedValue pairs + */ +export async function getPluginAuthMap({ + userId, + pluginKeys, + throwError = true, + findPluginAuthsByKeys, +}: GetPluginAuthMapParams): Promise { + try { + /** Early return for empty plugin keys */ + if (!pluginKeys?.length) { + return {}; + } + + /** All plugin auths for current user query */ + const pluginAuths = await findPluginAuthsByKeys({ userId, pluginKeys }); + + /** Group auth records by pluginKey for efficient lookup */ + const authsByPlugin = new Map(); + for (const auth of pluginAuths) { + if (!auth.pluginKey) { + logger.warn(`[getPluginAuthMap] Missing pluginKey for userId ${userId}`); + continue; + } + const existing = authsByPlugin.get(auth.pluginKey) || []; + existing.push(auth); + authsByPlugin.set(auth.pluginKey, existing); + } + + const authMap: PluginAuthMap = {}; + const decryptionPromises: Promise[] = []; + + /** Single loop through requested pluginKeys */ + for (const pluginKey of pluginKeys) { + authMap[pluginKey] = {}; + const auths = authsByPlugin.get(pluginKey) || []; + + for (const auth of auths) { + decryptionPromises.push( + (async () => { + try { + const decryptedValue = await decrypt(auth.value); + authMap[pluginKey][auth.authField] = decryptedValue; + } catch (error) { + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[getPluginAuthMap] Decryption failed for userId ${userId}, plugin ${pluginKey}, field ${auth.authField}: ${message}`, + ); + + if (throwError) { + throw new Error( + `Decryption failed for plugin ${pluginKey}, field ${auth.authField}: ${message}`, + ); + } + } + })(), + ); + } + } + + await Promise.all(decryptionPromises); + return authMap; + } catch (error) { + if (!throwError) { + /** Empty objects for each plugin key on error */ + return pluginKeys.reduce((acc, key) => { + acc[key] = {}; + return acc; + }, {} as PluginAuthMap); + } + + const message = error instanceof Error ? error.message : 'Unknown error'; + logger.error( + `[getPluginAuthMap] Failed to fetch auth values for userId ${userId}, plugins: ${pluginKeys.join(', ')}: ${message}`, + ); + throw error; + } +} diff --git a/packages/api/src/agents/run.ts b/packages/api/src/agents/run.ts index 22f8c4ae9..41ec02d9b 100644 --- a/packages/api/src/agents/run.ts +++ b/packages/api/src/agents/run.ts @@ -1,6 +1,12 @@ import { Run, Providers } from '@librechat/agents'; import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider'; -import type { StandardGraphConfig, EventHandler, GraphEvents, IState } from '@librechat/agents'; +import type { + StandardGraphConfig, + EventHandler, + GenericTool, + GraphEvents, + IState, +} from '@librechat/agents'; import type { Agent } from 'librechat-data-provider'; import type * as t from '~/types'; @@ -32,7 +38,7 @@ export async function createRun({ streaming = true, streamUsage = true, }: { - agent: Agent; + agent: Omit & { tools?: GenericTool[] }; signal: AbortSignal; runId?: string; streaming?: boolean; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index b7859e7ca..0341de44b 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -1,6 +1,7 @@ /* MCP */ export * from './mcp/manager'; export * from './mcp/oauth'; +export * from './mcp/auth'; /* Utilities */ export * from './mcp/utils'; export * from './utils'; diff --git a/packages/api/src/mcp/auth.ts b/packages/api/src/mcp/auth.ts new file mode 100644 index 000000000..7f6f6001f --- /dev/null +++ b/packages/api/src/mcp/auth.ts @@ -0,0 +1,58 @@ +import { logger } from '@librechat/data-schemas'; +import { Constants } from 'librechat-data-provider'; +import type { PluginAuthMethods } from '@librechat/data-schemas'; +import type { GenericTool } from '@librechat/agents'; +import { getPluginAuthMap } from '~/agents/auth'; +import { mcpToolPattern } from './utils'; + +export async function getUserMCPAuthMap({ + userId, + tools, + appTools, + findPluginAuthsByKeys, +}: { + userId: string; + tools: GenericTool[] | undefined; + appTools: Record; + findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys']; +}) { + if (!tools || tools.length === 0) { + return {}; + } + + const uniqueMcpServers = new Set(); + + for (const tool of tools) { + const toolKey = tool.name; + if (toolKey && appTools[toolKey] && mcpToolPattern.test(toolKey)) { + const parts = toolKey.split(Constants.mcp_delimiter); + const serverName = parts[parts.length - 1]; + uniqueMcpServers.add(`${Constants.mcp_prefix}${serverName}`); + } + } + + if (uniqueMcpServers.size === 0) { + return {}; + } + + const mcpPluginKeysToFetch = Array.from(uniqueMcpServers); + + let allMcpCustomUserVars: Record> = {}; + try { + allMcpCustomUserVars = await getPluginAuthMap({ + userId, + pluginKeys: mcpPluginKeysToFetch, + throwError: false, + findPluginAuthsByKeys, + }); + } catch (err) { + logger.error( + `[handleTools] Error batch fetching customUserVars for MCP tools (keys: ${mcpPluginKeysToFetch.join( + ', ', + )}), user ${userId}: ${err instanceof Error ? err.message : 'Unknown error'}`, + err, + ); + } + + return allMcpCustomUserVars; +} diff --git a/packages/api/src/mcp/manager.ts b/packages/api/src/mcp/manager.ts index 0a6078445..19d4d4e72 100644 --- a/packages/api/src/mcp/manager.ts +++ b/packages/api/src/mcp/manager.ts @@ -14,10 +14,6 @@ import { MCPTokenStorage } from './oauth/tokens'; import { formatToolContent } from './parsers'; import { MCPConnection } from './connection'; -export interface CallToolOptions extends RequestOptions { - user?: TUser; -} - export class MCPManager { private static instance: MCPManager | null = null; /** App-level connections initialized at startup */ @@ -28,7 +24,11 @@ export class MCPManager { private userLastActivity: Map = new Map(); private readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable) private mcpConfigs: t.MCPServers = {}; - private processMCPEnv?: (obj: MCPOptions, user?: TUser) => MCPOptions; // Store the processing function + private processMCPEnv?: ( + obj: MCPOptions, + user?: TUser, + customUserVars?: Record, + ) => MCPOptions; // Store the processing function /** Store MCP server instructions */ private serverInstructions: Map = new Map(); @@ -63,7 +63,6 @@ export class MCPManager { if (!tokenMethods) { logger.info('[MCP] No token methods provided, token persistence will not be available'); } - const entries = Object.entries(mcpServers); const initializedServers = new Set(); const connectionResults = await Promise.allSettled( @@ -382,6 +381,7 @@ export class MCPManager { user, serverName, flowManager, + customUserVars, tokenMethods, oauthStart, oauthEnd, @@ -390,6 +390,7 @@ export class MCPManager { user: TUser; serverName: string; flowManager: FlowStateManager; + customUserVars?: Record; tokenMethods?: TokenMethods; oauthStart?: (authURL: string) => Promise; oauthEnd?: () => Promise; @@ -444,9 +445,8 @@ export class MCPManager { } if (this.processMCPEnv) { - config = { ...(this.processMCPEnv(config, user) ?? {}) }; + config = { ...(this.processMCPEnv(config, user, customUserVars) ?? {}) }; } - /** If no in-memory tokens, tokens from persistent storage */ let tokens: MCPOAuthTokens | null = null; if (tokenMethods?.findToken) { @@ -752,7 +752,6 @@ export class MCPManager { getServerTools?: (serverName: string) => Promise; }): Promise { const mcpTools: t.LCManifestTool[] = []; - for (const [serverName, connection] of this.connections.entries()) { try { /** Attempt to ensure connection is active, with reconnection if needed */ @@ -784,13 +783,21 @@ export class MCPManager { const serverTools: t.LCManifestTool[] = []; for (const tool of tools) { const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`; + + const config = this.mcpConfigs[serverName]; const manifestTool: t.LCManifestTool = { name: tool.name, pluginKey, description: tool.description ?? '', icon: connection.iconPath, + authConfig: config?.customUserVars + ? Object.entries(config.customUserVars).map(([key, value]) => ({ + authField: key, + label: value.title || key, + description: value.description || '', + })) + : undefined, }; - const config = this.mcpConfigs[serverName]; if (config?.chatMenu === false) { manifestTool.chatMenu = false; } @@ -814,6 +821,7 @@ export class MCPManager { * for user-specific connections upon successful call initiation. */ async callTool({ + user, serverName, toolName, provider, @@ -823,20 +831,22 @@ export class MCPManager { flowManager, oauthStart, oauthEnd, + customUserVars, }: { + user?: TUser; serverName: string; toolName: string; provider: t.Provider; toolArguments?: Record; - options?: CallToolOptions; + options?: RequestOptions; tokenMethods?: TokenMethods; + customUserVars?: Record; flowManager: FlowStateManager; oauthStart?: (authURL: string) => Promise; oauthEnd?: () => Promise; }): Promise { /** User-specific connection */ let connection: MCPConnection | undefined; - const { user, ...callOptions } = options ?? {}; const userId = user?.id; const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`; @@ -852,6 +862,7 @@ export class MCPManager { oauthStart, oauthEnd, signal: options?.signal, + customUserVars, }); } else { /** App-level connection */ @@ -883,7 +894,7 @@ export class MCPManager { CallToolResultSchema, { timeout: connection.timeout, - ...callOptions, + ...options, }, ); if (userId) { diff --git a/packages/api/src/mcp/types/index.ts b/packages/api/src/mcp/types/index.ts index bfd73633e..d95251eec 100644 --- a/packages/api/src/mcp/types/index.ts +++ b/packages/api/src/mcp/types/index.ts @@ -14,7 +14,15 @@ export type StdioOptions = z.infer; export type WebSocketOptions = z.infer; export type SSEOptions = z.infer; export type StreamableHTTPOptions = z.infer; -export type MCPOptions = z.infer; +export type MCPOptions = z.infer & { + customUserVars?: Record< + string, + { + title: string; + description: string; + } + >; +}; export type MCPServers = z.infer; export interface MCPResource { uri: string; diff --git a/packages/api/src/mcp/utils.ts b/packages/api/src/mcp/utils.ts index f315976fc..631ce5c21 100644 --- a/packages/api/src/mcp/utils.ts +++ b/packages/api/src/mcp/utils.ts @@ -1,3 +1,6 @@ +import { Constants } from 'librechat-data-provider'; + +export const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`); /** * Normalizes a server name to match the pattern ^[a-zA-Z0-9_.-]+$ * This is required for Azure OpenAI models with Tool Calling diff --git a/packages/data-provider/package.json b/packages/data-provider/package.json index 146121cb4..d46bfcf71 100644 --- a/packages/data-provider/package.json +++ b/packages/data-provider/package.json @@ -1,6 +1,6 @@ { "name": "librechat-data-provider", - "version": "0.7.87", + "version": "0.7.88", "description": "data services for librechat apps", "main": "dist/index.js", "module": "dist/index.es.js", diff --git a/packages/data-provider/specs/actions.spec.ts b/packages/data-provider/specs/actions.spec.ts index 6d84c7937..818c72c83 100644 --- a/packages/data-provider/specs/actions.spec.ts +++ b/packages/data-provider/specs/actions.spec.ts @@ -1,6 +1,8 @@ -import axios from 'axios'; import { z } from 'zod'; -import { OpenAPIV3 } from 'openapi-types'; +import axios from 'axios'; +import type { OpenAPIV3 } from 'openapi-types'; +import type { ParametersSchema } from '../src/actions'; +import type { FlowchartSchema } from './openapiSpecs'; import { createURL, resolveRef, @@ -15,9 +17,7 @@ import { scholarAIOpenapiSpec, swapidev, } from './openapiSpecs'; -import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/assistants'; -import type { FlowchartSchema } from './openapiSpecs'; -import type { ParametersSchema } from '../src/actions'; +import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/agents'; jest.mock('axios'); const mockedAxios = axios as jest.Mocked; diff --git a/packages/data-provider/specs/mcp.spec.ts b/packages/data-provider/specs/mcp.spec.ts index f2b62c0f8..37493f1bb 100644 --- a/packages/data-provider/specs/mcp.spec.ts +++ b/packages/data-provider/specs/mcp.spec.ts @@ -525,5 +525,188 @@ describe('Environment Variable Extraction (MCP)', () => { const result3 = processMCPEnv(obj3, userWithBoth); expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789'); }); + + it('should process customUserVars in env field', () => { + const user = createTestUser(); + const customUserVars = { + CUSTOM_VAR_1: 'custom-value-1', + CUSTOM_VAR_2: 'custom-value-2', + }; + const obj: MCPOptions = { + command: 'node', + args: ['server.js'], + env: { + VAR_A: '{{CUSTOM_VAR_1}}', + VAR_B: 'Value with {{CUSTOM_VAR_2}}', + VAR_C: '${TEST_API_KEY}', + VAR_D: '{{LIBRECHAT_USER_EMAIL}}', + }, + }; + + const result = processMCPEnv(obj, user, customUserVars); + + expect('env' in result && result.env).toEqual({ + VAR_A: 'custom-value-1', + VAR_B: 'Value with custom-value-2', + VAR_C: 'test-api-key-value', + VAR_D: 'test@example.com', + }); + }); + + it('should process customUserVars in headers field', () => { + const user = createTestUser(); + const customUserVars = { + USER_TOKEN: 'user-specific-token', + REGION: 'us-west-1', + }; + const obj: MCPOptions = { + type: 'sse', + url: 'https://example.com/api', + headers: { + Authorization: 'Bearer {{USER_TOKEN}}', + 'X-Region': '{{REGION}}', + 'X-System-Key': '${TEST_API_KEY}', + 'X-User-Id': '{{LIBRECHAT_USER_ID}}', + }, + }; + + const result = processMCPEnv(obj, user, customUserVars); + + expect('headers' in result && result.headers).toEqual({ + Authorization: 'Bearer user-specific-token', + 'X-Region': 'us-west-1', + 'X-System-Key': 'test-api-key-value', + 'X-User-Id': 'test-user-id', + }); + }); + + it('should process customUserVars in URL field', () => { + const user = createTestUser(); + const customUserVars = { + API_VERSION: 'v2', + TENANT_ID: 'tenant123', + }; + const obj: MCPOptions = { + type: 'websocket', + url: 'wss://example.com/{{TENANT_ID}}/api/{{API_VERSION}}?user={{LIBRECHAT_USER_ID}}&key=${TEST_API_KEY}', + }; + + const result = processMCPEnv(obj, user, customUserVars); + + expect('url' in result && result.url).toBe( + 'wss://example.com/tenant123/api/v2?user=test-user-id&key=test-api-key-value', + ); + }); + + it('should prioritize customUserVars over user fields and system env vars if placeholders are the same (though not recommended)', () => { + // This tests the order of operations: customUserVars -> userFields -> systemEnv + // BUt it's generally not recommended to have overlapping placeholder names. + process.env.LIBRECHAT_USER_EMAIL = 'system-email-should-be-overridden'; + const user = createTestUser({ email: 'user-email-should-be-overridden' }); + const customUserVars = { + LIBRECHAT_USER_EMAIL: 'custom-email-wins', + }; + const obj: MCPOptions = { + type: 'sse', + url: 'https://example.com/api', + headers: { + 'Test-Email': '{{LIBRECHAT_USER_EMAIL}}', // Placeholder that could match custom, user, or system + }, + }; + + const result = processMCPEnv(obj, user, customUserVars); + expect('headers' in result && result.headers?.['Test-Email']).toBe('custom-email-wins'); + + // Clean up env var + delete process.env.LIBRECHAT_USER_EMAIL; + }); + + it('should handle customUserVars with no matching placeholders', () => { + const user = createTestUser(); + const customUserVars = { + UNUSED_VAR: 'unused-value', + }; + const obj: MCPOptions = { + command: 'node', + args: ['server.js'], + env: { + API_KEY: '${TEST_API_KEY}', + }, + }; + + const result = processMCPEnv(obj, user, customUserVars); + expect('env' in result && result.env).toEqual({ + API_KEY: 'test-api-key-value', + }); + }); + + it('should handle placeholders with no matching customUserVars (falling back to user/system vars)', () => { + const user = createTestUser({ email: 'user-provided-email@example.com' }); + // No customUserVars provided or customUserVars is empty + const customUserVars = {}; + const obj: MCPOptions = { + type: 'sse', + url: 'https://example.com/api', + headers: { + 'User-Email-Header': '{{LIBRECHAT_USER_EMAIL}}', // Should use user.email + 'System-Key-Header': '${TEST_API_KEY}', // Should use process.env.TEST_API_KEY + 'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}', // Should remain as placeholder + }, + }; + + const result = processMCPEnv(obj, user, customUserVars); + expect('headers' in result && result.headers).toEqual({ + 'User-Email-Header': 'user-provided-email@example.com', + 'System-Key-Header': 'test-api-key-value', + 'Non-Existent-Custom': '{{NON_EXISTENT_CUSTOM_VAR}}', + }); + }); + + it('should correctly process a mix of all variable types', () => { + const user = createTestUser({ id: 'userXYZ', username: 'john.doe' }); + const customUserVars = { + CUSTOM_ENDPOINT_ID: 'ep123', + ANOTHER_CUSTOM: 'another_val', + }; + + const obj = { + type: 'streamable-http' as const, + url: 'https://{{CUSTOM_ENDPOINT_ID}}.example.com/users/{{LIBRECHAT_USER_USERNAME}}', + headers: { + 'X-Auth-Token': '{{CUSTOM_TOKEN_FROM_USER_SETTINGS}}', // Assuming this would be a custom var + 'X-User-ID': '{{LIBRECHAT_USER_ID}}', + 'X-System-Test-Key': '${TEST_API_KEY}', // Using existing env var from beforeEach + }, + env: { + PROCESS_MODE: '{{PROCESS_MODE_CUSTOM}}', // Another custom var + USER_HOME_DIR: '/home/{{LIBRECHAT_USER_USERNAME}}', + SYSTEM_PATH: '${PATH}', // Example of a system env var + }, + }; + + // Simulate customUserVars that would be passed, including those for headers and env + const allCustomVarsForCall = { + ...customUserVars, + CUSTOM_TOKEN_FROM_USER_SETTINGS: 'secretToken123!', + PROCESS_MODE_CUSTOM: 'production', + }; + + // Cast obj to MCPOptions when calling processMCPEnv. + // This acknowledges the object might not strictly conform to one schema in the union, + // but we are testing the function's ability to handle these properties if present. + const result = processMCPEnv(obj as MCPOptions, user, allCustomVarsForCall); + + expect('url' in result && result.url).toBe('https://ep123.example.com/users/john.doe'); + expect('headers' in result && result.headers).toEqual({ + 'X-Auth-Token': 'secretToken123!', + 'X-User-ID': 'userXYZ', + 'X-System-Test-Key': 'test-api-key-value', // Expecting value of TEST_API_KEY + }); + expect('env' in result && result.env).toEqual({ + PROCESS_MODE: 'production', + USER_HOME_DIR: '/home/john.doe', + SYSTEM_PATH: process.env.PATH, // Actual value of PATH from the test environment + }); + }); }); }); diff --git a/packages/data-provider/src/config.ts b/packages/data-provider/src/config.ts index 145487d7e..4d1c95b69 100644 --- a/packages/data-provider/src/config.ts +++ b/packages/data-provider/src/config.ts @@ -588,6 +588,18 @@ export type TStartupConfig = { scraperType?: ScraperTypes; rerankerType?: RerankerTypes; }; + mcpServers?: Record< + string, + { + customUserVars: Record< + string, + { + title: string; + description: string; + } + >; + } + >; }; export enum OCRStrategy { @@ -885,7 +897,6 @@ export const defaultModels = { [EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'], [EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels) [EModelEndpoint.google]: [ - // Shared Google Models between Vertex AI & Gen AI // Gemini 2.0 Models 'gemini-2.0-flash-001', 'gemini-2.0-flash-exp', @@ -1395,6 +1406,8 @@ export enum Constants { GLOBAL_PROJECT_NAME = 'instance', /** Delimiter for MCP tools */ mcp_delimiter = '_mcp_', + /** Prefix for MCP plugins */ + mcp_prefix = 'mcp_', /** Placeholder Agent ID for Ephemeral Agents */ EPHEMERAL_AGENT_ID = 'ephemeral', } diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index b95636483..08a666dd7 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -151,7 +151,11 @@ export const updateUserPlugins = (payload: t.TUpdateUserPlugins) => { /* Config */ -export const getStartupConfig = (): Promise => { +export const getStartupConfig = (): Promise< + config.TStartupConfig & { + mcpCustomUserVars?: Record; + } +> => { return request.get(endpoints.config()); }; diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index 990b46e51..05b37115f 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -39,6 +39,15 @@ const BaseOptionsSchema = z.object({ token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(), }) .optional(), + customUserVars: z + .record( + z.string(), + z.object({ + title: z.string(), + description: z.string(), + }), + ) + .optional(), }); export const StdioOptionsSchema = BaseOptionsSchema.extend({ @@ -191,13 +200,55 @@ function processUserPlaceholders(value: string, user?: TUser): string { return value; } +function processSingleValue({ + originalValue, + customUserVars, + user, +}: { + originalValue: string; + customUserVars?: Record; + user?: TUser; +}): string { + let value = originalValue; + + // 1. Replace custom user variables + if (customUserVars) { + for (const [varName, varVal] of Object.entries(customUserVars)) { + /** Escaped varName for use in regex to avoid issues with special characters */ + const escapedVarName = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const placeholderRegex = new RegExp(`\\{\\{${escapedVarName}\\}\\}`, 'g'); + value = value.replace(placeholderRegex, varVal); + } + } + + // 2.A. Special handling for LIBRECHAT_USER_ID placeholder + // This ensures {{LIBRECHAT_USER_ID}} is replaced only if user.id is available. + // If user.id is null/undefined, the placeholder remains + if (user && user.id != null && value.includes('{{LIBRECHAT_USER_ID}}')) { + value = value.replace(/\{\{LIBRECHAT_USER_ID\}\}/g, String(user.id)); + } + + // 2.B. Replace other standard user field placeholders (e.g., {{LIBRECHAT_USER_EMAIL}}) + value = processUserPlaceholders(value, user); + + // 3. Replace system environment variables + value = extractEnvVariable(value); + + return value; +} + /** * Recursively processes an object to replace environment variables in string values * @param obj - The object to process * @param user - The user object containing all user fields + * @param customUserVars - vars that user set in settings * @returns - The processed object with environment variables replaced */ -export function processMCPEnv(obj: Readonly, user?: TUser): MCPOptions { +export function processMCPEnv( + obj: Readonly, + user?: TUser, + customUserVars?: Record, +): MCPOptions { if (obj === null || obj === undefined) { return obj; } @@ -206,32 +257,25 @@ export function processMCPEnv(obj: Readonly, user?: TUser): MCPOptio if ('env' in newObj && newObj.env) { const processedEnv: Record = {}; - for (const [key, value] of Object.entries(newObj.env)) { - let processedValue = extractEnvVariable(value); - processedValue = processUserPlaceholders(processedValue, user); - processedEnv[key] = processedValue; + for (const [key, originalValue] of Object.entries(newObj.env)) { + processedEnv[key] = processSingleValue({ originalValue, customUserVars, user }); } newObj.env = processedEnv; - } else if ('headers' in newObj && newObj.headers) { - const processedHeaders: Record = {}; - for (const [key, value] of Object.entries(newObj.headers)) { - const userId = user?.id; - if (value === '{{LIBRECHAT_USER_ID}}' && userId != null) { - processedHeaders[key] = String(userId); - continue; - } + } - let processedValue = extractEnvVariable(value); - processedValue = processUserPlaceholders(processedValue, user); - processedHeaders[key] = processedValue; + // Process headers if they exist (for WebSocket, SSE, StreamableHTTP types) + // Note: `env` and `headers` are on different branches of the MCPOptions union type. + if ('headers' in newObj && newObj.headers) { + const processedHeaders: Record = {}; + for (const [key, originalValue] of Object.entries(newObj.headers)) { + processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user }); } newObj.headers = processedHeaders; } + // Process URL if it exists (for WebSocket, SSE, StreamableHTTP types) if ('url' in newObj && newObj.url) { - let processedUrl = extractEnvVariable(newObj.url); - processedUrl = processUserPlaceholders(processedUrl, user); - newObj.url = processedUrl; + newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user }); } return newObj; diff --git a/packages/data-schemas/src/methods/index.ts b/packages/data-schemas/src/methods/index.ts index be4308ecd..57f0fc2f2 100644 --- a/packages/data-schemas/src/methods/index.ts +++ b/packages/data-schemas/src/methods/index.ts @@ -5,6 +5,7 @@ import { createRoleMethods, type RoleMethods } from './role'; /* Memories */ import { createMemoryMethods, type MemoryMethods } from './memory'; import { createShareMethods, type ShareMethods } from './share'; +import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth'; /** * Creates all database methods for all collections @@ -17,13 +18,15 @@ export function createMethods(mongoose: typeof import('mongoose')) { ...createRoleMethods(mongoose), ...createMemoryMethods(mongoose), ...createShareMethods(mongoose), + ...createPluginAuthMethods(mongoose), }; } -export type { MemoryMethods, ShareMethods, TokenMethods }; +export type { MemoryMethods, ShareMethods, TokenMethods, PluginAuthMethods }; export type AllMethods = UserMethods & SessionMethods & TokenMethods & RoleMethods & MemoryMethods & - ShareMethods; + ShareMethods & + PluginAuthMethods; diff --git a/packages/data-schemas/src/methods/pluginAuth.ts b/packages/data-schemas/src/methods/pluginAuth.ts new file mode 100644 index 000000000..f0256f859 --- /dev/null +++ b/packages/data-schemas/src/methods/pluginAuth.ts @@ -0,0 +1,140 @@ +import type { DeleteResult, Model } from 'mongoose'; +import type { IPluginAuth } from '~/schema/pluginAuth'; +import type { + FindPluginAuthsByKeysParams, + UpdatePluginAuthParams, + DeletePluginAuthParams, + FindPluginAuthParams, +} from '~/types'; + +// Factory function that takes mongoose instance and returns the methods +export function createPluginAuthMethods(mongoose: typeof import('mongoose')) { + const PluginAuth: Model = mongoose.models.PluginAuth; + + /** + * Finds a single plugin auth entry by userId and authField + */ + async function findOnePluginAuth({ + userId, + authField, + }: FindPluginAuthParams): Promise { + try { + return await PluginAuth.findOne({ userId, authField }).lean(); + } catch (error) { + throw new Error( + `Failed to find plugin auth: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + /** + * Finds multiple plugin auth entries by userId and pluginKeys + */ + async function findPluginAuthsByKeys({ + userId, + pluginKeys, + }: FindPluginAuthsByKeysParams): Promise { + try { + if (!pluginKeys || pluginKeys.length === 0) { + return []; + } + + return await PluginAuth.find({ + userId, + pluginKey: { $in: pluginKeys }, + }).lean(); + } catch (error) { + throw new Error( + `Failed to find plugin auths: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + /** + * Updates or creates a plugin auth entry + */ + async function updatePluginAuth({ + userId, + authField, + pluginKey, + value, + }: UpdatePluginAuthParams): Promise { + try { + const existingAuth = await PluginAuth.findOne({ userId, pluginKey, authField }).lean(); + + if (existingAuth) { + return await PluginAuth.findOneAndUpdate( + { userId, pluginKey, authField }, + { $set: { value } }, + { new: true, upsert: true }, + ).lean(); + } else { + const newPluginAuth = await new PluginAuth({ + userId, + authField, + value, + pluginKey, + }); + await newPluginAuth.save(); + return newPluginAuth.toObject(); + } + } catch (error) { + throw new Error( + `Failed to update plugin auth: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + /** + * Deletes plugin auth entries based on provided parameters + */ + async function deletePluginAuth({ + userId, + authField, + pluginKey, + all = false, + }: DeletePluginAuthParams): Promise { + try { + if (all) { + const filter: DeletePluginAuthParams = { userId }; + if (pluginKey) { + filter.pluginKey = pluginKey; + } + return await PluginAuth.deleteMany(filter); + } + + if (!authField) { + throw new Error('authField is required when all is false'); + } + + return await PluginAuth.deleteOne({ userId, authField }); + } catch (error) { + throw new Error( + `Failed to delete plugin auth: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + /** + * Deletes all plugin auth entries for a user + */ + async function deleteAllUserPluginAuths(userId: string): Promise { + try { + return await PluginAuth.deleteMany({ userId }); + } catch (error) { + throw new Error( + `Failed to delete all user plugin auths: ${error instanceof Error ? error.message : 'Unknown error'}`, + ); + } + } + + return { + findOnePluginAuth, + findPluginAuthsByKeys, + updatePluginAuth, + deletePluginAuth, + deleteAllUserPluginAuths, + }; +} + +export type PluginAuthMethods = ReturnType; diff --git a/packages/data-schemas/src/schema/pluginAuth.ts b/packages/data-schemas/src/schema/pluginAuth.ts index 5c2902445..534c49d12 100644 --- a/packages/data-schemas/src/schema/pluginAuth.ts +++ b/packages/data-schemas/src/schema/pluginAuth.ts @@ -1,13 +1,5 @@ -import { Schema, Document } from 'mongoose'; - -export interface IPluginAuth extends Document { - authField: string; - value: string; - userId: string; - pluginKey?: string; - createdAt?: Date; - updatedAt?: Date; -} +import { Schema } from 'mongoose'; +import type { IPluginAuth } from '~/types'; const pluginAuthSchema: Schema = new Schema( { diff --git a/packages/data-schemas/src/types/index.ts b/packages/data-schemas/src/types/index.ts index 3dfe1334e..f8a508a31 100644 --- a/packages/data-schemas/src/types/index.ts +++ b/packages/data-schemas/src/types/index.ts @@ -14,5 +14,6 @@ export * from './action'; export * from './assistant'; export * from './file'; export * from './share'; +export * from './pluginAuth'; /* Memories */ export * from './memory'; diff --git a/packages/data-schemas/src/types/pluginAuth.ts b/packages/data-schemas/src/types/pluginAuth.ts new file mode 100644 index 000000000..421769eaa --- /dev/null +++ b/packages/data-schemas/src/types/pluginAuth.ts @@ -0,0 +1,40 @@ +import type { Document } from 'mongoose'; + +export interface IPluginAuth extends Document { + authField: string; + value: string; + userId: string; + pluginKey?: string; + createdAt?: Date; + updatedAt?: Date; +} + +export interface PluginAuthQuery { + userId: string; + authField?: string; + pluginKey?: string; +} + +export interface FindPluginAuthParams { + userId: string; + authField: string; +} + +export interface FindPluginAuthsByKeysParams { + userId: string; + pluginKeys: string[]; +} + +export interface UpdatePluginAuthParams { + userId: string; + authField: string; + pluginKey: string; + value: string; +} + +export interface DeletePluginAuthParams { + userId: string; + authField?: string; + pluginKey?: string; + all?: boolean; +}