diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 5dcefd6df3..e03f8daa90 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -235,7 +235,7 @@ const loadTools = async ({ /** @type {Record} */ const toolContextMap = {}; - const appTools = (await getCachedTools({ includeGlobal: true })) ?? {}; + const cachedTools = (await getCachedTools({ userId: user, includeGlobal: true })) ?? {}; for (const tool of tools) { if (tool === Tools.execute_code) { @@ -303,7 +303,7 @@ Current Date & Time: ${replaceSpecialVars({ text: '{{iso_datetime}}' })} }); }; continue; - } else if (tool && appTools[tool] && mcpToolPattern.test(tool)) { + } else if (tool && cachedTools && mcpToolPattern.test(tool)) { requestedTools[tool] = async () => createMCPTool({ req: options.req, diff --git a/api/models/Agent.js b/api/models/Agent.js index dcb646f039..7cb32e6cb4 100644 --- a/api/models/Agent.js +++ b/api/models/Agent.js @@ -61,7 +61,7 @@ const getAgent = async (searchParameter) => await Agent.findOne(searchParameter) const loadEphemeralAgent = async ({ req, agent_id, endpoint, model_parameters: _m }) => { const { model, ...model_parameters } = _m; /** @type {Record} */ - const availableTools = await getCachedTools({ includeGlobal: true }); + const availableTools = await getCachedTools({ userId: req.user.id, includeGlobal: true }); /** @type {TEphemeralAgent | null} */ const ephemeralAgent = req.body.ephemeralAgent; const mcpServers = new Set(ephemeralAgent?.mcp); diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index a0af142938..938df25de9 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -138,15 +138,25 @@ function createGetServerTools() { */ const getAvailableTools = async (req, res) => { try { + const userId = req.user?.id; + const customConfig = await getCustomConfig(); const cache = getLogStores(CacheKeys.CONFIG_STORE); const cachedToolsArray = await cache.get(CacheKeys.TOOLS); - if (cachedToolsArray) { - res.status(200).json(cachedToolsArray); + const cachedUserTools = await getCachedTools({ userId }); + const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig); + + if (cachedToolsArray && userPlugins) { + const dedupedTools = [ + ...new Map( + [...userPlugins, ...cachedToolsArray].map((tool) => [tool.pluginKey, tool]), + ).values(), + ]; + res.status(200).json(dedupedTools); return; } + // If not in cache, build from manifest let pluginManifest = availableTools; - const customConfig = await getCustomConfig(); if (customConfig?.mcpServers != null) { const mcpManager = getMCPManager(); const flowsCache = getLogStores(CacheKeys.FLOWS); @@ -217,17 +227,76 @@ const getAvailableTools = async (req, res) => { toolsOutput.push(toolToAdd); } - const finalTools = filterUniquePlugins(toolsOutput); await cache.set(CacheKeys.TOOLS, finalTools); - res.status(200).json(finalTools); + + const dedupedTools = [ + ...new Map([...userPlugins, ...finalTools].map((tool) => [tool.pluginKey, tool])).values(), + ]; + + res.status(200).json(dedupedTools); } catch (error) { logger.error('[getAvailableTools]', error); 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 = { getAvailableTools, getAvailablePluginsController, + filterUniquePlugins, + checkPluginAuth, + createServerToolsCallback, + createGetServerTools, }; diff --git a/api/server/index.js b/api/server/index.js index 2da1adfcde..a792c70a67 100644 --- a/api/server/index.js +++ b/api/server/index.js @@ -16,7 +16,7 @@ const { connectDb, indexSync } = require('~/db'); const validateImageRequest = require('./middleware/validateImageRequest'); const { jwtLogin, ldapLogin, passportLogin } = require('~/strategies'); const errorController = require('./controllers/ErrorController'); -const initializeMCP = require('./services/initializeMCP'); +const initializeMCPs = require('./services/initializeMCPs'); const configureSocialLogins = require('./socialLogins'); const AppService = require('./services/AppService'); 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}`); } - initializeMCP(app); + initializeMCPs(app); }); }; diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 3dfed4d240..b9084f982d 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -1,9 +1,12 @@ const { Router } = require('express'); -const { MCPOAuthHandler } = require('@librechat/api'); 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 { getFlowStateManager } = require('~/config'); const { getLogStores } = require('~/cache'); 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; diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index c228242003..3f0a4d618e 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -104,7 +104,7 @@ function createAbortHandler({ userId, serverName, toolName, flowManager }) { * @returns { Promise unknown}> } An object with `_call` method to execute the tool input. */ 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; if (!toolDefinition) { logger.error(`Tool ${toolKey} not found in available tools`); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index f1567a3783..e37202a888 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -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}`, requiredActions, ); - const toolDefinitions = await getCachedTools({ includeGlobal: true }); + const toolDefinitions = await getCachedTools({ userId: client.req.user.id, includeGlobal: true }); const seenToolkits = new Set(); const tools = requiredActions .map((action) => { diff --git a/api/server/services/initializeMCP.js b/api/server/services/initializeMCPs.js similarity index 70% rename from api/server/services/initializeMCP.js rename to api/server/services/initializeMCPs.js index 0ed82e6ea1..1330560213 100644 --- a/api/server/services/initializeMCP.js +++ b/api/server/services/initializeMCPs.js @@ -9,20 +9,35 @@ const { getLogStores } = require('~/cache'); * Initialize MCP servers * @param {import('express').Application} app - Express app instance */ -async function initializeMCP(app) { +async function initializeMCPs(app) { const mcpServers = app.locals.mcpConfig; if (!mcpServers) { 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...'); const mcpManager = getMCPManager(); const flowsCache = getLogStores(CacheKeys.FLOWS); const flowManager = flowsCache ? getFlowStateManager(flowsCache) : null; try { - await mcpManager.initializeMCP({ - mcpServers, + await mcpManager.initializeMCPs({ + mcpServers: filteredServers, flowManager, tokenMethods: { findToken, @@ -44,13 +59,10 @@ async function initializeMCP(app) { await mcpManager.mapAvailableTools(toolsCopy, flowManager); await setCachedTools(toolsCopy, { isGlobal: true }); - const cache = getLogStores(CacheKeys.CONFIG_STORE); - await cache.delete(CacheKeys.TOOLS); - logger.debug('Cleared tools array cache after MCP initialization'); logger.info('MCP servers initialized successfully'); } catch (error) { logger.error('Failed to initialize MCP servers:', error); } } -module.exports = initializeMCP; +module.exports = initializeMCPs; diff --git a/client/src/components/SidePanel/MCP/MCPPanel.tsx b/client/src/components/SidePanel/MCP/MCPPanel.tsx index aa2bf72112..0a8ca856f6 100644 --- a/client/src/components/SidePanel/MCP/MCPPanel.tsx +++ b/client/src/components/SidePanel/MCP/MCPPanel.tsx @@ -1,8 +1,11 @@ -import React, { useState, useCallback, useMemo, useEffect } from 'react'; -import { ChevronLeft } from 'lucide-react'; import { Constants } from 'librechat-data-provider'; +import { ChevronLeft, RefreshCw } from 'lucide-react'; 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 { Button, Input, Label } from '~/components/ui'; import { useGetStartupConfig } from '~/data-provider'; @@ -24,6 +27,8 @@ export default function MCPPanel() { const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState( null, ); + const [rotatingServers, setRotatingServers] = useState>(new Set()); + const reinitializeMCPMutation = useReinitializeMCPServerMutation(); const mcpServerDefinitions = useMemo(() => { if (!startupConfig?.mcpServers) { @@ -89,6 +94,32 @@ export default function MCPPanel() { 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) { return ; } @@ -144,14 +175,27 @@ export default function MCPPanel() {
{mcpServerDefinitions.map((server) => ( - +
+ + +
))}
diff --git a/packages/api/src/mcp/manager.ts b/packages/api/src/mcp/manager.ts index b4a6a71a1f..a3bee8a18c 100644 --- a/packages/api/src/mcp/manager.ts +++ b/packages/api/src/mcp/manager.ts @@ -39,7 +39,7 @@ export class MCPManager { } /** Stores configs and initializes app-level connections */ - public async initializeMCP({ + public async initializeMCPs({ mcpServers, flowManager, tokenMethods, @@ -60,173 +60,17 @@ export class MCPManager { const entries = Object.entries(mcpServers); const initializedServers = new Set(); const connectionResults = await Promise.allSettled( - 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')); - } - }); - + entries.map(async ([serverName, config], i) => { try { - const connectTimeout = config.initTimeout ?? 30000; - const connectionTimeout = new Promise((_, reject) => - setTimeout( - () => reject(new Error(`Connection timeout after ${connectTimeout}ms`)), - connectTimeout, - ), - ); - - const connectionAttempt = this.initializeServer({ - connection, - logPrefix: `[MCP][${serverName}]`, + await this.initializeMCP({ + serverName, + config, flowManager, - handleOAuth: false, + tokenMethods, }); - await Promise.race([connectionAttempt, connectionTimeout]); - - 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(', ')}`, - ); - } - } - } + initializedServers.add(i); } catch (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; + tokenMethods?: TokenMethods; + }): Promise { + 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((_, 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 */ private async initializeServer({ connection, diff --git a/packages/api/src/mcp/mcp.spec.ts b/packages/api/src/mcp/mcp.spec.ts index a9f5b7db1d..f97d4b6bdd 100644 --- a/packages/api/src/mcp/mcp.spec.ts +++ b/packages/api/src/mcp/mcp.spec.ts @@ -708,5 +708,53 @@ describe('Environment Variable Extraction (MCP)', () => { 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', + }); + }); }); }); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index adb0e634a1..3930132ce0 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -132,6 +132,8 @@ export const resendVerificationEmail = () => '/api/user/verify/resend'; export const plugins = () => '/api/plugins'; +export const mcpReinitialize = (serverName: string) => `/api/mcp/${serverName}/reinitialize`; + export const config = () => '/api/config'; export const prompts = () => '/api/prompts'; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index dde248e2ad..d9e8ff7d12 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -141,6 +141,10 @@ export const updateUserPlugins = (payload: t.TUpdateUserPlugins) => { return request.post(endpoints.userPlugins(), payload); }; +export const reinitializeMCPServer = (serverName: string) => { + return request.post(endpoints.mcpReinitialize(serverName)); +}; + /* Config */ export const getStartupConfig = (): Promise< diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index 7e3fe549a8..ca7d4374fe 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -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 = ( config?: UseQueryOptions, ): QueryObserverResult => {