mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-09-21 21:50:49 +02:00
✨ 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:
parent
14660d75ae
commit
faaba30af1
14 changed files with 499 additions and 193 deletions
|
@ -230,7 +230,7 @@ const loadTools = async ({
|
|||
|
||||
/** @type {Record<string, string>} */
|
||||
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) {
|
||||
|
@ -298,7 +298,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,
|
||||
|
|
|
@ -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<string, FunctionTool>} */
|
||||
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);
|
||||
|
|
|
@ -138,15 +138,21 @@ 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 = filterUniquePlugins([...userPlugins, ...cachedToolsArray]);
|
||||
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,16 +223,69 @@ 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 = filterUniquePlugins([...userPlugins, ...finalTools]);
|
||||
|
||||
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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
};
|
||||
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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.
|
||||
*/
|
||||
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`);
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -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,
|
||||
|
@ -47,10 +62,11 @@ async function initializeMCP(app) {
|
|||
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;
|
|
@ -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<string | null>(
|
||||
null,
|
||||
);
|
||||
const [rotatingServers, setRotatingServers] = useState<Set<string>>(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 <MCPPanelSkeleton />;
|
||||
}
|
||||
|
@ -144,14 +175,27 @@ export default function MCPPanel() {
|
|||
<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 key={server.serverName} className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
className="flex-1 justify-start dark:hover:bg-gray-700"
|
||||
onClick={() => handleServerClickToEdit(server.serverName)}
|
||||
>
|
||||
{server.serverName}
|
||||
</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>
|
||||
|
|
|
@ -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<void>((_, 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<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 */
|
||||
private async initializeServer({
|
||||
connection,
|
||||
|
|
|
@ -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',
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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<
|
||||
|
|
|
@ -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<t.TCustomConfigSpeechResponse>,
|
||||
): QueryObserverResult<t.TCustomConfigSpeechResponse> => {
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue