feat: Add MCP Reinitialization to MCPPanel (#8418)

*  feat: Add MCP Reinitialization to MCPPanel

- Refactored tool caching to include user-specific tools in various service files.
- Refactored MCPManager class for clarity
- Added a new endpoint for reinitializing MCP servers, allowing for dynamic updates of server configurations.
- Enhanced the MCPPanel component to support server reinitialization with user feedback.

* 🔃 refactor: Simplify Plugin Deduplication and Clear Cache Post-MCP Initialization

- Replaced manual deduplication of tools with the dedicated `filterUniquePlugins` function for improved readability.
- Added back cache clearing for tools after MCP initialization to ensure fresh data is used.
- Removed unused exports from `PluginController.js` to clean up the codebase.
This commit is contained in:
Dustin Healy 2025-07-21 14:49:19 -07:00 committed by GitHub
parent 14660d75ae
commit faaba30af1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
14 changed files with 499 additions and 193 deletions

View file

@ -230,7 +230,7 @@ const loadTools = async ({
/** @type {Record<string, string>} */ /** @type {Record<string, string>} */
const toolContextMap = {}; const toolContextMap = {};
const appTools = (await getCachedTools({ includeGlobal: true })) ?? {}; const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {};
for (const tool of tools) { for (const tool of tools) {
if (tool === Tools.execute_code) { if (tool === Tools.execute_code) {
@ -298,7 +298,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })}
}); });
}; };
continue; continue;
} else if (tool && appTools[tool] && mcpToolPattern.test(tool)) { } else if (tool && cachedTools && mcpToolPattern.test(tool)) {
requestedTools[tool] = async () => requestedTools[tool] = async () =>
createMCPTool({ createMCPTool({
req: options.req, req: options.req,

View file

@ -61,7 +61,7 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter)
const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => { const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => {
const { model, ...model_parameters } = _m; const { model, ...model_parameters } = _m;
/** @type {Record<string, FunctionTool>} */ /** @type {Record<string, FunctionTool>} */
const availableTools = await getCachedTools({ includeGlobal: true }); const availableTools = await getCachedTools({ userId: req.user.id, includeGlobal: true });
/** @type {TEphemeralAgent | null} */ /** @type {TEphemeralAgent | null} */
const ephemeralAgent = req.body.ephemeralAgent; const ephemeralAgent = req.body.ephemeralAgent;
const mcpServers = new Set(ephemeralAgent?.mcp); const mcpServers = new Set(ephemeralAgent?.mcp);

View file

@ -138,15 +138,21 @@ function createGetServerTools() {
*/ */
const getAvailableTools = async (req, res) => { const getAvailableTools = async (req, res) => {
try { try {
const userId = req.user?.id;
const customConfig = await getCustomConfig();
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedToolsArray = await cache.get(CacheKeys.TOOLS); const cachedToolsArray = await cache.get(CacheKeys.TOOLS);
if (cachedToolsArray) { const cachedUserTools = await getCachedTools({ userId });
res.status(200).json(cachedToolsArray); const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig);
if (cachedToolsArray && userPlugins) {
const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
res.status(200).json(dedupedTools);
return; return;
} }
// If not in cache, build from manifest
let pluginManifest = availableTools; let pluginManifest = availableTools;
const customConfig = await getCustomConfig();
if (customConfig?.mcpServers != null) { if (customConfig?.mcpServers != null) {
const mcpManager = getMCPManager(); const mcpManager = getMCPManager();
const flowsCache = getLogStores(CacheKeys.FLOWS); const flowsCache = getLogStores(CacheKeys.FLOWS);
@ -217,16 +223,69 @@ const getAvailableTools = async (req, res) => {
toolsOutput.push(toolToAdd); toolsOutput.push(toolToAdd);
} }
const finalTools = filterUniquePlugins(toolsOutput); const finalTools = filterUniquePlugins(toolsOutput);
await cache.set(CacheKeys.TOOLS, finalTools); await cache.set(CacheKeys.TOOLS, finalTools);
res.status(200).json(finalTools);
const dedupedTools = filterUniquePlugins([...userPlugins, ...finalTools]);
res.status(200).json(dedupedTools);
} catch (error) { } catch (error) {
logger.error('[getAvailableTools]', error); logger.error('[getAvailableTools]', error);
res.status(500).json({ message: error.message }); res.status(500).json({ message: error.message });
} }
}; };
/**
* Converts MCP function format tools to plugin format
* @param {Object} functionTools - Object with function format tools
* @param {Object} customConfig - Custom configuration for MCP servers
* @returns {Array} Array of plugin objects
*/
function convertMCPToolsToPlugins(functionTools, customConfig) {
const plugins = [];
for (const [toolKey, toolData] of Object.entries(functionTools)) {
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
continue;
}
const functionData = toolData.function;
const parts = toolKey.split(Constants.mcp_delimiter);
const serverName = parts[parts.length - 1];
const plugin = {
name: parts[0], // Use the tool name without server suffix
pluginKey: toolKey,
description: functionData.description || '',
authenticated: true,
icon: undefined,
};
// Build authConfig for MCP tools
const serverConfig = customConfig?.mcpServers?.[serverName];
if (!serverConfig?.customUserVars) {
plugin.authConfig = [];
plugins.push(plugin);
continue;
}
const customVarKeys = Object.keys(serverConfig.customUserVars);
if (customVarKeys.length === 0) {
plugin.authConfig = [];
} else {
plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({
authField: key,
label: value.title || key,
description: value.description || '',
}));
}
plugins.push(plugin);
}
return plugins;
}
module.exports = { module.exports = {
getAvailableTools, getAvailableTools,
getAvailablePluginsController, getAvailablePluginsController,

View file

@ -16,7 +16,7 @@ const { connectDb, indexSync } = require('~/db');
const validateImageRequest = require('./middleware/validateImageRequest'); const validateImageRequest = require('./middleware/validateImageRequest');
const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies');
const errorController = require('./controllers/ErrorController'); const errorController = require('./controllers/ErrorController');
const initializeMCP = require('./services/initializeMCP'); const initializeMCPs = require('./services/initializeMCPs');
const configureSocialLogins = require('./socialLogins'); const configureSocialLogins = require('./socialLogins');
const AppService = require('./services/AppService'); const AppService = require('./services/AppService');
const staticCache = require('./utils/staticCache'); const staticCache = require('./utils/staticCache');
@ -146,7 +146,7 @@ const startServer = async () => {
logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`); logger.info(`Server listening at http://${host == '0.0.0.0' ? 'localhost' : host}:${port}`);
} }
initializeMCP(app); initializeMCPs(app);
}); });
}; };

View file

@ -1,9 +1,12 @@
const { Router } = require('express'); const { Router } = require('express');
const { MCPOAuthHandler } = require('@librechat/api');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { CacheKeys } = require('librechat-data-provider'); const { MCPOAuthHandler } = require('@librechat/api');
const { CacheKeys, Constants } = require('librechat-data-provider');
const { findToken, updateToken, createToken, deleteTokens } = require('~/models');
const { setCachedTools, getCachedTools, loadCustomConfig } = require('~/server/services/Config');
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { getMCPManager, getFlowStateManager } = require('~/config');
const { requireJwtAuth } = require('~/server/middleware'); const { requireJwtAuth } = require('~/server/middleware');
const { getFlowStateManager } = require('~/config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
const router = Router(); const router = Router();
@ -202,4 +205,106 @@ router.get('/oauth/status/:flowId', async (req, res) => {
} }
}); });
/**
* Reinitialize MCP server
* This endpoint allows reinitializing a specific MCP server
*/
router.post('/:serverName/reinitialize', requireJwtAuth, async (req, res) => {
try {
const { serverName } = req.params;
const user = req.user;
if (!user?.id) {
return res.status(401).json({ error: 'User not authenticated' });
}
logger.info(`[MCP Reinitialize] Reinitializing server: ${serverName}`);
const config = await loadCustomConfig();
if (!config || !config.mcpServers || !config.mcpServers[serverName]) {
return res.status(404).json({
error: `MCP server '${serverName}' not found in configuration`,
});
}
const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = getFlowStateManager(flowsCache);
const mcpManager = getMCPManager();
await mcpManager.disconnectServer(serverName);
logger.info(`[MCP Reinitialize] Disconnected existing server: ${serverName}`);
const serverConfig = config.mcpServers[serverName];
mcpManager.mcpConfigs[serverName] = serverConfig;
let customUserVars = {};
if (serverConfig.customUserVars && typeof serverConfig.customUserVars === 'object') {
for (const varName of Object.keys(serverConfig.customUserVars)) {
try {
const value = await getUserPluginAuthValue(user.id, varName, false);
if (value) {
customUserVars[varName] = value;
}
} catch (err) {
logger.error(`[MCP Reinitialize] Error fetching ${varName} for user ${user.id}:`, err);
}
}
}
let userConnection = null;
try {
userConnection = await mcpManager.getUserConnection({
user,
serverName,
flowManager,
customUserVars,
tokenMethods: {
findToken,
updateToken,
createToken,
deleteTokens,
},
});
} catch (err) {
logger.error(`[MCP Reinitialize] Error initializing MCP server ${serverName} for user:`, err);
return res.status(500).json({ error: 'Failed to reinitialize MCP server for user' });
}
const userTools = (await getCachedTools({ userId: user.id })) || {};
// Remove any old tools from this server in the user's cache
const mcpDelimiter = Constants.mcp_delimiter;
for (const key of Object.keys(userTools)) {
if (key.endsWith(`${mcpDelimiter}${serverName}`)) {
delete userTools[key];
}
}
// Add the new tools from this server
const tools = await userConnection.fetchTools();
for (const tool of tools) {
const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`;
userTools[name] = {
type: 'function',
['function']: {
name,
description: tool.description,
parameters: tool.inputSchema,
},
};
}
// Save the updated user tool cache
await setCachedTools(userTools, { userId: user.id });
res.json({
success: true,
message: `MCP server '${serverName}' reinitialized successfully`,
serverName,
});
} catch (error) {
logger.error('[MCP Reinitialize] Unexpected error', error);
res.status(500).json({ error: 'Internal server error' });
}
});
module.exports = router; module.exports = router;

View file

@ -104,7 +104,7 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) {
* @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input. * @returns { Promise<typeof tool | { _call: (toolInput: Object | string) => unknown}> } An object with `_call` method to execute the tool input.
*/ */
async function createMCPTool({ req, res, toolKey, provider: _provider }) { async function createMCPTool({ req, res, toolKey, provider: _provider }) {
const availableTools = await getCachedTools({ includeGlobal: true }); const availableTools = await getCachedTools({ userId: req.user?.id, includeGlobal: true });
const toolDefinition = availableTools?.[toolKey]?.function; const toolDefinition = availableTools?.[toolKey]?.function;
if (!toolDefinition) { if (!toolDefinition) {
logger.error(`Tool ${toolKey} not found in available tools`); logger.error(`Tool ${toolKey} not found in available tools`);

View file

@ -226,7 +226,7 @@ async function processRequiredActions(client, requiredActions) {
`[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`, `[required actions] user: ${client.req.user.id} | thread_id: ${requiredActions[0].thread_id} | run_id: ${requiredActions[0].run_id}`,
requiredActions, requiredActions,
); );
const toolDefinitions = await getCachedTools({ includeGlobal: true }); const toolDefinitions = await getCachedTools({ userId: client.req.user.id, includeGlobal: true });
const seenToolkits = new Set(); const seenToolkits = new Set();
const tools = requiredActions const tools = requiredActions
.map((action) => { .map((action) => {

View file

@ -9,20 +9,35 @@ const { getLogStores } = require('~/cache');
* Initialize MCP servers * Initialize MCP servers
* @param {import('express').Application} app - Express app instance * @param {import('express').Application} app - Express app instance
*/ */
async function initializeMCP(app) { async function initializeMCPs(app) {
const mcpServers = app.locals.mcpConfig; const mcpServers = app.locals.mcpConfig;
if (!mcpServers) { if (!mcpServers) {
return; return;
} }
// Filter out servers with startup: false
const filteredServers = {};
for (const [name, config] of Object.entries(mcpServers)) {
if (config.startup === false) {
logger.info(`Skipping MCP server '${name}' due to startup: false`);
continue;
}
filteredServers[name] = config;
}
if (Object.keys(filteredServers).length === 0) {
logger.info('[MCP] No MCP servers to initialize (all skipped or none configured)');
return;
}
logger.info('Initializing MCP servers...'); logger.info('Initializing MCP servers...');
const mcpManager = getMCPManager(); const mcpManager = getMCPManager();
const flowsCache = getLogStores(CacheKeys.FLOWS); const flowsCache = getLogStores(CacheKeys.FLOWS);
const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null; const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null;
try { try {
await mcpManager.initializeMCP({ await mcpManager.initializeMCPs({
mcpServers, mcpServers: filteredServers,
flowManager, flowManager,
tokenMethods: { tokenMethods: {
findToken, findToken,
@ -47,10 +62,11 @@ async function initializeMCP(app) {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
await cache.delete(CacheKeys.TOOLS); await cache.delete(CacheKeys.TOOLS);
logger.debug('Cleared tools array cache after MCP initialization'); logger.debug('Cleared tools array cache after MCP initialization');
logger.info('MCP servers initialized successfully'); logger.info('MCP servers initialized successfully');
} catch (error) { } catch (error) {
logger.error('Failed to initialize MCP servers:', error); logger.error('Failed to initialize MCP servers:', error);
} }
} }
module.exports = initializeMCP; module.exports = initializeMCPs;

View file

@ -1,8 +1,11 @@
import React, { useState, useCallback, useMemo, useEffect } from 'react';
import { ChevronLeft } from 'lucide-react';
import { Constants } from 'librechat-data-provider'; import { Constants } from 'librechat-data-provider';
import { ChevronLeft, RefreshCw } from 'lucide-react';
import { useForm, Controller } from 'react-hook-form'; import { useForm, Controller } from 'react-hook-form';
import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import React, { useState, useCallback, useMemo, useEffect } from 'react';
import {
useUpdateUserPluginsMutation,
useReinitializeMCPServerMutation,
} from 'librechat-data-provider/react-query';
import type { TUpdateUserPlugins } from 'librechat-data-provider'; import type { TUpdateUserPlugins } from 'librechat-data-provider';
import { Button, Input, Label } from '~/components/ui'; import { Button, Input, Label } from '~/components/ui';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig } from '~/data-provider';
@ -24,6 +27,8 @@ export default function MCPPanel() {
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>( const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
null, null,
); );
const [rotatingServers, setRotatingServers] = useState<Set<string>>(new Set());
const reinitializeMCPMutation = useReinitializeMCPServerMutation();
const mcpServerDefinitions = useMemo(() => { const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) { if (!startupConfig?.mcpServers) {
@ -89,6 +94,32 @@ export default function MCPPanel() {
setSelectedServerNameForEditing(null); setSelectedServerNameForEditing(null);
}; };
const handleReinitializeServer = useCallback(
async (serverName: string) => {
setRotatingServers((prev) => new Set(prev).add(serverName));
try {
await reinitializeMCPMutation.mutateAsync(serverName);
showToast({
message: `MCP server '${serverName}' reinitialized successfully`,
status: 'success',
});
} catch (error) {
console.error('Error reinitializing MCP server:', error);
showToast({
message: 'Failed to reinitialize MCP server',
status: 'error',
});
} finally {
setRotatingServers((prev) => {
const next = new Set(prev);
next.delete(serverName);
return next;
});
}
},
[showToast, reinitializeMCPMutation],
);
if (startupConfigLoading) { if (startupConfigLoading) {
return <MCPPanelSkeleton />; return <MCPPanelSkeleton />;
} }
@ -144,14 +175,27 @@ export default function MCPPanel() {
<div className="h-auto max-w-full overflow-x-hidden p-3"> <div className="h-auto max-w-full overflow-x-hidden p-3">
<div className="space-y-2"> <div className="space-y-2">
{mcpServerDefinitions.map((server) => ( {mcpServerDefinitions.map((server) => (
<Button <div key={server.serverName} className="flex items-center gap-2">
key={server.serverName} <Button
variant="outline" variant="outline"
className="w-full justify-start dark:hover:bg-gray-700" className="flex-1 justify-start dark:hover:bg-gray-700"
onClick={() => handleServerClickToEdit(server.serverName)} onClick={() => handleServerClickToEdit(server.serverName)}
> >
{server.serverName} {server.serverName}
</Button> </Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleReinitializeServer(server.serverName)}
className="px-2 py-1"
title="Reinitialize MCP server"
disabled={reinitializeMCPMutation.isLoading}
>
<RefreshCw
className={`h-4 w-4 ${rotatingServers.has(server.serverName) ? 'animate-spin' : ''}`}
/>
</Button>
</div>
))} ))}
</div> </div>
</div> </div>

View file

@ -39,7 +39,7 @@ export class MCPManager {
} }
/** Stores configs and initializes app-level connections */ /** Stores configs and initializes app-level connections */
public async initializeMCP({ public async initializeMCPs({
mcpServers, mcpServers,
flowManager, flowManager,
tokenMethods, tokenMethods,
@ -60,173 +60,17 @@ export class MCPManager {
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(
entries.map(async ([serverName, _config], i) => { entries.map(async ([serverName, config], i) => {
/** Process env for app-level connections */
const config = processMCPEnv(_config);
/** Existing tokens for system-level connections */
let tokens: MCPOAuthTokens | null = null;
if (tokenMethods?.findToken) {
try {
/** Refresh function for app-level connections */
const refreshTokensFunction = async (
refreshToken: string,
metadata: {
userId: string;
serverName: string;
identifier: string;
clientInfo?: OAuthClientInformation;
},
) => {
/** URL from config if available */
const serverUrl = (config as t.SSEOptions | t.StreamableHTTPOptions).url;
return await MCPOAuthHandler.refreshOAuthTokens(
refreshToken,
{
serverName: metadata.serverName,
serverUrl,
clientInfo: metadata.clientInfo,
},
config.oauth,
);
};
/** Flow state to prevent concurrent token operations */
const tokenFlowId = `tokens:${CONSTANTS.SYSTEM_USER_ID}:${serverName}`;
tokens = await flowManager.createFlowWithHandler(
tokenFlowId,
'mcp_get_tokens',
async () => {
return await MCPTokenStorage.getTokens({
userId: CONSTANTS.SYSTEM_USER_ID,
serverName,
findToken: tokenMethods.findToken,
refreshTokens: refreshTokensFunction,
createToken: tokenMethods.createToken,
updateToken: tokenMethods.updateToken,
});
},
);
} catch {
logger.debug(`[MCP][${serverName}] No existing tokens found`);
}
}
if (tokens) {
logger.info(`[MCP][${serverName}] Loaded OAuth tokens`);
}
const connection = new MCPConnection(serverName, config, undefined, tokens);
/** Listen for OAuth requirements */
logger.info(`[MCP][${serverName}] Setting up OAuth event listener`);
connection.on('oauthRequired', async (data) => {
logger.debug(`[MCP][${serverName}] oauthRequired event received`);
const result = await this.handleOAuthRequired({
...data,
flowManager,
});
if (result?.tokens && tokenMethods?.createToken) {
try {
connection.setOAuthTokens(result.tokens);
await MCPTokenStorage.storeTokens({
userId: CONSTANTS.SYSTEM_USER_ID,
serverName,
tokens: result.tokens,
createToken: tokenMethods.createToken,
updateToken: tokenMethods.updateToken,
findToken: tokenMethods.findToken,
clientInfo: result.clientInfo,
});
logger.info(`[MCP][${serverName}] OAuth tokens saved to storage`);
} catch (error) {
logger.error(`[MCP][${serverName}] Failed to save OAuth tokens to storage`, error);
}
}
// Only emit oauthHandled if we actually got tokens (OAuth succeeded)
if (result?.tokens) {
connection.emit('oauthHandled');
} else {
// OAuth failed, emit oauthFailed to properly reject the promise
logger.warn(`[MCP][${serverName}] OAuth failed, emitting oauthFailed event`);
connection.emit('oauthFailed', new Error('OAuth authentication failed'));
}
});
try { try {
const connectTimeout = config.initTimeout ?? 30000; await this.initializeMCP({
const connectionTimeout = new Promise<void>((_, reject) => serverName,
setTimeout( config,
() => reject(new Error(`Connection timeout after ${connectTimeout}ms`)),
connectTimeout,
),
);
const connectionAttempt = this.initializeServer({
connection,
logPrefix: `[MCP][${serverName}]`,
flowManager, flowManager,
handleOAuth: false, tokenMethods,
}); });
await Promise.race([connectionAttempt, connectionTimeout]); initializedServers.add(i);
if (await connection.isConnected()) {
initializedServers.add(i);
this.connections.set(serverName, connection);
/** Unified `serverInstructions` configuration */
const configInstructions = config.serverInstructions;
if (configInstructions !== undefined) {
if (typeof configInstructions === 'string') {
this.serverInstructions.set(serverName, configInstructions);
logger.info(
`[MCP][${serverName}] Custom instructions stored for context inclusion: ${configInstructions}`,
);
} else if (configInstructions === true) {
/** Server-provided instructions */
const serverInstructions = connection.client.getInstructions();
if (serverInstructions) {
this.serverInstructions.set(serverName, serverInstructions);
logger.info(
`[MCP][${serverName}] Server instructions stored for context inclusion: ${serverInstructions}`,
);
} else {
logger.info(
`[MCP][${serverName}] serverInstructions=true but no server instructions available`,
);
}
} else {
logger.info(
`[MCP][${serverName}] Instructions explicitly disabled (serverInstructions=false)`,
);
}
} else {
logger.info(
`[MCP][${serverName}] Instructions not included (serverInstructions not configured)`,
);
}
const serverCapabilities = connection.client.getServerCapabilities();
logger.info(`[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`);
if (serverCapabilities?.tools) {
const tools = await connection.client.listTools();
if (tools.tools.length) {
logger.info(
`[MCP][${serverName}] Available tools: ${tools.tools
.map((tool) => tool.name)
.join(', ')}`,
);
}
}
}
} catch (error) { } catch (error) {
logger.error(`[MCP][${serverName}] Initialization failed`, error); logger.error(`[MCP][${serverName}] Initialization failed`, error);
throw error;
} }
}), }),
); );
@ -260,6 +104,176 @@ export class MCPManager {
} }
} }
/** Initializes a single MCP server connection (app-level) */
public async initializeMCP({
serverName,
config,
flowManager,
tokenMethods,
}: {
serverName: string;
config: t.MCPOptions;
flowManager: FlowStateManager<MCPOAuthTokens | null>;
tokenMethods?: TokenMethods;
}): Promise<void> {
const processedConfig = processMCPEnv(config);
let tokens: MCPOAuthTokens | null = null;
if (tokenMethods?.findToken) {
try {
/** Refresh function for app-level connections */
const refreshTokensFunction = async (
refreshToken: string,
metadata: {
userId: string;
serverName: string;
identifier: string;
clientInfo?: OAuthClientInformation;
},
) => {
const serverUrl = (processedConfig as t.SSEOptions | t.StreamableHTTPOptions).url;
return await MCPOAuthHandler.refreshOAuthTokens(
refreshToken,
{
serverName: metadata.serverName,
serverUrl,
clientInfo: metadata.clientInfo,
},
processedConfig.oauth,
);
};
/** Flow state to prevent concurrent token operations */
const tokenFlowId = `tokens:${CONSTANTS.SYSTEM_USER_ID}:${serverName}`;
tokens = await flowManager.createFlowWithHandler(
tokenFlowId,
'mcp_get_tokens',
async () => {
return await MCPTokenStorage.getTokens({
userId: CONSTANTS.SYSTEM_USER_ID,
serverName,
findToken: tokenMethods.findToken,
refreshTokens: refreshTokensFunction,
createToken: tokenMethods.createToken,
updateToken: tokenMethods.updateToken,
});
},
);
} catch {
logger.debug(`[MCP][${serverName}] No existing tokens found`);
}
}
if (tokens) {
logger.info(`[MCP][${serverName}] Loaded OAuth tokens`);
}
const connection = new MCPConnection(serverName, processedConfig, undefined, tokens);
logger.info(`[MCP][${serverName}] Setting up OAuth event listener`);
connection.on('oauthRequired', async (data) => {
logger.debug(`[MCP][${serverName}] oauthRequired event received`);
const result = await this.handleOAuthRequired({
...data,
flowManager,
});
if (result?.tokens && tokenMethods?.createToken) {
try {
connection.setOAuthTokens(result.tokens);
await MCPTokenStorage.storeTokens({
userId: CONSTANTS.SYSTEM_USER_ID,
serverName,
tokens: result.tokens,
createToken: tokenMethods.createToken,
updateToken: tokenMethods.updateToken,
findToken: tokenMethods.findToken,
clientInfo: result.clientInfo,
});
logger.info(`[MCP][${serverName}] OAuth tokens saved to storage`);
} catch (error) {
logger.error(`[MCP][${serverName}] Failed to save OAuth tokens to storage`, error);
}
}
// Only emit oauthHandled if we actually got tokens (OAuth succeeded)
if (result?.tokens) {
connection.emit('oauthHandled');
} else {
// OAuth failed, emit oauthFailed to properly reject the promise
logger.warn(`[MCP][${serverName}] OAuth failed, emitting oauthFailed event`);
connection.emit('oauthFailed', new Error('OAuth authentication failed'));
}
});
try {
const connectTimeout = processedConfig.initTimeout ?? 30000;
const connectionTimeout = new Promise<void>((_, reject) =>
setTimeout(
() => reject(new Error(`Connection timeout after ${connectTimeout}ms`)),
connectTimeout,
),
);
const connectionAttempt = this.initializeServer({
connection,
logPrefix: `[MCP][${serverName}]`,
flowManager,
handleOAuth: false,
});
await Promise.race([connectionAttempt, connectionTimeout]);
if (await connection.isConnected()) {
this.connections.set(serverName, connection);
/** Unified `serverInstructions` configuration */
const configInstructions = processedConfig.serverInstructions;
if (configInstructions !== undefined) {
if (typeof configInstructions === 'string') {
this.serverInstructions.set(serverName, configInstructions);
logger.info(
`[MCP][${serverName}] Custom instructions stored for context inclusion: ${configInstructions}`,
);
} else if (configInstructions === true) {
/** Server-provided instructions */
const serverInstructions = connection.client.getInstructions();
if (serverInstructions) {
this.serverInstructions.set(serverName, serverInstructions);
logger.info(
`[MCP][${serverName}] Server instructions stored for context inclusion: ${serverInstructions}`,
);
} else {
logger.info(
`[MCP][${serverName}] serverInstructions=true but no server instructions available`,
);
}
} else {
logger.info(
`[MCP][${serverName}] Instructions explicitly disabled (serverInstructions=false)`,
);
}
} else {
logger.info(
`[MCP][${serverName}] Instructions not included (serverInstructions not configured)`,
);
}
const serverCapabilities = connection.client.getServerCapabilities();
logger.info(`[MCP][${serverName}] Capabilities: ${JSON.stringify(serverCapabilities)}`);
if (serverCapabilities?.tools) {
const tools = await connection.client.listTools();
if (tools.tools.length) {
logger.info(
`[MCP][${serverName}] Available tools: ${tools.tools
.map((tool) => tool.name)
.join(', ')}`,
);
}
}
logger.info(`[MCP][${serverName}] ✓ Initialized`);
} else {
logger.info(`[MCP][${serverName}] ✗ Failed`);
}
} catch (error) {
logger.error(`[MCP][${serverName}] Initialization failed`, error);
throw error;
}
}
/** Generic server initialization logic */ /** Generic server initialization logic */
private async initializeServer({ private async initializeServer({
connection, connection,

View file

@ -708,5 +708,53 @@ describe('Environment Variable Extraction (MCP)', () => {
SYSTEM_PATH: process.env.PATH, // Actual value of PATH from the test environment SYSTEM_PATH: process.env.PATH, // Actual value of PATH from the test environment
}); });
}); });
it('should process GitHub MCP server configuration with PAT_TOKEN placeholder', () => {
const user = createTestUser({ id: 'github-user-123', email: 'user@example.com' });
const customUserVars = {
PAT_TOKEN: 'ghp_1234567890abcdef1234567890abcdef12345678', // GitHub Personal Access Token
};
// Simulate the GitHub MCP server configuration from librechat.yaml
const obj: MCPOptions = {
type: 'streamable-http',
url: 'https://api.githubcopilot.com/mcp/',
headers: {
Authorization: '{{PAT_TOKEN}}',
'Content-Type': 'application/json',
'User-Agent': 'LibreChat-MCP-Client',
},
};
const result = processMCPEnv(obj, user, customUserVars);
expect('headers' in result && result.headers).toEqual({
Authorization: 'ghp_1234567890abcdef1234567890abcdef12345678',
'Content-Type': 'application/json',
'User-Agent': 'LibreChat-MCP-Client',
});
expect('url' in result && result.url).toBe('https://api.githubcopilot.com/mcp/');
expect(result.type).toBe('streamable-http');
});
it('should handle GitHub MCP server configuration without PAT_TOKEN (placeholder remains)', () => {
const user = createTestUser({ id: 'github-user-123' });
// No customUserVars provided - PAT_TOKEN should remain as placeholder
const obj: MCPOptions = {
type: 'streamable-http',
url: 'https://api.githubcopilot.com/mcp/',
headers: {
Authorization: '{{PAT_TOKEN}}',
'Content-Type': 'application/json',
},
};
const result = processMCPEnv(obj, user);
expect('headers' in result && result.headers).toEqual({
Authorization: '{{PAT_TOKEN}}', // Should remain unchanged since no customUserVars provided
'Content-Type': 'application/json',
});
});
}); });
}); });

View file

@ -132,6 +132,8 @@ export const resendVerificationEmail = () => '/api/user/verify/resend';
export const plugins = () => '/api/plugins'; export const plugins = () => '/api/plugins';
export const mcpReinitialize = (serverName: string) => `/api/mcp/${serverName}/reinitialize`;
export const config = () => '/api/config'; export const config = () => '/api/config';
export const prompts = () => '/api/prompts'; export const prompts = () => '/api/prompts';

View file

@ -141,6 +141,10 @@ export const updateUserPlugins = (payload: t.TUpdateUserPlugins) => {
return request.post(endpoints.userPlugins(), payload); return request.post(endpoints.userPlugins(), payload);
}; };
export const reinitializeMCPServer = (serverName: string) => {
return request.post(endpoints.mcpReinitialize(serverName));
};
/* Config */ /* Config */
export const getStartupConfig = (): Promise< export const getStartupConfig = (): Promise<

View file

@ -316,6 +316,20 @@ export const useUpdateUserPluginsMutation = (
}); });
}; };
export const useReinitializeMCPServerMutation = (): UseMutationResult<
{ success: boolean; message: string; serverName: string },
unknown,
string,
unknown
> => {
const queryClient = useQueryClient();
return useMutation((serverName: string) => dataService.reinitializeMCPServer(serverName), {
onSuccess: () => {
queryClient.refetchQueries([QueryKeys.tools]);
},
});
};
export const useGetCustomConfigSpeechQuery = ( export const useGetCustomConfigSpeechQuery = (
config?: UseQueryOptions<t.TCustomConfigSpeechResponse>, config?: UseQueryOptions<t.TCustomConfigSpeechResponse>,
): QueryObserverResult<t.TCustomConfigSpeechResponse> => { ): QueryObserverResult<t.TCustomConfigSpeechResponse> => {