Skip to content

Commit 22a6421

Browse files
๐Ÿ—๏ธ feat: User Provided Credentials for MCP Servers (danny-avila#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 <[email protected]>
1 parent 9f6bef2 commit 22a6421

File tree

36 files changed

+1537
-167
lines changed

36 files changed

+1537
-167
lines changed

โ€Ž.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ bower_components/
5555
# AI
5656
.clineignore
5757
.cursor
58+
.aider*
5859

5960
# Floobits
6061
.floo

โ€Žapi/app/clients/tools/util/handleTools.js

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,14 @@
1+
const { mcpToolPattern } = require('@librechat/api');
12
const { logger } = require('@librechat/data-schemas');
23
const { SerpAPI } = require('@langchain/community/tools/serpapi');
34
const { Calculator } = require('@langchain/community/tools/calculator');
45
const { EnvVar, createCodeExecutionTool, createSearchTool } = require('@librechat/agents');
56
const {
67
Tools,
7-
Constants,
88
EToolResources,
99
loadWebSearchAuth,
1010
replaceSpecialVars,
1111
} = require('librechat-data-provider');
12-
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
1312
const {
1413
availableTools,
1514
manifestToolMap,
@@ -29,12 +28,11 @@ const {
2928
} = require('../');
3029
const { primeFiles: primeCodeFiles } = require('~/server/services/Files/Code/process');
3130
const { createFileSearchTool, primeFiles: primeSearchFiles } = require('./fileSearch');
31+
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
3232
const { loadAuthValues } = require('~/server/services/Tools/credentials');
3333
const { getCachedTools } = require('~/server/services/Config');
3434
const { createMCPTool } = require('~/server/services/MCP');
3535

36-
const mcpToolPattern = new RegExp(`^.+${Constants.mcp_delimiter}.+$`);
37-
3836
/**
3937
* Validates the availability and authentication of tools for a user based on environment variables or user-specific plugin authentication values.
4038
* Tools without required authentication or with valid authentication are considered valid.
@@ -94,7 +92,7 @@ const validateTools = async (user, tools = []) => {
9492
return Array.from(validToolsSet.values());
9593
} catch (err) {
9694
logger.error('[validateTools] There was a problem validating tools', err);
97-
throw new Error('There was a problem validating tools');
95+
throw new Error(err);
9896
}
9997
};
10098

โ€Žapi/server/controllers/PluginController.js

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ const { getToolkitKey } = require('~/server/services/ToolService');
55
const { getMCPManager, getFlowStateManager } = require('~/config');
66
const { availableTools } = require('~/app/clients/tools');
77
const { getLogStores } = require('~/cache');
8+
const { Constants } = require('librechat-data-provider');
89

910
/**
1011
* Filters out duplicate plugins from the list of plugins.
@@ -173,16 +174,56 @@ const getAvailableTools = async (req, res) => {
173174
});
174175

175176
const toolDefinitions = await getCachedTools({ includeGlobal: true });
176-
const tools = authenticatedPlugins.filter(
177-
(plugin) =>
178-
toolDefinitions[plugin.pluginKey] !== undefined ||
179-
(plugin.toolkit === true &&
180-
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey)),
181-
);
182-
183-
await cache.set(CacheKeys.TOOLS, tools);
184-
res.status(200).json(tools);
177+
178+
const toolsOutput = [];
179+
for (const plugin of authenticatedPlugins) {
180+
const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined;
181+
const isToolkit =
182+
plugin.toolkit === true &&
183+
Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey);
184+
185+
if (!isToolDefined && !isToolkit) {
186+
continue;
187+
}
188+
189+
const toolToAdd = { ...plugin };
190+
191+
if (!plugin.pluginKey.includes(Constants.mcp_delimiter)) {
192+
toolsOutput.push(toolToAdd);
193+
continue;
194+
}
195+
196+
const parts = plugin.pluginKey.split(Constants.mcp_delimiter);
197+
const serverName = parts[parts.length - 1];
198+
const serverConfig = customConfig?.mcpServers?.[serverName];
199+
200+
if (!serverConfig?.customUserVars) {
201+
toolsOutput.push(toolToAdd);
202+
continue;
203+
}
204+
205+
const customVarKeys = Object.keys(serverConfig.customUserVars);
206+
207+
if (customVarKeys.length === 0) {
208+
toolToAdd.authConfig = [];
209+
toolToAdd.authenticated = true;
210+
} else {
211+
toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
212+
authField: key,
213+
label: value.title || key,
214+
description: value.description || '',
215+
}));
216+
toolToAdd.authenticated = false;
217+
}
218+
219+
toolsOutput.push(toolToAdd);
220+
}
221+
222+
const finalTools = filterUniquePlugins(toolsOutput);
223+
await cache.set(CacheKeys.TOOLS, finalTools);
224+
res.status(200).json(finalTools);
185225
} catch (error) {
226+
logger.error('[getAvailableTools]', error);
186227
res.status(500).json({ message: error.message });
187228
}
188229
};

โ€Žapi/server/controllers/UserController.js

Lines changed: 56 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
const {
22
Tools,
3+
Constants,
34
FileSources,
45
webSearchKeys,
56
extractWebSearchEnvVars,
@@ -23,6 +24,7 @@ const { processDeleteRequest } = require('~/server/services/Files/process');
2324
const { Transaction, Balance, User } = require('~/db/models');
2425
const { deleteToolCalls } = require('~/models/ToolCall');
2526
const { deleteAllSharedLinks } = require('~/models');
27+
const { getMCPManager } = require('~/config');
2628

2729
const getUserController = async (req, res) => {
2830
/** @type {MongoUser} */
@@ -102,10 +104,22 @@ const updateUserPluginsController = async (req, res) => {
102104
}
103105

104106
let keys = Object.keys(auth);
105-
if (keys.length === 0 && pluginKey !== Tools.web_search) {
107+
const values = Object.values(auth); // Used in 'install' block
108+
109+
const isMCPTool = pluginKey.startsWith('mcp_') || pluginKey.includes(Constants.mcp_delimiter);
110+
111+
// Early exit condition:
112+
// If keys are empty (meaning auth: {} was likely sent for uninstall, or auth was empty for install)
113+
// AND it's not web_search (which has special key handling to populate `keys` for uninstall)
114+
// AND it's NOT (an uninstall action FOR an MCP tool - we need to proceed for this case to clear all its auth)
115+
// THEN return.
116+
if (
117+
keys.length === 0 &&
118+
pluginKey !== Tools.web_search &&
119+
!(action === 'uninstall' && isMCPTool)
120+
) {
106121
return res.status(200).send();
107122
}
108-
const values = Object.values(auth);
109123

110124
/** @type {number} */
111125
let status = 200;
@@ -132,16 +146,53 @@ const updateUserPluginsController = async (req, res) => {
132146
}
133147
}
134148
} else if (action === 'uninstall') {
135-
for (let i = 0; i < keys.length; i++) {
136-
authService = await deleteUserPluginAuth(user.id, keys[i]);
149+
// const isMCPTool was defined earlier
150+
if (isMCPTool && keys.length === 0) {
151+
// This handles the case where auth: {} is sent for an MCP tool uninstall.
152+
// It means "delete all credentials associated with this MCP pluginKey".
153+
authService = await deleteUserPluginAuth(user.id, null, true, pluginKey);
137154
if (authService instanceof Error) {
138-
logger.error('[authService]', authService);
155+
logger.error(
156+
`[authService] Error deleting all auth for MCP tool ${pluginKey}:`,
157+
authService,
158+
);
139159
({ status, message } = authService);
140160
}
161+
} else {
162+
// This handles:
163+
// 1. Web_search uninstall (keys will be populated with all webSearchKeys if auth was {}).
164+
// 2. Other tools uninstall (if keys were provided).
165+
// 3. MCP tool uninstall if specific keys were provided in `auth` (not current frontend behavior).
166+
// If keys is empty for non-MCP tools (and not web_search), this loop won't run, and nothing is deleted.
167+
for (let i = 0; i < keys.length; i++) {
168+
authService = await deleteUserPluginAuth(user.id, keys[i]); // Deletes by authField name
169+
if (authService instanceof Error) {
170+
logger.error('[authService] Error deleting specific auth key:', authService);
171+
({ status, message } = authService);
172+
}
173+
}
141174
}
142175
}
143176

144177
if (status === 200) {
178+
// If auth was updated successfully, disconnect MCP sessions as they might use these credentials
179+
if (pluginKey.startsWith(Constants.mcp_prefix)) {
180+
try {
181+
const mcpManager = getMCPManager(user.id);
182+
if (mcpManager) {
183+
logger.info(
184+
`[updateUserPluginsController] Disconnecting MCP connections for user ${user.id} after plugin auth update for ${pluginKey}.`,
185+
);
186+
await mcpManager.disconnectUserConnections(user.id);
187+
}
188+
} catch (disconnectError) {
189+
logger.error(
190+
`[updateUserPluginsController] Error disconnecting MCP connections for user ${user.id} after plugin auth update:`,
191+
disconnectError,
192+
);
193+
// Do not fail the request for this, but log it.
194+
}
195+
}
145196
return res.status(status).send();
146197
}
147198

โ€Žapi/server/controllers/agents/client.js

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,15 @@ const {
3131
} = require('librechat-data-provider');
3232
const { DynamicStructuredTool } = require('@langchain/core/tools');
3333
const { getBufferString, HumanMessage } = require('@langchain/core/messages');
34-
const { getCustomEndpointConfig, checkCapability } = require('~/server/services/Config');
34+
const {
35+
getCustomEndpointConfig,
36+
createGetMCPAuthMap,
37+
checkCapability,
38+
} = require('~/server/services/Config');
3539
const { addCacheControl, createContextHandlers } = require('~/app/clients/prompts');
3640
const { initializeAgent } = require('~/server/services/Endpoints/agents/agent');
3741
const { spendTokens, spendStructuredTokens } = require('~/models/spendTokens');
38-
const { setMemory, deleteMemory, getFormattedMemories } = require('~/models');
42+
const { getFormattedMemories, deleteMemory, setMemory } = require('~/models');
3943
const { encodeAndFormat } = require('~/server/services/Files/images/encode');
4044
const initOpenAI = require('~/server/services/Endpoints/openAI/initialize');
4145
const { checkAccess } = require('~/server/middleware/roles/access');
@@ -679,6 +683,8 @@ class AgentClient extends BaseClient {
679683
version: 'v2',
680684
};
681685

686+
const getUserMCPAuthMap = await createGetMCPAuthMap();
687+
682688
const toolSet = new Set((this.options.agent.tools ?? []).map((tool) => tool && tool.name));
683689
let { messages: initialMessages, indexTokenCountMap } = formatAgentMessages(
684690
payload,
@@ -798,6 +804,20 @@ class AgentClient extends BaseClient {
798804
run.Graph.contentData = contentData;
799805
}
800806

807+
try {
808+
if (getUserMCPAuthMap) {
809+
config.configurable.userMCPAuthMap = await getUserMCPAuthMap({
810+
tools: agent.tools,
811+
userId: this.options.req.user.id,
812+
});
813+
}
814+
} catch (err) {
815+
logger.error(
816+
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent ${agent.id}`,
817+
err,
818+
);
819+
}
820+
801821
await run.processStream({ messages }, config, {
802822
keepContent: i !== 0,
803823
tokenCounter: createTokenCounter(this.getEncoding()),

โ€Žapi/server/routes/config.js

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
const express = require('express');
2+
const { logger } = require('@librechat/data-schemas');
23
const { CacheKeys, defaultSocialLogins, Constants } = require('librechat-data-provider');
4+
const { getCustomConfig } = require('~/server/services/Config/getCustomConfig');
35
const { getLdapConfig } = require('~/server/services/Config/ldap');
46
const { getProjectByName } = require('~/models/Project');
57
const { isEnabled } = require('~/server/utils');
68
const { getLogStores } = require('~/cache');
7-
const { logger } = require('~/config');
89

910
const router = express.Router();
1011
const emailLoginEnabled =
@@ -21,12 +22,15 @@ const publicSharedLinksEnabled =
2122

2223
router.get('/', async function (req, res) {
2324
const cache = getLogStores(CacheKeys.CONFIG_STORE);
25+
2426
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
2527
if (cachedStartupConfig) {
2628
res.send(cachedStartupConfig);
2729
return;
2830
}
2931

32+
const config = await getCustomConfig();
33+
3034
const isBirthday = () => {
3135
const today = new Date();
3236
return today.getMonth() === 1 && today.getDate() === 11;
@@ -96,6 +100,17 @@ router.get('/', async function (req, res) {
96100
bundlerURL: process.env.SANDPACK_BUNDLER_URL,
97101
staticBundlerURL: process.env.SANDPACK_STATIC_BUNDLER_URL,
98102
};
103+
104+
payload.mcpServers = {};
105+
if (config.mcpServers) {
106+
for (const serverName in config.mcpServers) {
107+
const serverConfig = config.mcpServers[serverName];
108+
payload.mcpServers[serverName] = {
109+
customUserVars: serverConfig?.customUserVars || {},
110+
};
111+
}
112+
}
113+
99114
/** @type {TCustomConfig['webSearch']} */
100115
const webSearchConfig = req.app.locals.webSearch;
101116
if (

โ€Žapi/server/services/Config/getCustomConfig.js

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
1+
const { logger } = require('@librechat/data-schemas');
2+
const { getUserMCPAuthMap } = require('@librechat/api');
13
const { CacheKeys, EModelEndpoint } = require('librechat-data-provider');
24
const { normalizeEndpointName, isEnabled } = require('~/server/utils');
35
const loadCustomConfig = require('./loadCustomConfig');
6+
const { getCachedTools } = require('./getCachedTools');
7+
const { findPluginAuthsByKeys } = require('~/models');
48
const getLogStores = require('~/cache/getLogStores');
59

610
/**
@@ -50,4 +54,46 @@ const getCustomEndpointConfig = async (endpoint) => {
5054
);
5155
};
5256

53-
module.exports = { getCustomConfig, getBalanceConfig, getCustomEndpointConfig };
57+
async function createGetMCPAuthMap() {
58+
const customConfig = await getCustomConfig();
59+
const mcpServers = customConfig?.mcpServers;
60+
const hasCustomUserVars = Object.values(mcpServers).some((server) => server.customUserVars);
61+
if (!hasCustomUserVars) {
62+
return;
63+
}
64+
65+
/**
66+
* @param {Object} params
67+
* @param {GenericTool[]} [params.tools]
68+
* @param {string} params.userId
69+
* @returns {Promise<Record<string, Record<string, string>> | undefined>}
70+
*/
71+
return async function ({ tools, userId }) {
72+
try {
73+
if (!tools || tools.length === 0) {
74+
return;
75+
}
76+
const appTools = await getCachedTools({
77+
userId,
78+
});
79+
return await getUserMCPAuthMap({
80+
tools,
81+
userId,
82+
appTools,
83+
findPluginAuthsByKeys,
84+
});
85+
} catch (err) {
86+
logger.error(
87+
`[api/server/controllers/agents/client.js #chatCompletion] Error getting custom user vars for agent`,
88+
err,
89+
);
90+
}
91+
};
92+
}
93+
94+
module.exports = {
95+
getCustomConfig,
96+
getBalanceConfig,
97+
createGetMCPAuthMap,
98+
getCustomEndpointConfig,
99+
};

โ€Žapi/server/services/MCP.js

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -168,15 +168,19 @@ async function createMCPTool({ req, res, toolKey, provider: _provider }) {
168168
derivedSignal.addEventListener('abort', abortHandler, { once: true });
169169
}
170170

171+
const customUserVars =
172+
config?.configurable?.userMCPAuthMap?.[`${Constants.mcp_prefix}${serverName}`];
173+
171174
const result = await mcpManager.callTool({
172175
serverName,
173176
toolName,
174177
provider,
175178
toolArguments,
176179
options: {
177180
signal: derivedSignal,
178-
user: config?.configurable?.user,
179181
},
182+
user: config?.configurable?.user,
183+
customUserVars,
180184
flowManager,
181185
tokenMethods: {
182186
findToken,

0 commit comments

Comments
ย (0)