mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-22 06:00:56 +02:00
🗝️ feat: User Provided Credentials for MCP Servers (#7980)
* 🗝️ feat: Per-User Credentials for MCP Servers
chore: add aider to gitignore
feat: fill custom variables to MCP server
feat: replace placeholders with custom user MCP variables
feat: handle MCP install/uninstall (uses pluginauths)
feat: add MCP custom variables dialog to MCPSelect
feat: add MCP custom variables dialog to the side panel
feat: do not require to fill MCP credentials for in tools dialog
feat: add translations keys (en+cs) for custom MCP variables
fix: handle LIBRECHAT_USER_ID correctly during MCP var replacement
style: remove unused MCP translation keys
style: fix eslint for MCP custom vars
chore: move aider gitignore to AI section
* feat: Add Plugin Authentication Methods to data-schemas
* refactor: Replace PluginAuth model methods with new utility functions for improved code organization and maintainability
* refactor: Move IPluginAuth interface to types directory for better organization and update pluginAuth schema to use the new import
* refactor: Remove unused getUsersPluginsAuthValuesMap function and streamline PluginService.js; add new getPluginAuthMap function for improved plugin authentication handling
* chore: fix typing for optional tools property with GenericTool[] type
* chore: update librechat-data-provider version to 0.7.88
* refactor: optimize getUserMCPAuthMap function by reducing variable usage and improving server key collection logic
* refactor: streamline MCP tool creation by removing customUserVars parameter and enhancing user-specific authentication handling to avoid closure encapsulation
* refactor: extract processSingleValue function to streamline MCP environment variable processing and enhance readability
* refactor: enhance MCP tool processing logic by simplifying conditions and improving authentication handling for custom user variables
* ci: fix action tests
* chore: fix imports, remove comments
* chore: remove non-english translations
* fix: remove newline at end of translation.json file
---------
Co-authored-by: Aleš Kůtek <kutekales@gmail.com>
This commit is contained in:
parent
8b15bb2ed6
commit
3e4b01de82
36 changed files with 1536 additions and 166 deletions
1
.gitignore
vendored
1
.gitignore
vendored
|
@ -55,6 +55,7 @@ bower_components/
|
||||||
# AI
|
# AI
|
||||||
.clineignore
|
.clineignore
|
||||||
.cursor
|
.cursor
|
||||||
|
.aider*
|
||||||
|
|
||||||
# Floobits
|
# Floobits
|
||||||
.floo
|
.floo
|
||||||
|
|
|
@ -1,15 +1,14 @@
|
||||||
|
const { mcpToolPattern } = require('@librechat/api');
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
const { SerpAPI } = require('@langchain/community/tools/serpapi');
|
||||||
const { Calculator } = require('@langchain/community/tools/calculator');
|
const { Calculator } = require('@langchain/community/tools/calculator');
|
||||||
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
Constants,
|
|
||||||
EToolResources,
|
EToolResources,
|
||||||
loadWebSearchAuth,
|
loadWebSearchAuth,
|
||||||
replaceSpecialVars,
|
replaceSpecialVars,
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
|
||||||
const {
|
const {
|
||||||
availableTools,
|
availableTools,
|
||||||
manifestToolMap,
|
manifestToolMap,
|
||||||
|
@ -29,12 +28,11 @@ const {
|
||||||
} = require('../');
|
} = require('../');
|
||||||
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
|
||||||
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
|
||||||
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
||||||
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
const { loadAuthValues } = require('~/server/services/Tools/credentials');
|
||||||
const { getCachedTools } = require('~/server/services/Config');
|
const { getCachedTools } = require('~/server/services/Config');
|
||||||
const { createMCPTool } = require('~/server/services/MCP');
|
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.
|
* 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.
|
* 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());
|
return Array.from(validToolsSet.values());
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[validateTools] There was a problem validating tools', err);
|
logger.error('[validateTools] There was a problem validating tools', err);
|
||||||
throw new Error('There was a problem validating tools');
|
throw new Error(err);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -5,6 +5,7 @@ const { getToolkitKey } = require('~/server/services/ToolService');
|
||||||
const { getMCPManager, getFlowStateManager } = require('~/config');
|
const { getMCPManager, getFlowStateManager } = require('~/config');
|
||||||
const { availableTools } = require('~/app/clients/tools');
|
const { availableTools } = require('~/app/clients/tools');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
|
const { Constants } = require('librechat-data-provider');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Filters out duplicate plugins from the list of plugins.
|
* 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 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);
|
const toolsOutput = [];
|
||||||
res.status(200).json(tools);
|
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) {
|
} catch (error) {
|
||||||
|
logger.error('[getAvailableTools]', error);
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
const {
|
const {
|
||||||
Tools,
|
Tools,
|
||||||
|
Constants,
|
||||||
FileSources,
|
FileSources,
|
||||||
webSearchKeys,
|
webSearchKeys,
|
||||||
extractWebSearchEnvVars,
|
extractWebSearchEnvVars,
|
||||||
|
@ -23,6 +24,7 @@ const { processDeleteRequest } = require('~/server/services/Files/process');
|
||||||
const { Transaction, Balance, User } = require('~/db/models');
|
const { Transaction, Balance, User } = require('~/db/models');
|
||||||
const { deleteToolCalls } = require('~/models/ToolCall');
|
const { deleteToolCalls } = require('~/models/ToolCall');
|
||||||
const { deleteAllSharedLinks } = require('~/models');
|
const { deleteAllSharedLinks } = require('~/models');
|
||||||
|
const { getMCPManager } = require('~/config');
|
||||||
|
|
||||||
const getUserController = async (req, res) => {
|
const getUserController = async (req, res) => {
|
||||||
/** @type {MongoUser} */
|
/** @type {MongoUser} */
|
||||||
|
@ -102,10 +104,22 @@ const updateUserPluginsController = async (req, res) => {
|
||||||
}
|
}
|
||||||
|
|
||||||
let keys = Object.keys(auth);
|
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();
|
return res.status(200).send();
|
||||||
}
|
}
|
||||||
const values = Object.values(auth);
|
|
||||||
|
|
||||||
/** @type {number} */
|
/** @type {number} */
|
||||||
let status = 200;
|
let status = 200;
|
||||||
|
@ -132,16 +146,53 @@ const updateUserPluginsController = async (req, res) => {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (action === 'uninstall') {
|
} else if (action === 'uninstall') {
|
||||||
for (let i = 0; i < keys.length; i++) {
|
// const isMCPTool was defined earlier
|
||||||
authService = await deleteUserPluginAuth(user.id, keys[i]);
|
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) {
|
if (authService instanceof Error) {
|
||||||
logger.error('[authService]', authService);
|
logger.error(
|
||||||
|
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
|
||||||
|
authService,
|
||||||
|
);
|
||||||
({ status, message } = 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 (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();
|
return res.status(status).send();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -31,11 +31,15 @@ const {
|
||||||
} = require('librechat-data-provider');
|
} = require('librechat-data-provider');
|
||||||
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
const { DynamicStructuredTool } = require('@langchain/core/tools');
|
||||||
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
|
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 { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
|
||||||
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
|
||||||
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
|
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 { encodeAndFormat } = require('~/server/services/Files/images/encode');
|
||||||
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
|
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
|
||||||
const { checkAccess } = require('~/server/middleware/roles/access');
|
const { checkAccess } = require('~/server/middleware/roles/access');
|
||||||
|
@ -679,6 +683,8 @@ class AgentClient extends BaseClient {
|
||||||
version: 'v2',
|
version: 'v2',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const getUserMCPAuthMap = await createGetMCPAuthMap();
|
||||||
|
|
||||||
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
|
||||||
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
|
||||||
payload,
|
payload,
|
||||||
|
@ -798,6 +804,20 @@ class AgentClient extends BaseClient {
|
||||||
run.Graph.contentData = contentData;
|
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, {
|
await run.processStream({ messages }, config, {
|
||||||
keepContent: i !== 0,
|
keepContent: i !== 0,
|
||||||
tokenCounter: createTokenCounter(this.getEncoding()),
|
tokenCounter: createTokenCounter(this.getEncoding()),
|
||||||
|
|
|
@ -1,10 +1,11 @@
|
||||||
const express = require('express');
|
const express = require('express');
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
|
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
|
||||||
|
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
|
||||||
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
const { getLdapConfig } = require('~/server/services/Config/ldap');
|
||||||
const { getProjectByName } = require('~/models/Project');
|
const { getProjectByName } = require('~/models/Project');
|
||||||
const { isEnabled } = require('~/server/utils');
|
const { isEnabled } = require('~/server/utils');
|
||||||
const { getLogStores } = require('~/cache');
|
const { getLogStores } = require('~/cache');
|
||||||
const { logger } = require('~/config');
|
|
||||||
|
|
||||||
const router = express.Router();
|
const router = express.Router();
|
||||||
const emailLoginEnabled =
|
const emailLoginEnabled =
|
||||||
|
@ -21,12 +22,15 @@ const publicSharedLinksEnabled =
|
||||||
|
|
||||||
router.get('/', async function (req, res) {
|
router.get('/', async function (req, res) {
|
||||||
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
const cache = getLogStores(CacheKeys.CONFIG_STORE);
|
||||||
|
|
||||||
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
|
||||||
if (cachedStartupConfig) {
|
if (cachedStartupConfig) {
|
||||||
res.send(cachedStartupConfig);
|
res.send(cachedStartupConfig);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const config = await getCustomConfig();
|
||||||
|
|
||||||
const isBirthday = () => {
|
const isBirthday = () => {
|
||||||
const today = new Date();
|
const today = new Date();
|
||||||
return today.getMonth() === 1 && today.getDate() === 11;
|
return today.getMonth() === 1 && today.getDate() === 11;
|
||||||
|
@ -96,6 +100,17 @@ router.get('/', async function (req, res) {
|
||||||
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
|
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
|
||||||
staticBundlerURL: process.env.SANDPACK_STATIC_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']} */
|
/** @type {TCustomConfig['webSearch']} */
|
||||||
const webSearchConfig = req.app.locals.webSearch;
|
const webSearchConfig = req.app.locals.webSearch;
|
||||||
if (
|
if (
|
||||||
|
|
|
@ -1,6 +1,10 @@
|
||||||
|
const { logger } = require('@librechat/data-schemas');
|
||||||
|
const { getUserMCPAuthMap } = require('@librechat/api');
|
||||||
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
|
||||||
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
|
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
|
||||||
const loadCustomConfig = require('./loadCustomConfig');
|
const loadCustomConfig = require('./loadCustomConfig');
|
||||||
|
const { getCachedTools } = require('./getCachedTools');
|
||||||
|
const { findPluginAuthsByKeys } = require('~/models');
|
||||||
const getLogStores = require('~/cache/getLogStores');
|
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<Record<string, Record<string, string>> | 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,
|
||||||
|
};
|
||||||
|
|
|
@ -168,6 +168,9 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||||
derivedSignal.addEventListener('abort', abortHandler, { once: true });
|
derivedSignal.addEventListener('abort', abortHandler, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const customUserVars =
|
||||||
|
config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
|
||||||
|
|
||||||
const result = await mcpManager.callTool({
|
const result = await mcpManager.callTool({
|
||||||
serverName,
|
serverName,
|
||||||
toolName,
|
toolName,
|
||||||
|
@ -175,8 +178,9 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
|
||||||
toolArguments,
|
toolArguments,
|
||||||
options: {
|
options: {
|
||||||
signal: derivedSignal,
|
signal: derivedSignal,
|
||||||
user: config?.configurable?.user,
|
|
||||||
},
|
},
|
||||||
|
user: config?.configurable?.user,
|
||||||
|
customUserVars,
|
||||||
flowManager,
|
flowManager,
|
||||||
tokenMethods: {
|
tokenMethods: {
|
||||||
findToken,
|
findToken,
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
const { logger } = require('@librechat/data-schemas');
|
const { logger } = require('@librechat/data-schemas');
|
||||||
const { encrypt, decrypt } = require('@librechat/api');
|
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.
|
* 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) => {
|
const getUserPluginAuthValue = async (userId, authField, throwError = true) => {
|
||||||
try {
|
try {
|
||||||
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
|
const pluginAuth = await findOnePluginAuth({ userId, authField });
|
||||||
if (!pluginAuth) {
|
if (!pluginAuth) {
|
||||||
throw new Error(`No plugin auth ${authField} found for user ${userId}`);
|
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) => {
|
const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
|
||||||
try {
|
try {
|
||||||
const encryptedValue = await encrypt(value);
|
const encryptedValue = await encrypt(value);
|
||||||
const pluginAuth = await PluginAuth.findOne({ userId, authField }).lean();
|
return await updatePluginAuth({
|
||||||
if (pluginAuth) {
|
userId,
|
||||||
return await PluginAuth.findOneAndUpdate(
|
authField,
|
||||||
{ userId, authField },
|
pluginKey,
|
||||||
{ $set: { value: encryptedValue } },
|
value: encryptedValue,
|
||||||
{ new: true, upsert: true },
|
});
|
||||||
).lean();
|
|
||||||
} else {
|
|
||||||
const newPluginAuth = await new PluginAuth({
|
|
||||||
userId,
|
|
||||||
authField,
|
|
||||||
value: encryptedValue,
|
|
||||||
pluginKey,
|
|
||||||
});
|
|
||||||
await newPluginAuth.save();
|
|
||||||
return newPluginAuth.toObject();
|
|
||||||
}
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.error('[updateUserPluginAuth]', err);
|
logger.error('[updateUserPluginAuth]', err);
|
||||||
return err;
|
return err;
|
||||||
|
@ -105,26 +94,25 @@ const updateUserPluginAuth = async (userId, authField, pluginKey, value) => {
|
||||||
/**
|
/**
|
||||||
* @async
|
* @async
|
||||||
* @param {string} userId
|
* @param {string} userId
|
||||||
* @param {string} authField
|
* @param {string | null} authField - The specific authField to delete, or null if `all` is true.
|
||||||
* @param {boolean} [all]
|
* @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<import('mongoose').DeleteResult>}
|
* @returns {Promise<import('mongoose').DeleteResult>}
|
||||||
* @throws {Error}
|
* @throws {Error}
|
||||||
*/
|
*/
|
||||||
const deleteUserPluginAuth = async (userId, authField, all = false) => {
|
const deleteUserPluginAuth = async (userId, authField, all = false, pluginKey) => {
|
||||||
if (all) {
|
|
||||||
try {
|
|
||||||
const response = await PluginAuth.deleteMany({ userId });
|
|
||||||
return response;
|
|
||||||
} catch (err) {
|
|
||||||
logger.error('[deleteUserPluginAuth]', err);
|
|
||||||
return err;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await PluginAuth.deleteOne({ userId, authField });
|
return await deletePluginAuth({
|
||||||
|
userId,
|
||||||
|
authField,
|
||||||
|
pluginKey,
|
||||||
|
all,
|
||||||
|
});
|
||||||
} catch (err) {
|
} 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;
|
return err;
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
@ -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 { 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 { 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 { useAvailableToolsQuery } from '~/data-provider';
|
||||||
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
import useLocalStorage from '~/hooks/useLocalStorageAlt';
|
||||||
import MultiSelect from '~/components/ui/MultiSelect';
|
import MultiSelect from '~/components/ui/MultiSelect';
|
||||||
import { ephemeralAgentByConvoId } from '~/store';
|
import { ephemeralAgentByConvoId } from '~/store';
|
||||||
|
import { useToastContext } from '~/Providers';
|
||||||
import MCPIcon from '~/components/ui/MCPIcon';
|
import MCPIcon from '~/components/ui/MCPIcon';
|
||||||
import { useLocalize } from '~/hooks';
|
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) => {
|
const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||||
if (rawCurrentValue) {
|
if (rawCurrentValue) {
|
||||||
try {
|
try {
|
||||||
|
@ -24,20 +42,45 @@ const storageCondition = (value: unknown, rawCurrentValue?: string | null) => {
|
||||||
|
|
||||||
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||||
const localize = useLocalize();
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
const key = conversationId ?? Constants.NEW_CONVO;
|
const key = conversationId ?? Constants.NEW_CONVO;
|
||||||
const hasSetFetched = useRef<string | null>(null);
|
const hasSetFetched = useRef<string | null>(null);
|
||||||
|
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
|
||||||
|
const [selectedToolForConfig, setSelectedToolForConfig] = useState<McpServerInfo | null>(null);
|
||||||
|
|
||||||
const { data: mcpServerSet, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
const { data: mcpToolDetails, isFetched } = useAvailableToolsQuery(EModelEndpoint.agents, {
|
||||||
select: (data) => {
|
select: (data: TPlugin[]) => {
|
||||||
const serverNames = new Set<string>();
|
const mcpToolsMap = new Map<string, McpServerInfo>();
|
||||||
data.forEach((tool) => {
|
data.forEach((tool) => {
|
||||||
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter);
|
||||||
if (isMCP && tool.chatMenu !== false) {
|
if (isMCP && tool.chatMenu !== false) {
|
||||||
const parts = tool.pluginKey.split(Constants.mcp_delimiter);
|
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;
|
return;
|
||||||
}
|
}
|
||||||
hasSetFetched.current = key;
|
hasSetFetched.current = key;
|
||||||
if ((mcpServerSet?.size ?? 0) > 0) {
|
if ((mcpToolDetails?.length ?? 0) > 0) {
|
||||||
setMCPValues(mcpValues.filter((mcp) => mcpServerSet?.has(mcp)));
|
setMCPValues(mcpValues.filter((mcp) => mcpToolDetails?.some((tool) => tool.name === mcp)));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setMCPValues([]);
|
setMCPValues([]);
|
||||||
}, [isFetched, setMCPValues, mcpServerSet, key, mcpValues]);
|
}, [isFetched, setMCPValues, mcpToolDetails, key, mcpValues]);
|
||||||
|
|
||||||
const renderSelectedValues = useCallback(
|
const renderSelectedValues = useCallback(
|
||||||
(values: string[], placeholder?: string) => {
|
(values: string[], placeholder?: string) => {
|
||||||
|
@ -96,28 +139,140 @@ function MCPSelect({ conversationId }: { conversationId?: string | null }) {
|
||||||
[localize],
|
[localize],
|
||||||
);
|
);
|
||||||
|
|
||||||
const mcpServers = useMemo(() => {
|
const mcpServerNames = useMemo(() => {
|
||||||
return Array.from(mcpServerSet ?? []);
|
return (mcpToolDetails ?? []).map((tool) => tool.name);
|
||||||
}, [mcpServerSet]);
|
}, [mcpToolDetails]);
|
||||||
|
|
||||||
if (!mcpServerSet || mcpServerSet.size === 0) {
|
const handleConfigSave = useCallback(
|
||||||
|
(targetName: string, authData: Record<string, string>) => {
|
||||||
|
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 = (
|
||||||
|
<div className="flex flex-grow items-center">{defaultContent}</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
if (tool && hasAuthConfig) {
|
||||||
|
return (
|
||||||
|
<div className="flex w-full items-center justify-between">
|
||||||
|
{mainContentWrapper}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
e.preventDefault();
|
||||||
|
setSelectedToolForConfig(tool);
|
||||||
|
setIsConfigModalOpen(true);
|
||||||
|
}}
|
||||||
|
className="ml-2 flex h-6 w-6 items-center justify-center rounded p-1 hover:bg-black/10 dark:hover:bg-white/10"
|
||||||
|
aria-label={`Configure ${serverName}`}
|
||||||
|
>
|
||||||
|
<Settings2 className={`h-4 w-4 ${tool.authenticated ? 'text-green-500' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
// 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 null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MultiSelect
|
<>
|
||||||
items={mcpServers ?? []}
|
<MultiSelect
|
||||||
selectedValues={mcpValues ?? []}
|
items={mcpServerNames}
|
||||||
setSelectedValues={setMCPValues}
|
selectedValues={mcpValues ?? []}
|
||||||
defaultSelectedValues={mcpValues ?? []}
|
setSelectedValues={setMCPValues}
|
||||||
renderSelectedValues={renderSelectedValues}
|
defaultSelectedValues={mcpValues ?? []}
|
||||||
placeholder={localize('com_ui_mcp_servers')}
|
renderSelectedValues={renderSelectedValues}
|
||||||
popoverClassName="min-w-fit"
|
renderItemContent={renderItemContent}
|
||||||
className="badge-icon min-w-fit"
|
placeholder={localize('com_ui_mcp_servers')}
|
||||||
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
popoverClassName="min-w-fit"
|
||||||
selectItemsClassName="border border-blue-600/50 bg-blue-500/10 hover:bg-blue-700/10"
|
className="badge-icon min-w-fit"
|
||||||
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"
|
selectIcon={<MCPIcon className="icon-md text-text-primary" />}
|
||||||
/>
|
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 && (
|
||||||
|
<MCPConfigDialog
|
||||||
|
isOpen={isConfigModalOpen}
|
||||||
|
onOpenChange={setIsConfigModalOpen}
|
||||||
|
serverName={selectedToolForConfig.name}
|
||||||
|
fieldsSchema={(() => {
|
||||||
|
const schema: Record<string, ConfigFieldDetail> = {};
|
||||||
|
if (selectedToolForConfig?.authConfig) {
|
||||||
|
selectedToolForConfig.authConfig.forEach((field) => {
|
||||||
|
schema[field.authField] = {
|
||||||
|
title: field.label,
|
||||||
|
description: field.description,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return schema;
|
||||||
|
})()}
|
||||||
|
initialValues={(() => {
|
||||||
|
const initial: Record<string, string> = {};
|
||||||
|
// 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}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
253
client/src/components/SidePanel/MCP/MCPPanel.tsx
Normal file
|
@ -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<string, { title: string; description: string }>;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MCPPanel() {
|
||||||
|
const localize = useLocalize();
|
||||||
|
const { showToast } = useToastContext();
|
||||||
|
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig();
|
||||||
|
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
|
||||||
|
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<string, string>) => {
|
||||||
|
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 <MCPPanelSkeleton />;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mcpServerDefinitions.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">
|
||||||
|
{localize('com_sidepanel_mcp_no_servers_with_vars')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-500">
|
||||||
|
{localize('com_ui_error')}: {localize('com_ui_mcp_server_not_found')}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
onClick={handleGoBackToList}
|
||||||
|
className="mb-3 flex items-center px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||||
|
{localize('com_ui_back')}
|
||||||
|
</Button>
|
||||||
|
<h3 className="mb-3 text-lg font-medium">
|
||||||
|
{localize('com_sidepanel_mcp_variables_for', { '0': serverBeingEdited.serverName })}
|
||||||
|
</h3>
|
||||||
|
<MCPVariableEditor
|
||||||
|
server={serverBeingEdited}
|
||||||
|
onSave={handleSaveServerVars}
|
||||||
|
onRevoke={handleRevokeServerVars}
|
||||||
|
isSubmitting={updateUserPluginsMutation.isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
// Server List View
|
||||||
|
return (
|
||||||
|
<div className="h-auto max-w-full overflow-x-hidden p-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
{mcpServerDefinitions.map((server) => (
|
||||||
|
<Button
|
||||||
|
key={server.serverName}
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start dark:hover:bg-gray-700"
|
||||||
|
onClick={() => handleServerClickToEdit(server.serverName)}
|
||||||
|
>
|
||||||
|
{server.serverName}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inner component for the form - remains the same
|
||||||
|
interface MCPVariableEditorProps {
|
||||||
|
server: ServerConfigWithVars;
|
||||||
|
onSave: (serverName: string, updatedValues: Record<string, string>) => 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<Record<string, string>>({
|
||||||
|
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<string, string>,
|
||||||
|
);
|
||||||
|
reset(initialFormValues);
|
||||||
|
}, [reset, server.config.customUserVars]);
|
||||||
|
|
||||||
|
const onFormSubmit = (data: Record<string, string>) => {
|
||||||
|
onSave(server.serverName, data);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRevokeClick = () => {
|
||||||
|
onRevoke(server.serverName);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="mb-4 mt-2 space-y-4">
|
||||||
|
{Object.entries(server.config.customUserVars).map(([key, details]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label htmlFor={`${server.serverName}-${key}`} className="text-sm font-medium">
|
||||||
|
{details.title}
|
||||||
|
</Label>
|
||||||
|
<Controller
|
||||||
|
name={key}
|
||||||
|
control={control}
|
||||||
|
defaultValue={''}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={`${server.serverName}-${key}`}
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
placeholder={localize('com_sidepanel_mcp_enter_value', { '0': details.title })}
|
||||||
|
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{details.description && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
{Object.keys(server.config.customUserVars).length > 0 && (
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={handleRevokeClick}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{localize('com_ui_revoke')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button
|
||||||
|
type="submit"
|
||||||
|
className="bg-green-500 text-white hover:bg-green-600"
|
||||||
|
disabled={isSubmitting || !isDirty}
|
||||||
|
>
|
||||||
|
{isSubmitting ? localize('com_ui_saving') : localize('com_ui_save')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
);
|
||||||
|
}
|
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
21
client/src/components/SidePanel/MCP/MCPPanelSkeleton.tsx
Normal file
|
@ -0,0 +1,21 @@
|
||||||
|
import React from 'react';
|
||||||
|
import { Skeleton } from '~/components/ui';
|
||||||
|
|
||||||
|
export default function MCPPanelSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="space-y-6 p-2">
|
||||||
|
{[1, 2].map((serverIdx) => (
|
||||||
|
<div key={serverIdx} className="space-y-4">
|
||||||
|
<Skeleton className="h-6 w-1/3 rounded-lg" /> {/* Server Name */}
|
||||||
|
{[1, 2].map((varIdx) => (
|
||||||
|
<div key={varIdx} className="space-y-2">
|
||||||
|
<Skeleton className="h-5 w-1/4 rounded-lg" /> {/* Variable Title */}
|
||||||
|
<Skeleton className="h-8 w-full rounded-lg" /> {/* Input Field */}
|
||||||
|
<Skeleton className="h-4 w-2/3 rounded-lg" /> {/* Description */}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
|
@ -1,7 +1,7 @@
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { Search, X } from 'lucide-react';
|
import { Search, X } from 'lucide-react';
|
||||||
import { useFormContext } from 'react-hook-form';
|
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 { Dialog, DialogPanel, DialogTitle, Description } from '@headlessui/react';
|
||||||
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query';
|
||||||
import type {
|
import type {
|
||||||
|
@ -125,16 +125,23 @@ function ToolSelectDialog({
|
||||||
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
|
const getAvailablePluginFromKey = tools?.find((p) => p.pluginKey === pluginKey);
|
||||||
setSelectedPlugin(getAvailablePluginFromKey);
|
setSelectedPlugin(getAvailablePluginFromKey);
|
||||||
|
|
||||||
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
const isMCPTool = pluginKey.includes(Constants.mcp_delimiter);
|
||||||
|
|
||||||
if (authConfig && authConfig.length > 0 && !authenticated) {
|
if (isMCPTool) {
|
||||||
setShowPluginAuthForm(true);
|
// 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 {
|
} else {
|
||||||
handleInstall({
|
const { authConfig, authenticated = false } = getAvailablePluginFromKey ?? {};
|
||||||
pluginKey,
|
if (authConfig && authConfig.length > 0 && !authenticated) {
|
||||||
action: 'install',
|
setShowPluginAuthForm(true);
|
||||||
auth: {},
|
} else {
|
||||||
});
|
handleInstall({
|
||||||
|
pluginKey,
|
||||||
|
action: 'install',
|
||||||
|
auth: {},
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
122
client/src/components/ui/MCPConfigDialog.tsx
Normal file
|
@ -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<string, ConfigFieldDetail>;
|
||||||
|
initialValues: Record<string, string>;
|
||||||
|
onSave: (updatedValues: Record<string, string>) => 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<Record<string, string>>({
|
||||||
|
defaultValues: initialValues,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
reset(initialValues);
|
||||||
|
}
|
||||||
|
}, [isOpen, initialValues, reset]);
|
||||||
|
|
||||||
|
const onFormSubmit = (data: Record<string, string>) => {
|
||||||
|
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 (
|
||||||
|
<OGDialog open={isOpen} onOpenChange={onOpenChange}>
|
||||||
|
<OGDialogTemplate
|
||||||
|
className="sm:max-w-lg"
|
||||||
|
title={dialogTitle}
|
||||||
|
description={dialogDescription}
|
||||||
|
headerClassName="px-6 pt-6 pb-4"
|
||||||
|
main={
|
||||||
|
<form onSubmit={handleSubmit(onFormSubmit)} className="space-y-4 px-6 pb-2">
|
||||||
|
{Object.entries(fieldsSchema).map(([key, details]) => (
|
||||||
|
<div key={key} className="space-y-2">
|
||||||
|
<Label htmlFor={key} className="text-sm font-medium">
|
||||||
|
{details.title}
|
||||||
|
</Label>
|
||||||
|
<Controller
|
||||||
|
name={key}
|
||||||
|
control={control}
|
||||||
|
defaultValue={initialValues[key] || ''}
|
||||||
|
render={({ field }) => (
|
||||||
|
<Input
|
||||||
|
id={key}
|
||||||
|
type="text"
|
||||||
|
{...field}
|
||||||
|
placeholder={localize('com_ui_mcp_enter_var', { 0: details.title })}
|
||||||
|
className="w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white sm:text-sm"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
{details.description && (
|
||||||
|
<p
|
||||||
|
className="text-xs text-text-secondary [&_a]:text-blue-500 [&_a]:hover:text-blue-600 dark:[&_a]:text-blue-400 dark:[&_a]:hover:text-blue-300"
|
||||||
|
dangerouslySetInnerHTML={{ __html: details.description }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{errors[key] && <p className="text-xs text-red-500">{errors[key]?.message}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</form>
|
||||||
|
}
|
||||||
|
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 && (
|
||||||
|
<Button
|
||||||
|
onClick={handleRevoke}
|
||||||
|
className="bg-red-600 text-white hover:bg-red-700 dark:hover:bg-red-800"
|
||||||
|
disabled={isSubmitting}
|
||||||
|
>
|
||||||
|
{localize('com_ui_revoke')}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
footerClassName="flex justify-end gap-2 px-6 pb-6 pt-2"
|
||||||
|
showCancelButton={true}
|
||||||
|
/>
|
||||||
|
</OGDialog>
|
||||||
|
);
|
||||||
|
}
|
|
@ -26,6 +26,11 @@ interface MultiSelectProps<T extends string> {
|
||||||
selectItemsClassName?: string;
|
selectItemsClassName?: string;
|
||||||
selectedValues: T[];
|
selectedValues: T[];
|
||||||
setSelectedValues: (values: T[]) => void;
|
setSelectedValues: (values: T[]) => void;
|
||||||
|
renderItemContent?: (
|
||||||
|
value: T,
|
||||||
|
defaultContent: React.ReactNode,
|
||||||
|
isSelected: boolean,
|
||||||
|
) => React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
function defaultRender<T extends string>(values: T[], placeholder?: string) {
|
||||||
|
@ -54,9 +59,9 @@ export default function MultiSelect<T extends string>({
|
||||||
selectItemsClassName,
|
selectItemsClassName,
|
||||||
selectedValues = [],
|
selectedValues = [],
|
||||||
setSelectedValues,
|
setSelectedValues,
|
||||||
|
renderItemContent,
|
||||||
}: MultiSelectProps<T>) {
|
}: MultiSelectProps<T>) {
|
||||||
const selectRef = useRef<HTMLButtonElement>(null);
|
const selectRef = useRef<HTMLButtonElement>(null);
|
||||||
// const [selectedValues, setSelectedValues] = React.useState<T[]>(defaultSelectedValues);
|
|
||||||
|
|
||||||
const handleValueChange = (values: T[]) => {
|
const handleValueChange = (values: T[]) => {
|
||||||
setSelectedValues(values);
|
setSelectedValues(values);
|
||||||
|
@ -105,23 +110,33 @@ export default function MultiSelect<T extends string>({
|
||||||
popoverClassName,
|
popoverClassName,
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{items.map((value) => (
|
{items.map((value) => {
|
||||||
<SelectItem
|
const defaultContent = (
|
||||||
key={value}
|
<>
|
||||||
value={value}
|
<SelectItemCheck className="text-primary" />
|
||||||
className={cn(
|
<span className="truncate">{value}</span>
|
||||||
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
</>
|
||||||
'scroll-m-1 outline-none transition-colors',
|
);
|
||||||
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
const isCurrentItemSelected = selectedValues.includes(value);
|
||||||
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
return (
|
||||||
'w-full min-w-0 text-sm',
|
<SelectItem
|
||||||
itemClassName,
|
key={value}
|
||||||
)}
|
value={value}
|
||||||
>
|
className={cn(
|
||||||
<SelectItemCheck className="text-primary" />
|
'flex items-center gap-2 rounded-lg px-2 py-1.5 hover:cursor-pointer',
|
||||||
<span className="truncate">{value}</span>
|
'scroll-m-1 outline-none transition-colors',
|
||||||
</SelectItem>
|
'hover:bg-black/[0.075] dark:hover:bg-white/10',
|
||||||
))}
|
'data-[active-item]:bg-black/[0.075] dark:data-[active-item]:bg-white/10',
|
||||||
|
'w-full min-w-0 text-sm',
|
||||||
|
itemClassName,
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{renderItemContent
|
||||||
|
? renderItemContent(value, defaultContent, isCurrentItemSelected)
|
||||||
|
: defaultContent}
|
||||||
|
</SelectItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
</SelectPopover>
|
</SelectPopover>
|
||||||
</SelectProvider>
|
</SelectProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
@ -17,7 +17,10 @@ import PanelSwitch from '~/components/SidePanel/Builder/PanelSwitch';
|
||||||
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
|
||||||
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
import Parameters from '~/components/SidePanel/Parameters/Panel';
|
||||||
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
import FilesPanel from '~/components/SidePanel/Files/Panel';
|
||||||
|
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
|
||||||
import { Blocks, AttachmentIcon } from '~/components/svg';
|
import { Blocks, AttachmentIcon } from '~/components/svg';
|
||||||
|
import { useGetStartupConfig } from '~/data-provider';
|
||||||
|
import MCPIcon from '~/components/ui/MCPIcon';
|
||||||
import { useHasAccess } from '~/hooks';
|
import { useHasAccess } from '~/hooks';
|
||||||
|
|
||||||
export default function useSideNavLinks({
|
export default function useSideNavLinks({
|
||||||
|
@ -59,6 +62,7 @@ export default function useSideNavLinks({
|
||||||
permissionType: PermissionTypes.AGENTS,
|
permissionType: PermissionTypes.AGENTS,
|
||||||
permission: Permissions.CREATE,
|
permission: Permissions.CREATE,
|
||||||
});
|
});
|
||||||
|
const { data: startupConfig } = useGetStartupConfig();
|
||||||
|
|
||||||
const Links = useMemo(() => {
|
const Links = useMemo(() => {
|
||||||
const links: NavLink[] = [];
|
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({
|
links.push({
|
||||||
title: 'com_sidepanel_hide_panel',
|
title: 'com_sidepanel_hide_panel',
|
||||||
label: '',
|
label: '',
|
||||||
|
@ -171,6 +190,7 @@ export default function useSideNavLinks({
|
||||||
hasAccessToBookmarks,
|
hasAccessToBookmarks,
|
||||||
hasAccessToCreateAgents,
|
hasAccessToCreateAgents,
|
||||||
hidePanel,
|
hidePanel,
|
||||||
|
startupConfig,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return Links;
|
return Links;
|
||||||
|
|
|
@ -423,6 +423,8 @@
|
||||||
"com_nav_log_out": "Log out",
|
"com_nav_log_out": "Log out",
|
||||||
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
|
"com_nav_long_audio_warning": "Longer texts will take longer to process.",
|
||||||
"com_nav_maximize_chat_space": "Maximize chat space",
|
"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_modular_chat": "Enable switching Endpoints mid-conversation",
|
||||||
"com_nav_my_files": "My Files",
|
"com_nav_my_files": "My Files",
|
||||||
"com_nav_not_supported": "Not Supported",
|
"com_nav_not_supported": "Not Supported",
|
||||||
|
@ -447,6 +449,7 @@
|
||||||
"com_nav_setting_chat": "Chat",
|
"com_nav_setting_chat": "Chat",
|
||||||
"com_nav_setting_data": "Data controls",
|
"com_nav_setting_data": "Data controls",
|
||||||
"com_nav_setting_general": "General",
|
"com_nav_setting_general": "General",
|
||||||
|
"com_nav_setting_mcp": "MCP Settings",
|
||||||
"com_nav_setting_personalization": "Personalization",
|
"com_nav_setting_personalization": "Personalization",
|
||||||
"com_nav_setting_speech": "Speech",
|
"com_nav_setting_speech": "Speech",
|
||||||
"com_nav_settings": "Settings",
|
"com_nav_settings": "Settings",
|
||||||
|
@ -480,8 +483,15 @@
|
||||||
"com_sidepanel_conversation_tags": "Bookmarks",
|
"com_sidepanel_conversation_tags": "Bookmarks",
|
||||||
"com_sidepanel_hide_panel": "Hide Panel",
|
"com_sidepanel_hide_panel": "Hide Panel",
|
||||||
"com_sidepanel_manage_files": "Manage Files",
|
"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_sidepanel_parameters": "Parameters",
|
||||||
"com_sources_image_alt": "Search result image",
|
"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_more_sources": "+{{count}} sources",
|
||||||
"com_sources_tab_all": "All",
|
"com_sources_tab_all": "All",
|
||||||
"com_sources_tab_images": "Images",
|
"com_sources_tab_images": "Images",
|
||||||
|
@ -570,6 +580,7 @@
|
||||||
"com_ui_authentication_type": "Authentication Type",
|
"com_ui_authentication_type": "Authentication Type",
|
||||||
"com_ui_avatar": "Avatar",
|
"com_ui_avatar": "Avatar",
|
||||||
"com_ui_azure": "Azure",
|
"com_ui_azure": "Azure",
|
||||||
|
"com_ui_back": "Back",
|
||||||
"com_ui_back_to_chat": "Back to Chat",
|
"com_ui_back_to_chat": "Back to Chat",
|
||||||
"com_ui_back_to_prompts": "Back to Prompts",
|
"com_ui_back_to_prompts": "Back to Prompts",
|
||||||
"com_ui_backup_codes": "Backup Codes",
|
"com_ui_backup_codes": "Backup Codes",
|
||||||
|
@ -795,6 +806,7 @@
|
||||||
"com_ui_manage": "Manage",
|
"com_ui_manage": "Manage",
|
||||||
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
"com_ui_max_tags": "Maximum number allowed is {{0}}, using latest values.",
|
||||||
"com_ui_mcp_servers": "MCP Servers",
|
"com_ui_mcp_servers": "MCP Servers",
|
||||||
|
"com_ui_mcp_server_not_found": "Server not found.",
|
||||||
"com_ui_memories": "Memories",
|
"com_ui_memories": "Memories",
|
||||||
"com_ui_memories_allow_create": "Allow creating Memories",
|
"com_ui_memories_allow_create": "Allow creating Memories",
|
||||||
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
|
"com_ui_memories_allow_opt_out": "Allow users to opt out of Memories",
|
||||||
|
@ -1020,6 +1032,7 @@
|
||||||
"com_user_message": "You",
|
"com_user_message": "You",
|
||||||
"com_warning_resubmit_unsupported": "Resubmitting the AI message is not supported for this endpoint.",
|
"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": "Add MCP",
|
||||||
"com_ui_add_mcp_server": "Add MCP Server",
|
"com_ui_add_mcp_server": "Add MCP Server",
|
||||||
"com_ui_edit_mcp_server": "Edit MCP Server",
|
"com_ui_edit_mcp_server": "Edit MCP Server",
|
||||||
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
|
"com_agents_mcps_disabled": "You need to create an agent before adding MCPs.",
|
||||||
|
|
2
package-lock.json
generated
2
package-lock.json
generated
|
@ -46322,7 +46322,7 @@
|
||||||
},
|
},
|
||||||
"packages/data-provider": {
|
"packages/data-provider": {
|
||||||
"name": "librechat-data-provider",
|
"name": "librechat-data-provider",
|
||||||
"version": "0.7.87",
|
"version": "0.7.88",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"axios": "^1.8.2",
|
"axios": "^1.8.2",
|
||||||
|
|
93
packages/api/src/agents/auth.ts
Normal file
93
packages/api/src/agents/auth.ts
Normal file
|
@ -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<string, Record<string, string>>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<PluginAuthMap> {
|
||||||
|
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<string, IPluginAuth[]>();
|
||||||
|
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<void>[] = [];
|
||||||
|
|
||||||
|
/** 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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -1,6 +1,12 @@
|
||||||
import { Run, Providers } from '@librechat/agents';
|
import { Run, Providers } from '@librechat/agents';
|
||||||
import { providerEndpointMap, KnownEndpoints } from 'librechat-data-provider';
|
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 { Agent } from 'librechat-data-provider';
|
||||||
import type * as t from '~/types';
|
import type * as t from '~/types';
|
||||||
|
|
||||||
|
@ -32,7 +38,7 @@ export async function createRun({
|
||||||
streaming = true,
|
streaming = true,
|
||||||
streamUsage = true,
|
streamUsage = true,
|
||||||
}: {
|
}: {
|
||||||
agent: Agent;
|
agent: Omit<Agent, 'tools'> & { tools?: GenericTool[] };
|
||||||
signal: AbortSignal;
|
signal: AbortSignal;
|
||||||
runId?: string;
|
runId?: string;
|
||||||
streaming?: boolean;
|
streaming?: boolean;
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
/* MCP */
|
/* MCP */
|
||||||
export * from './mcp/manager';
|
export * from './mcp/manager';
|
||||||
export * from './mcp/oauth';
|
export * from './mcp/oauth';
|
||||||
|
export * from './mcp/auth';
|
||||||
/* Utilities */
|
/* Utilities */
|
||||||
export * from './mcp/utils';
|
export * from './mcp/utils';
|
||||||
export * from './utils';
|
export * from './utils';
|
||||||
|
|
58
packages/api/src/mcp/auth.ts
Normal file
58
packages/api/src/mcp/auth.ts
Normal file
|
@ -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<string, unknown>;
|
||||||
|
findPluginAuthsByKeys: PluginAuthMethods['findPluginAuthsByKeys'];
|
||||||
|
}) {
|
||||||
|
if (!tools || tools.length === 0) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
const uniqueMcpServers = new Set<string>();
|
||||||
|
|
||||||
|
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<string, Record<string, string>> = {};
|
||||||
|
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;
|
||||||
|
}
|
|
@ -14,10 +14,6 @@ import { MCPTokenStorage } from './oauth/tokens';
|
||||||
import { formatToolContent } from './parsers';
|
import { formatToolContent } from './parsers';
|
||||||
import { MCPConnection } from './connection';
|
import { MCPConnection } from './connection';
|
||||||
|
|
||||||
export interface CallToolOptions extends RequestOptions {
|
|
||||||
user?: TUser;
|
|
||||||
}
|
|
||||||
|
|
||||||
export class MCPManager {
|
export class MCPManager {
|
||||||
private static instance: MCPManager | null = null;
|
private static instance: MCPManager | null = null;
|
||||||
/** App-level connections initialized at startup */
|
/** App-level connections initialized at startup */
|
||||||
|
@ -28,7 +24,11 @@ export class MCPManager {
|
||||||
private userLastActivity: Map<string, number> = new Map();
|
private userLastActivity: Map<string, number> = new Map();
|
||||||
private readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable)
|
private readonly USER_CONNECTION_IDLE_TIMEOUT = 15 * 60 * 1000; // 15 minutes (TODO: make configurable)
|
||||||
private mcpConfigs: t.MCPServers = {};
|
private mcpConfigs: t.MCPServers = {};
|
||||||
private processMCPEnv?: (obj: MCPOptions, user?: TUser) => MCPOptions; // Store the processing function
|
private processMCPEnv?: (
|
||||||
|
obj: MCPOptions,
|
||||||
|
user?: TUser,
|
||||||
|
customUserVars?: Record<string, string>,
|
||||||
|
) => MCPOptions; // Store the processing function
|
||||||
/** Store MCP server instructions */
|
/** Store MCP server instructions */
|
||||||
private serverInstructions: Map<string, string> = new Map();
|
private serverInstructions: Map<string, string> = new Map();
|
||||||
|
|
||||||
|
@ -63,7 +63,6 @@ export class MCPManager {
|
||||||
if (!tokenMethods) {
|
if (!tokenMethods) {
|
||||||
logger.info('[MCP] No token methods provided, token persistence will not be available');
|
logger.info('[MCP] No token methods provided, token persistence will not be available');
|
||||||
}
|
}
|
||||||
|
|
||||||
const entries = Object.entries(mcpServers);
|
const entries = Object.entries(mcpServers);
|
||||||
const initializedServers = new Set();
|
const initializedServers = new Set();
|
||||||
const connectionResults = await Promise.allSettled(
|
const connectionResults = await Promise.allSettled(
|
||||||
|
@ -382,6 +381,7 @@ export class MCPManager {
|
||||||
user,
|
user,
|
||||||
serverName,
|
serverName,
|
||||||
flowManager,
|
flowManager,
|
||||||
|
customUserVars,
|
||||||
tokenMethods,
|
tokenMethods,
|
||||||
oauthStart,
|
oauthStart,
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
|
@ -390,6 +390,7 @@ export class MCPManager {
|
||||||
user: TUser;
|
user: TUser;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||||
|
customUserVars?: Record<string, string>;
|
||||||
tokenMethods?: TokenMethods;
|
tokenMethods?: TokenMethods;
|
||||||
oauthStart?: (authURL: string) => Promise<void>;
|
oauthStart?: (authURL: string) => Promise<void>;
|
||||||
oauthEnd?: () => Promise<void>;
|
oauthEnd?: () => Promise<void>;
|
||||||
|
@ -444,9 +445,8 @@ export class MCPManager {
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.processMCPEnv) {
|
if (this.processMCPEnv) {
|
||||||
config = { ...(this.processMCPEnv(config, user) ?? {}) };
|
config = { ...(this.processMCPEnv(config, user, customUserVars) ?? {}) };
|
||||||
}
|
}
|
||||||
|
|
||||||
/** If no in-memory tokens, tokens from persistent storage */
|
/** If no in-memory tokens, tokens from persistent storage */
|
||||||
let tokens: MCPOAuthTokens | null = null;
|
let tokens: MCPOAuthTokens | null = null;
|
||||||
if (tokenMethods?.findToken) {
|
if (tokenMethods?.findToken) {
|
||||||
|
@ -752,7 +752,6 @@ export class MCPManager {
|
||||||
getServerTools?: (serverName: string) => Promise<t.LCManifestTool[] | undefined>;
|
getServerTools?: (serverName: string) => Promise<t.LCManifestTool[] | undefined>;
|
||||||
}): Promise<t.LCToolManifest> {
|
}): Promise<t.LCToolManifest> {
|
||||||
const mcpTools: t.LCManifestTool[] = [];
|
const mcpTools: t.LCManifestTool[] = [];
|
||||||
|
|
||||||
for (const [serverName, connection] of this.connections.entries()) {
|
for (const [serverName, connection] of this.connections.entries()) {
|
||||||
try {
|
try {
|
||||||
/** Attempt to ensure connection is active, with reconnection if needed */
|
/** Attempt to ensure connection is active, with reconnection if needed */
|
||||||
|
@ -784,13 +783,21 @@ export class MCPManager {
|
||||||
const serverTools: t.LCManifestTool[] = [];
|
const serverTools: t.LCManifestTool[] = [];
|
||||||
for (const tool of tools) {
|
for (const tool of tools) {
|
||||||
const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
|
const pluginKey = `${tool.name}${CONSTANTS.mcp_delimiter}${serverName}`;
|
||||||
|
|
||||||
|
const config = this.mcpConfigs[serverName];
|
||||||
const manifestTool: t.LCManifestTool = {
|
const manifestTool: t.LCManifestTool = {
|
||||||
name: tool.name,
|
name: tool.name,
|
||||||
pluginKey,
|
pluginKey,
|
||||||
description: tool.description ?? '',
|
description: tool.description ?? '',
|
||||||
icon: connection.iconPath,
|
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) {
|
if (config?.chatMenu === false) {
|
||||||
manifestTool.chatMenu = false;
|
manifestTool.chatMenu = false;
|
||||||
}
|
}
|
||||||
|
@ -814,6 +821,7 @@ export class MCPManager {
|
||||||
* for user-specific connections upon successful call initiation.
|
* for user-specific connections upon successful call initiation.
|
||||||
*/
|
*/
|
||||||
async callTool({
|
async callTool({
|
||||||
|
user,
|
||||||
serverName,
|
serverName,
|
||||||
toolName,
|
toolName,
|
||||||
provider,
|
provider,
|
||||||
|
@ -823,20 +831,22 @@ export class MCPManager {
|
||||||
flowManager,
|
flowManager,
|
||||||
oauthStart,
|
oauthStart,
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
|
customUserVars,
|
||||||
}: {
|
}: {
|
||||||
|
user?: TUser;
|
||||||
serverName: string;
|
serverName: string;
|
||||||
toolName: string;
|
toolName: string;
|
||||||
provider: t.Provider;
|
provider: t.Provider;
|
||||||
toolArguments?: Record<string, unknown>;
|
toolArguments?: Record<string, unknown>;
|
||||||
options?: CallToolOptions;
|
options?: RequestOptions;
|
||||||
tokenMethods?: TokenMethods;
|
tokenMethods?: TokenMethods;
|
||||||
|
customUserVars?: Record<string, string>;
|
||||||
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
flowManager: FlowStateManager<MCPOAuthTokens | null>;
|
||||||
oauthStart?: (authURL: string) => Promise<void>;
|
oauthStart?: (authURL: string) => Promise<void>;
|
||||||
oauthEnd?: () => Promise<void>;
|
oauthEnd?: () => Promise<void>;
|
||||||
}): Promise<t.FormattedToolResponse> {
|
}): Promise<t.FormattedToolResponse> {
|
||||||
/** User-specific connection */
|
/** User-specific connection */
|
||||||
let connection: MCPConnection | undefined;
|
let connection: MCPConnection | undefined;
|
||||||
const { user, ...callOptions } = options ?? {};
|
|
||||||
const userId = user?.id;
|
const userId = user?.id;
|
||||||
const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`;
|
const logPrefix = userId ? `[MCP][User: ${userId}][${serverName}]` : `[MCP][${serverName}]`;
|
||||||
|
|
||||||
|
@ -852,6 +862,7 @@ export class MCPManager {
|
||||||
oauthStart,
|
oauthStart,
|
||||||
oauthEnd,
|
oauthEnd,
|
||||||
signal: options?.signal,
|
signal: options?.signal,
|
||||||
|
customUserVars,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
/** App-level connection */
|
/** App-level connection */
|
||||||
|
@ -883,7 +894,7 @@ export class MCPManager {
|
||||||
CallToolResultSchema,
|
CallToolResultSchema,
|
||||||
{
|
{
|
||||||
timeout: connection.timeout,
|
timeout: connection.timeout,
|
||||||
...callOptions,
|
...options,
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
if (userId) {
|
if (userId) {
|
||||||
|
|
|
@ -14,7 +14,15 @@ export type StdioOptions = z.infer<typeof StdioOptionsSchema>;
|
||||||
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
export type WebSocketOptions = z.infer<typeof WebSocketOptionsSchema>;
|
||||||
export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
|
export type SSEOptions = z.infer<typeof SSEOptionsSchema>;
|
||||||
export type StreamableHTTPOptions = z.infer<typeof StreamableHTTPOptionsSchema>;
|
export type StreamableHTTPOptions = z.infer<typeof StreamableHTTPOptionsSchema>;
|
||||||
export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
|
export type MCPOptions = z.infer<typeof MCPOptionsSchema> & {
|
||||||
|
customUserVars?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
};
|
||||||
export type MCPServers = z.infer<typeof MCPServersSchema>;
|
export type MCPServers = z.infer<typeof MCPServersSchema>;
|
||||||
export interface MCPResource {
|
export interface MCPResource {
|
||||||
uri: string;
|
uri: string;
|
||||||
|
|
|
@ -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_.-]+$
|
* Normalizes a server name to match the pattern ^[a-zA-Z0-9_.-]+$
|
||||||
* This is required for Azure OpenAI models with Tool Calling
|
* This is required for Azure OpenAI models with Tool Calling
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
{
|
{
|
||||||
"name": "librechat-data-provider",
|
"name": "librechat-data-provider",
|
||||||
"version": "0.7.87",
|
"version": "0.7.88",
|
||||||
"description": "data services for librechat apps",
|
"description": "data services for librechat apps",
|
||||||
"main": "dist/index.js",
|
"main": "dist/index.js",
|
||||||
"module": "dist/index.es.js",
|
"module": "dist/index.es.js",
|
||||||
|
|
|
@ -1,6 +1,8 @@
|
||||||
import axios from 'axios';
|
|
||||||
import { z } from 'zod';
|
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 {
|
import {
|
||||||
createURL,
|
createURL,
|
||||||
resolveRef,
|
resolveRef,
|
||||||
|
@ -15,9 +17,7 @@ import {
|
||||||
scholarAIOpenapiSpec,
|
scholarAIOpenapiSpec,
|
||||||
swapidev,
|
swapidev,
|
||||||
} from './openapiSpecs';
|
} from './openapiSpecs';
|
||||||
import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/assistants';
|
import { AuthorizationTypeEnum, AuthTypeEnum } from '../src/types/agents';
|
||||||
import type { FlowchartSchema } from './openapiSpecs';
|
|
||||||
import type { ParametersSchema } from '../src/actions';
|
|
||||||
|
|
||||||
jest.mock('axios');
|
jest.mock('axios');
|
||||||
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
const mockedAxios = axios as jest.Mocked<typeof axios>;
|
||||||
|
|
|
@ -525,5 +525,188 @@ describe('Environment Variable Extraction (MCP)', () => {
|
||||||
const result3 = processMCPEnv(obj3, userWithBoth);
|
const result3 = processMCPEnv(obj3, userWithBoth);
|
||||||
expect('headers' in result3 && result3.headers?.['User-Id']).toBe('user-789');
|
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
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -588,6 +588,18 @@ export type TStartupConfig = {
|
||||||
scraperType?: ScraperTypes;
|
scraperType?: ScraperTypes;
|
||||||
rerankerType?: RerankerTypes;
|
rerankerType?: RerankerTypes;
|
||||||
};
|
};
|
||||||
|
mcpServers?: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
customUserVars: Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
}
|
||||||
|
>;
|
||||||
|
}
|
||||||
|
>;
|
||||||
};
|
};
|
||||||
|
|
||||||
export enum OCRStrategy {
|
export enum OCRStrategy {
|
||||||
|
@ -885,7 +897,6 @@ export const defaultModels = {
|
||||||
[EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'],
|
[EModelEndpoint.assistants]: [...sharedOpenAIModels, 'chatgpt-4o-latest'],
|
||||||
[EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels)
|
[EModelEndpoint.agents]: sharedOpenAIModels, // TODO: Add agent models (agentsModels)
|
||||||
[EModelEndpoint.google]: [
|
[EModelEndpoint.google]: [
|
||||||
// Shared Google Models between Vertex AI & Gen AI
|
|
||||||
// Gemini 2.0 Models
|
// Gemini 2.0 Models
|
||||||
'gemini-2.0-flash-001',
|
'gemini-2.0-flash-001',
|
||||||
'gemini-2.0-flash-exp',
|
'gemini-2.0-flash-exp',
|
||||||
|
@ -1395,6 +1406,8 @@ export enum Constants {
|
||||||
GLOBAL_PROJECT_NAME = 'instance',
|
GLOBAL_PROJECT_NAME = 'instance',
|
||||||
/** Delimiter for MCP tools */
|
/** Delimiter for MCP tools */
|
||||||
mcp_delimiter = '_mcp_',
|
mcp_delimiter = '_mcp_',
|
||||||
|
/** Prefix for MCP plugins */
|
||||||
|
mcp_prefix = 'mcp_',
|
||||||
/** Placeholder Agent ID for Ephemeral Agents */
|
/** Placeholder Agent ID for Ephemeral Agents */
|
||||||
EPHEMERAL_AGENT_ID = 'ephemeral',
|
EPHEMERAL_AGENT_ID = 'ephemeral',
|
||||||
}
|
}
|
||||||
|
|
|
@ -151,7 +151,11 @@ export const updateUserPlugins = (payload: t.TUpdateUserPlugins) => {
|
||||||
|
|
||||||
/* Config */
|
/* Config */
|
||||||
|
|
||||||
export const getStartupConfig = (): Promise<config.TStartupConfig> => {
|
export const getStartupConfig = (): Promise<
|
||||||
|
config.TStartupConfig & {
|
||||||
|
mcpCustomUserVars?: Record<string, { title: string; description: string }>;
|
||||||
|
}
|
||||||
|
> => {
|
||||||
return request.get(endpoints.config());
|
return request.get(endpoints.config());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
@ -39,6 +39,15 @@ const BaseOptionsSchema = z.object({
|
||||||
token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(),
|
token_exchange_method: z.nativeEnum(TokenExchangeMethodEnum).optional(),
|
||||||
})
|
})
|
||||||
.optional(),
|
.optional(),
|
||||||
|
customUserVars: z
|
||||||
|
.record(
|
||||||
|
z.string(),
|
||||||
|
z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const StdioOptionsSchema = BaseOptionsSchema.extend({
|
export const StdioOptionsSchema = BaseOptionsSchema.extend({
|
||||||
|
@ -191,13 +200,55 @@ function processUserPlaceholders(value: string, user?: TUser): string {
|
||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function processSingleValue({
|
||||||
|
originalValue,
|
||||||
|
customUserVars,
|
||||||
|
user,
|
||||||
|
}: {
|
||||||
|
originalValue: string;
|
||||||
|
customUserVars?: Record<string, string>;
|
||||||
|
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
|
* Recursively processes an object to replace environment variables in string values
|
||||||
* @param obj - The object to process
|
* @param obj - The object to process
|
||||||
* @param user - The user object containing all user fields
|
* @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
|
* @returns - The processed object with environment variables replaced
|
||||||
*/
|
*/
|
||||||
export function processMCPEnv(obj: Readonly<MCPOptions>, user?: TUser): MCPOptions {
|
export function processMCPEnv(
|
||||||
|
obj: Readonly<MCPOptions>,
|
||||||
|
user?: TUser,
|
||||||
|
customUserVars?: Record<string, string>,
|
||||||
|
): MCPOptions {
|
||||||
if (obj === null || obj === undefined) {
|
if (obj === null || obj === undefined) {
|
||||||
return obj;
|
return obj;
|
||||||
}
|
}
|
||||||
|
@ -206,32 +257,25 @@ export function processMCPEnv(obj: Readonly<MCPOptions>, user?: TUser): MCPOptio
|
||||||
|
|
||||||
if ('env' in newObj && newObj.env) {
|
if ('env' in newObj && newObj.env) {
|
||||||
const processedEnv: Record<string, string> = {};
|
const processedEnv: Record<string, string> = {};
|
||||||
for (const [key, value] of Object.entries(newObj.env)) {
|
for (const [key, originalValue] of Object.entries(newObj.env)) {
|
||||||
let processedValue = extractEnvVariable(value);
|
processedEnv[key] = processSingleValue({ originalValue, customUserVars, user });
|
||||||
processedValue = processUserPlaceholders(processedValue, user);
|
|
||||||
processedEnv[key] = processedValue;
|
|
||||||
}
|
}
|
||||||
newObj.env = processedEnv;
|
newObj.env = processedEnv;
|
||||||
} else if ('headers' in newObj && newObj.headers) {
|
}
|
||||||
const processedHeaders: Record<string, string> = {};
|
|
||||||
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);
|
// Process headers if they exist (for WebSocket, SSE, StreamableHTTP types)
|
||||||
processedValue = processUserPlaceholders(processedValue, user);
|
// Note: `env` and `headers` are on different branches of the MCPOptions union type.
|
||||||
processedHeaders[key] = processedValue;
|
if ('headers' in newObj && newObj.headers) {
|
||||||
|
const processedHeaders: Record<string, string> = {};
|
||||||
|
for (const [key, originalValue] of Object.entries(newObj.headers)) {
|
||||||
|
processedHeaders[key] = processSingleValue({ originalValue, customUserVars, user });
|
||||||
}
|
}
|
||||||
newObj.headers = processedHeaders;
|
newObj.headers = processedHeaders;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Process URL if it exists (for WebSocket, SSE, StreamableHTTP types)
|
||||||
if ('url' in newObj && newObj.url) {
|
if ('url' in newObj && newObj.url) {
|
||||||
let processedUrl = extractEnvVariable(newObj.url);
|
newObj.url = processSingleValue({ originalValue: newObj.url, customUserVars, user });
|
||||||
processedUrl = processUserPlaceholders(processedUrl, user);
|
|
||||||
newObj.url = processedUrl;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return newObj;
|
return newObj;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import { createRoleMethods, type RoleMethods } from './role';
|
||||||
/* Memories */
|
/* Memories */
|
||||||
import { createMemoryMethods, type MemoryMethods } from './memory';
|
import { createMemoryMethods, type MemoryMethods } from './memory';
|
||||||
import { createShareMethods, type ShareMethods } from './share';
|
import { createShareMethods, type ShareMethods } from './share';
|
||||||
|
import { createPluginAuthMethods, type PluginAuthMethods } from './pluginAuth';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates all database methods for all collections
|
* Creates all database methods for all collections
|
||||||
|
@ -17,13 +18,15 @@ export function createMethods(mongoose: typeof import('mongoose')) {
|
||||||
...createRoleMethods(mongoose),
|
...createRoleMethods(mongoose),
|
||||||
...createMemoryMethods(mongoose),
|
...createMemoryMethods(mongoose),
|
||||||
...createShareMethods(mongoose),
|
...createShareMethods(mongoose),
|
||||||
|
...createPluginAuthMethods(mongoose),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { MemoryMethods, ShareMethods, TokenMethods };
|
export type { MemoryMethods, ShareMethods, TokenMethods, PluginAuthMethods };
|
||||||
export type AllMethods = UserMethods &
|
export type AllMethods = UserMethods &
|
||||||
SessionMethods &
|
SessionMethods &
|
||||||
TokenMethods &
|
TokenMethods &
|
||||||
RoleMethods &
|
RoleMethods &
|
||||||
MemoryMethods &
|
MemoryMethods &
|
||||||
ShareMethods;
|
ShareMethods &
|
||||||
|
PluginAuthMethods;
|
||||||
|
|
140
packages/data-schemas/src/methods/pluginAuth.ts
Normal file
140
packages/data-schemas/src/methods/pluginAuth.ts
Normal file
|
@ -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<IPluginAuth> = mongoose.models.PluginAuth;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Finds a single plugin auth entry by userId and authField
|
||||||
|
*/
|
||||||
|
async function findOnePluginAuth({
|
||||||
|
userId,
|
||||||
|
authField,
|
||||||
|
}: FindPluginAuthParams): Promise<IPluginAuth | null> {
|
||||||
|
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<IPluginAuth[]> {
|
||||||
|
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<IPluginAuth> {
|
||||||
|
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<DeleteResult> {
|
||||||
|
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<DeleteResult> {
|
||||||
|
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<typeof createPluginAuthMethods>;
|
|
@ -1,13 +1,5 @@
|
||||||
import { Schema, Document } from 'mongoose';
|
import { Schema } from 'mongoose';
|
||||||
|
import type { IPluginAuth } from '~/types';
|
||||||
export interface IPluginAuth extends Document {
|
|
||||||
authField: string;
|
|
||||||
value: string;
|
|
||||||
userId: string;
|
|
||||||
pluginKey?: string;
|
|
||||||
createdAt?: Date;
|
|
||||||
updatedAt?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
const pluginAuthSchema: Schema<IPluginAuth> = new Schema(
|
const pluginAuthSchema: Schema<IPluginAuth> = new Schema(
|
||||||
{
|
{
|
||||||
|
|
|
@ -14,5 +14,6 @@ export * from './action';
|
||||||
export * from './assistant';
|
export * from './assistant';
|
||||||
export * from './file';
|
export * from './file';
|
||||||
export * from './share';
|
export * from './share';
|
||||||
|
export * from './pluginAuth';
|
||||||
/* Memories */
|
/* Memories */
|
||||||
export * from './memory';
|
export * from './memory';
|
||||||
|
|
40
packages/data-schemas/src/types/pluginAuth.ts
Normal file
40
packages/data-schemas/src/types/pluginAuth.ts
Normal file
|
@ -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;
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue