🧩 refactor: Decouple MCP Config from Startup Config (#10689)

* Decouple mcp config from start up config

* Chore: Work on AI Review and Copilot Comments

- setRawConfig is not needed since the private raw config is not needed any more
- !!serversLoading bug fixed
- added unit tests for route /api/mcp/servers
- copilot comments addressed

* chore: remove comments

* chore: rename data-provider dir for MCP

* chore: reorganize mcp specific query hooks

* fix: consolidate imports for MCP server manager

* chore: add dev-staging branch to frontend review workflow triggers

* feat: add GitHub Actions workflow for building and pushing Docker images to GitHub Container Registry and Docker Hub

* fix: update label for tag input in BookmarkForm tests to improve clarity

---------

Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com>
Co-authored-by: Danny Avila <danny@librechat.ai>
This commit is contained in:
Atef Bellaaj 2025-11-26 21:26:40 +01:00 committed by Danny Avila
parent 98b188f26c
commit ef1b7f0157
No known key found for this signature in database
GPG key ID: BF31EEB2C5CA0956
36 changed files with 548 additions and 301 deletions

View file

@ -5,6 +5,7 @@ on:
branches: branches:
- main - main
- dev - dev
- dev-staging
- release/* - release/*
paths: paths:
- 'client/**' - 'client/**'

View file

@ -10,6 +10,7 @@ const {
createSafeUser, createSafeUser,
mcpToolPattern, mcpToolPattern,
loadWebSearchAuth, loadWebSearchAuth,
mcpServersRegistry,
} = require('@librechat/api'); } = require('@librechat/api');
const { const {
Tools, Tools,
@ -347,7 +348,7 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new
/** Placeholder used for UI purposes */ /** Placeholder used for UI purposes */
continue; continue;
} }
if (serverName && options.req?.config?.mcpConfig?.[serverName] == null) { if (serverName && (await mcpServersRegistry.getServerConfig(serverName, user)) == undefined) {
logger.warn( logger.warn(
`MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`, `MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`,
); );

View file

@ -4,11 +4,7 @@
*/ */
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { Constants } = require('librechat-data-provider'); const { Constants } = require('librechat-data-provider');
const { const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config');
cacheMCPServerTools,
getMCPServerTools,
getAppConfig,
} = require('~/server/services/Config');
const { getMCPManager } = require('~/config'); const { getMCPManager } = require('~/config');
const { mcpServersRegistry } = require('@librechat/api'); const { mcpServersRegistry } = require('@librechat/api');
@ -23,13 +19,14 @@ const getMCPTools = async (req, res) => {
return res.status(401).json({ message: 'Unauthorized' }); return res.status(401).json({ message: 'Unauthorized' });
} }
const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role })); const mcpConfig = await mcpServersRegistry.getAllServerConfigs(userId);
if (!appConfig?.mcpConfig) { const configuredServers = mcpConfig ? Object.keys(mcpConfig) : [];
if (!mcpConfig || Object.keys(mcpConfig).length == 0) {
return res.status(200).json({ servers: {} }); return res.status(200).json({ servers: {} });
} }
const mcpManager = getMCPManager(); const mcpManager = getMCPManager();
const configuredServers = Object.keys(appConfig.mcpConfig);
const mcpServers = {}; const mcpServers = {};
const cachePromises = configuredServers.map((serverName) => const cachePromises = configuredServers.map((serverName) =>
@ -71,7 +68,7 @@ const getMCPTools = async (req, res) => {
const serverTools = serverToolsMap.get(serverName); const serverTools = serverToolsMap.get(serverName);
// Get server config once // Get server config once
const serverConfig = appConfig.mcpConfig[serverName]; const serverConfig = mcpConfig[serverName];
const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId); const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
// Initialize server object with all server-level data // Initialize server object with all server-level data
@ -127,7 +124,29 @@ const getMCPTools = async (req, res) => {
res.status(500).json({ message: error.message }); res.status(500).json({ message: error.message });
} }
}; };
/**
* Get all MCP servers with permissions
* @route GET /api/mcp/servers
*/
const getMCPServersList = async (req, res) => {
try {
const userId = req.user?.id;
if (!userId) {
return res.status(401).json({ message: 'Unauthorized' });
}
// TODO - Ensure DB servers loaded into registry (configs only)
// 2. Get all server configs from registry (YAML + DB)
const serverConfigs = await mcpServersRegistry.getAllServerConfigs(userId);
return res.json(serverConfigs);
} catch (error) {
logger.error('[getMCPServersList]', error);
res.status(500).json({ error: error.message });
}
};
module.exports = { module.exports = {
getMCPTools, getMCPTools,
getMCPServersList,
}; };

View file

@ -18,6 +18,7 @@ jest.mock('@librechat/api', () => ({
mcpServersRegistry: { mcpServersRegistry: {
getServerConfig: jest.fn(), getServerConfig: jest.fn(),
getOAuthServers: jest.fn(), getOAuthServers: jest.fn(),
getAllServerConfigs: jest.fn(),
}, },
})); }));
@ -1415,4 +1416,62 @@ describe('MCP Routes', () => {
expect(response.headers.location).toContain('/oauth/success'); expect(response.headers.location).toContain('/oauth/success');
}); });
}); });
describe('GET /servers', () => {
const { mcpServersRegistry } = require('@librechat/api');
it('should return all server configs for authenticated user', async () => {
const mockServerConfigs = {
'server-1': {
endpoint: 'http://server1.com',
name: 'Server 1',
},
'server-2': {
endpoint: 'http://server2.com',
name: 'Server 2',
},
};
mcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockServerConfigs);
const response = await request(app).get('/api/mcp/servers');
expect(response.status).toBe(200);
expect(response.body).toEqual(mockServerConfigs);
expect(mcpServersRegistry.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
});
it('should return empty object when no servers are configured', async () => {
mcpServersRegistry.getAllServerConfigs.mockResolvedValue({});
const response = await request(app).get('/api/mcp/servers');
expect(response.status).toBe(200);
expect(response.body).toEqual({});
});
it('should return 401 when user is not authenticated', async () => {
const unauthApp = express();
unauthApp.use(express.json());
unauthApp.use((req, _res, next) => {
req.user = null;
next();
});
unauthApp.use('/api/mcp', mcpRouter);
const response = await request(unauthApp).get('/api/mcp/servers');
expect(response.status).toBe(401);
expect(response.body).toEqual({ message: 'Unauthorized' });
});
it('should return 500 when server config retrieval fails', async () => {
mcpServersRegistry.getAllServerConfigs.mockRejectedValue(new Error('Database error'));
const response = await request(app).get('/api/mcp/servers');
expect(response.status).toBe(500);
expect(response.body).toEqual({ error: 'Database error' });
});
});
}); });

View file

@ -1,18 +1,11 @@
const express = require('express'); const express = require('express');
const { logger } = require('@librechat/data-schemas'); const { logger } = require('@librechat/data-schemas');
const { isEnabled, getBalanceConfig } = require('@librechat/api'); const { isEnabled, getBalanceConfig } = require('@librechat/api');
const { const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider');
Constants,
CacheKeys,
removeNullishValues,
defaultSocialLogins,
} = require('librechat-data-provider');
const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getLdapConfig } = require('~/server/services/Config/ldap');
const { getAppConfig } = require('~/server/services/Config/app'); const { getAppConfig } = require('~/server/services/Config/app');
const { getProjectByName } = require('~/models/Project'); const { getProjectByName } = require('~/models/Project');
const { getMCPManager } = require('~/config');
const { getLogStores } = require('~/cache'); const { getLogStores } = require('~/cache');
const { mcpServersRegistry } = require('@librechat/api');
const router = express.Router(); const router = express.Router();
const emailLoginEnabled = const emailLoginEnabled =
@ -30,46 +23,11 @@ const publicSharedLinksEnabled =
const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER);
const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS); const openidReuseTokens = isEnabled(process.env.OPENID_REUSE_TOKENS);
/**
* Fetches MCP servers from registry and adds them to the payload.
* Registry now includes all configured servers (from YAML) plus inspection data when available.
* Always fetches fresh to avoid caching incomplete initialization state.
*/
const getMCPServers = async (payload, appConfig) => {
try {
if (appConfig?.mcpConfig == null) {
return;
}
const mcpManager = getMCPManager();
if (!mcpManager) {
return;
}
const mcpServers = await mcpServersRegistry.getAllServerConfigs();
if (!mcpServers) return;
for (const serverName in mcpServers) {
if (!payload.mcpServers) {
payload.mcpServers = {};
}
const serverConfig = mcpServers[serverName];
payload.mcpServers[serverName] = removeNullishValues({
startup: serverConfig?.startup,
chatMenu: serverConfig?.chatMenu,
isOAuth: serverConfig.requiresOAuth,
customUserVars: serverConfig?.customUserVars,
});
}
} catch (error) {
logger.error('Error loading MCP servers', error);
}
};
router.get('/', async function (req, res) { router.get('/', async function (req, res) {
const cache = getLogStores(CacheKeys.CONFIG_STORE); const cache = getLogStores(CacheKeys.CONFIG_STORE);
const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG); const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG);
if (cachedStartupConfig) { if (cachedStartupConfig) {
const appConfig = await getAppConfig({ role: req.user?.role });
await getMCPServers(cachedStartupConfig, appConfig);
res.send(cachedStartupConfig); res.send(cachedStartupConfig);
return; return;
} }
@ -190,7 +148,6 @@ router.get('/', async function (req, res) {
} }
await cache.set(CacheKeys.STARTUP_CONFIG, payload); await cache.set(CacheKeys.STARTUP_CONFIG, payload);
await getMCPServers(payload, appConfig);
return res.status(200).send(payload); return res.status(200).send(payload);
} catch (err) { } catch (err) {
logger.error('Error in startup config', err); logger.error('Error in startup config', err);

View file

@ -14,6 +14,7 @@ const { findToken, updateToken, createToken, deleteTokens } = require('~/models'
const { getUserPluginAuthValue } = require('~/server/services/PluginService'); const { getUserPluginAuthValue } = require('~/server/services/PluginService');
const { updateMCPServerTools } = require('~/server/services/Config/mcp'); const { updateMCPServerTools } = require('~/server/services/Config/mcp');
const { reinitMCPServer } = require('~/server/services/Tools/mcp'); const { reinitMCPServer } = require('~/server/services/Tools/mcp');
const { getMCPServersList } = require('~/server/controllers/mcp');
const { getMCPTools } = require('~/server/controllers/mcp'); const { getMCPTools } = require('~/server/controllers/mcp');
const { requireJwtAuth } = require('~/server/middleware'); const { requireJwtAuth } = require('~/server/middleware');
const { findPluginAuthsByKeys } = require('~/models'); const { findPluginAuthsByKeys } = require('~/models');
@ -428,11 +429,12 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => {
); );
const connectionStatus = {}; const connectionStatus = {};
for (const [serverName] of Object.entries(mcpConfig)) { for (const [serverName, config] of Object.entries(mcpConfig)) {
try { try {
connectionStatus[serverName] = await getServerConnectionStatus( connectionStatus[serverName] = await getServerConnectionStatus(
user.id, user.id,
serverName, serverName,
config,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -487,6 +489,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) =>
const serverStatus = await getServerConnectionStatus( const serverStatus = await getServerConnectionStatus(
user.id, user.id,
serverName, serverName,
mcpConfig[serverName],
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -566,5 +569,11 @@ async function getOAuthHeaders(serverName, userId) {
const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId); const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId);
return serverConfig?.oauth_headers ?? {}; return serverConfig?.oauth_headers ?? {};
} }
/**
* Get list of accessible MCP servers
* @route GET /api/mcp/servers
* @returns {MCPServersListResponse} 200 - Success response - application/json
*/
router.get('/servers', requireJwtAuth, getMCPServersList);
module.exports = router; module.exports = router;

View file

@ -435,8 +435,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide
* @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers * @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers
*/ */
async function getMCPSetupData(userId) { async function getMCPSetupData(userId) {
const config = await getAppConfig(); const mcpConfig = await mcpServersRegistry.getAllServerConfigs(userId);
const mcpConfig = config?.mcpConfig;
if (!mcpConfig) { if (!mcpConfig) {
throw new Error('MCP config not found'); throw new Error('MCP config not found');
@ -451,7 +450,7 @@ async function getMCPSetupData(userId) {
logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error); logger.error(`[MCP][User: ${userId}] Error getting app connections:`, error);
} }
const userConnections = mcpManager.getUserConnections(userId) || new Map(); const userConnections = mcpManager.getUserConnections(userId) || new Map();
const oauthServers = await mcpServersRegistry.getOAuthServers(); const oauthServers = await mcpServersRegistry.getOAuthServers(userId);
return { return {
mcpConfig, mcpConfig,
@ -524,24 +523,26 @@ async function checkOAuthFlowStatus(userId, serverName) {
* Get connection status for a specific MCP server * Get connection status for a specific MCP server
* @param {string} userId - The user ID * @param {string} userId - The user ID
* @param {string} serverName - The server name * @param {string} serverName - The server name
* @param {Map} appConnections - App-level connections * @param {import('@librechat/api').ParsedServerConfig} config - The server configuration
* @param {Map} userConnections - User-level connections * @param {Map<string, import('@librechat/api').MCPConnection>} appConnections - App-level connections
* @param {Map<string, import('@librechat/api').MCPConnection>} userConnections - User-level connections
* @param {Set} oauthServers - Set of OAuth servers * @param {Set} oauthServers - Set of OAuth servers
* @returns {Object} Object containing requiresOAuth and connectionState * @returns {Object} Object containing requiresOAuth and connectionState
*/ */
async function getServerConnectionStatus( async function getServerConnectionStatus(
userId, userId,
serverName, serverName,
config,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
) { ) {
const getConnectionState = () => const connection = appConnections.get(serverName) || userConnections.get(serverName);
appConnections.get(serverName)?.connectionState ?? const isStaleOrDoNotExist = connection ? connection?.isStale(config.lastUpdatedAt) : true;
userConnections.get(serverName)?.connectionState ??
'disconnected';
const baseConnectionState = getConnectionState(); const baseConnectionState = isStaleOrDoNotExist
? 'disconnected'
: connection?.connectionState || 'disconnected';
let finalConnectionState = baseConnectionState; let finalConnectionState = baseConnectionState;
// connection state overrides specific to OAuth servers // connection state overrides specific to OAuth servers

View file

@ -52,6 +52,7 @@ jest.mock('@librechat/api', () => ({
convertWithResolvedRefs: jest.fn((params) => params), convertWithResolvedRefs: jest.fn((params) => params),
mcpServersRegistry: { mcpServersRegistry: {
getOAuthServers: jest.fn(() => Promise.resolve(new Set())), getOAuthServers: jest.fn(() => Promise.resolve(new Set())),
getAllServerConfigs: jest.fn(() => Promise.resolve({})),
}, },
})); }));
@ -118,31 +119,28 @@ describe('tests for the new helper functions used by the MCP connection status e
describe('getMCPSetupData', () => { describe('getMCPSetupData', () => {
const mockUserId = 'user-123'; const mockUserId = 'user-123';
const mockConfig = { const mockConfig = {
mcpServers: {
server1: { type: 'stdio' }, server1: { type: 'stdio' },
server2: { type: 'http' }, server2: { type: 'http' },
},
}; };
let mockGetAppConfig;
beforeEach(() => { beforeEach(() => {
mockGetAppConfig = require('./Config').getAppConfig;
mockGetMCPManager.mockReturnValue({ mockGetMCPManager.mockReturnValue({
appConnections: { getAll: jest.fn(() => new Map()) }, appConnections: { getAll: jest.fn(() => new Map()) },
getUserConnections: jest.fn(() => new Map()), getUserConnections: jest.fn(() => new Map()),
}); });
mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set()); mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set());
mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig);
}); });
it('should successfully return MCP setup data', async () => { it('should successfully return MCP setup data', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers }); mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig);
const mockAppConnections = new Map([['server1', { status: 'connected' }]]); const mockAppConnections = new Map([['server1', { status: 'connected' }]]);
const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]); const mockUserConnections = new Map([['server2', { status: 'disconnected' }]]);
const mockOAuthServers = new Set(['server2']); const mockOAuthServers = new Set(['server2']);
const mockMCPManager = { const mockMCPManager = {
appConnections: { getAll: jest.fn(() => mockAppConnections) }, appConnections: { getAll: jest.fn(() => Promise.resolve(mockAppConnections)) },
getUserConnections: jest.fn(() => mockUserConnections), getUserConnections: jest.fn(() => mockUserConnections),
}; };
mockGetMCPManager.mockReturnValue(mockMCPManager); mockGetMCPManager.mockReturnValue(mockMCPManager);
@ -150,14 +148,14 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getMCPSetupData(mockUserId); const result = await getMCPSetupData(mockUserId);
expect(mockGetAppConfig).toHaveBeenCalled(); expect(mockMcpServersRegistry.getAllServerConfigs).toHaveBeenCalledWith(mockUserId);
expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId); expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId);
expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled(); expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled();
expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId); expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId);
expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalled(); expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalledWith(mockUserId);
expect(result).toEqual({ expect(result).toEqual({
mcpConfig: mockConfig.mcpServers, mcpConfig: mockConfig,
appConnections: mockAppConnections, appConnections: mockAppConnections,
userConnections: mockUserConnections, userConnections: mockUserConnections,
oauthServers: mockOAuthServers, oauthServers: mockOAuthServers,
@ -165,15 +163,15 @@ describe('tests for the new helper functions used by the MCP connection status e
}); });
it('should throw error when MCP config not found', async () => { it('should throw error when MCP config not found', async () => {
mockGetAppConfig.mockResolvedValue({}); mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(null);
await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found'); await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found');
}); });
it('should handle null values from MCP manager gracefully', async () => { it('should handle null values from MCP manager gracefully', async () => {
mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers }); mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig);
const mockMCPManager = { const mockMCPManager = {
appConnections: { getAll: jest.fn(() => null) }, appConnections: { getAll: jest.fn(() => Promise.resolve(null)) },
getUserConnections: jest.fn(() => null), getUserConnections: jest.fn(() => null),
}; };
mockGetMCPManager.mockReturnValue(mockMCPManager); mockGetMCPManager.mockReturnValue(mockMCPManager);
@ -182,7 +180,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getMCPSetupData(mockUserId); const result = await getMCPSetupData(mockUserId);
expect(result).toEqual({ expect(result).toEqual({
mcpConfig: mockConfig.mcpServers, mcpConfig: mockConfig,
appConnections: new Map(), appConnections: new Map(),
userConnections: new Map(), userConnections: new Map(),
oauthServers: new Set(), oauthServers: new Set(),
@ -329,15 +327,25 @@ describe('tests for the new helper functions used by the MCP connection status e
describe('getServerConnectionStatus', () => { describe('getServerConnectionStatus', () => {
const mockUserId = 'user-123'; const mockUserId = 'user-123';
const mockServerName = 'test-server'; const mockServerName = 'test-server';
const mockConfig = { lastUpdatedAt: Date.now() };
it('should return app connection state when available', async () => { it('should return app connection state when available', async () => {
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]); const appConnections = new Map([
[
mockServerName,
{
connectionState: 'connected',
isStale: jest.fn(() => false),
},
],
]);
const userConnections = new Map(); const userConnections = new Map();
const oauthServers = new Set(); const oauthServers = new Set();
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -351,12 +359,21 @@ describe('tests for the new helper functions used by the MCP connection status e
it('should fallback to user connection state when app connection not available', async () => { it('should fallback to user connection state when app connection not available', async () => {
const appConnections = new Map(); const appConnections = new Map();
const userConnections = new Map([[mockServerName, { connectionState: 'connecting' }]]); const userConnections = new Map([
[
mockServerName,
{
connectionState: 'connecting',
isStale: jest.fn(() => false),
},
],
]);
const oauthServers = new Set(); const oauthServers = new Set();
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -376,6 +393,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -388,13 +406,30 @@ describe('tests for the new helper functions used by the MCP connection status e
}); });
it('should prioritize app connection over user connection', async () => { it('should prioritize app connection over user connection', async () => {
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]); const appConnections = new Map([
const userConnections = new Map([[mockServerName, { connectionState: 'disconnected' }]]); [
mockServerName,
{
connectionState: 'connected',
isStale: jest.fn(() => false),
},
],
]);
const userConnections = new Map([
[
mockServerName,
{
connectionState: 'disconnected',
isStale: jest.fn(() => false),
},
],
]);
const oauthServers = new Set(); const oauthServers = new Set();
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -420,6 +455,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -454,6 +490,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -491,6 +528,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -524,6 +562,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -549,6 +588,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -571,13 +611,22 @@ describe('tests for the new helper functions used by the MCP connection status e
mockGetFlowStateManager.mockReturnValue(mockFlowManager); mockGetFlowStateManager.mockReturnValue(mockFlowManager);
mockGetLogStores.mockReturnValue({}); mockGetLogStores.mockReturnValue({});
const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]); const appConnections = new Map([
[
mockServerName,
{
connectionState: 'connected',
isStale: jest.fn(() => false),
},
],
]);
const userConnections = new Map(); const userConnections = new Map();
const oauthServers = new Set([mockServerName]); const oauthServers = new Set([mockServerName]);
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,
@ -606,6 +655,7 @@ describe('tests for the new helper functions used by the MCP connection status e
const result = await getServerConnectionStatus( const result = await getServerConnectionStatus(
mockUserId, mockUserId,
mockServerName, mockServerName,
mockConfig,
appConnections, appConnections,
userConnections, userConnections,
oauthServers, oauthServers,

View file

@ -427,6 +427,7 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA
/** @type {Record<string, Record<string, string>>} */ /** @type {Record<string, Record<string, string>>} */
let userMCPAuthMap; let userMCPAuthMap;
//TODO pass config from registry
if (hasCustomUserVars(req.config)) { if (hasCustomUserVars(req.config)) {
userMCPAuthMap = await getUserMCPAuthMap({ userMCPAuthMap = await getUserMCPAuthMap({
tools: agent.tools, tools: agent.tools,

View file

@ -8,7 +8,12 @@ import {
useGetStartupConfig, useGetStartupConfig,
useMCPToolsQuery, useMCPToolsQuery,
} from '~/data-provider'; } from '~/data-provider';
import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks'; import {
useLocalize,
useGetAgentsConfig,
useMCPConnectionStatus,
useMCPServerManager,
} from '~/hooks';
import { Panel, isEphemeralAgent } from '~/common'; import { Panel, isEphemeralAgent } from '~/common';
const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined); const AgentPanelContext = createContext<AgentPanelContextType | undefined>(undefined);
@ -29,7 +34,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
const [action, setAction] = useState<Action | undefined>(undefined); const [action, setAction] = useState<Action | undefined>(undefined);
const [activePanel, setActivePanel] = useState<Panel>(Panel.builder); const [activePanel, setActivePanel] = useState<Panel>(Panel.builder);
const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined); const [agent_id, setCurrentAgentId] = useState<string | undefined>(undefined);
const { availableMCPServers, isLoading, availableMCPServersMap } = useMCPServerManager();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig();
const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, { const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, {
enabled: !isEphemeralAgent(agent_id), enabled: !isEphemeralAgent(agent_id),
@ -38,19 +43,23 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents);
const { data: mcpData } = useMCPToolsQuery({ const { data: mcpData } = useMCPToolsQuery({
enabled: !isEphemeralAgent(agent_id) && startupConfig?.mcpServers != null, enabled:
!isEphemeralAgent(agent_id) &&
!isLoading &&
availableMCPServers != null &&
availableMCPServers.length > 0,
}); });
const { agentsConfig, endpointsConfig } = useGetAgentsConfig(); const { agentsConfig, endpointsConfig } = useGetAgentsConfig();
const mcpServerNames = useMemo( const mcpServerNames = useMemo(
() => Object.keys(startupConfig?.mcpServers ?? {}), () => availableMCPServers.map((s) => s.serverName),
[startupConfig], [availableMCPServers],
); );
const { connectionStatus } = useMCPConnectionStatus({ const { connectionStatus } = useMCPConnectionStatus({
enabled: !isEphemeralAgent(agent_id) && mcpServerNames.length > 0, enabled: !isEphemeralAgent(agent_id) && mcpServerNames.length > 0,
}); });
//TODO to refactor when tools come from tool box
const mcpServersMap = useMemo(() => { const mcpServersMap = useMemo(() => {
const configuredServers = new Set(mcpServerNames); const configuredServers = new Set(mcpServerNames);
const serversMap = new Map<string, MCPServerInfo>(); const serversMap = new Map<string, MCPServerInfo>();
@ -127,6 +136,8 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode })
setActivePanel, setActivePanel,
endpointsConfig, endpointsConfig,
setCurrentAgentId, setCurrentAgentId,
availableMCPServers,
availableMCPServersMap,
}; };
return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>; return <AgentPanelContext.Provider value={value}>{children}</AgentPanelContext.Provider>;

View file

@ -7,6 +7,7 @@ import type { ColumnDef } from '@tanstack/react-table';
import type * as t from 'librechat-data-provider'; import type * as t from 'librechat-data-provider';
import type { LucideIcon } from 'lucide-react'; import type { LucideIcon } from 'lucide-react';
import type { TranslationKeys } from '~/hooks'; import type { TranslationKeys } from '~/hooks';
import { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager';
export function isEphemeralAgent(agentId: string | null | undefined): boolean { export function isEphemeralAgent(agentId: string | null | undefined): boolean {
return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID; return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID;
@ -246,6 +247,8 @@ export type AgentPanelContextType = {
endpointsConfig?: t.TEndpointsConfig | null; endpointsConfig?: t.TEndpointsConfig | null;
/** Pre-computed MCP server information indexed by server key */ /** Pre-computed MCP server information indexed by server key */
mcpServersMap: Map<string, MCPServerInfo>; mcpServersMap: Map<string, MCPServerInfo>;
availableMCPServers: MCPServerDefinition[];
availableMCPServersMap: t.MCPServersListResponse | undefined;
}; };
export type AgentModelPanelProps = { export type AgentModelPanelProps = {

View file

@ -223,7 +223,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
/>, />,
); );
const tagInput = screen.getByLabelText('Edit Bookmark'); const tagInput = screen.getByLabelText('Title');
await act(async () => { await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } }); fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
@ -265,7 +265,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
/>, />,
); );
const tagInput = screen.getByLabelText('Edit Bookmark'); const tagInput = screen.getByLabelText('Title');
await act(async () => { await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Existing Tag' } }); fireEvent.change(tagInput, { target: { value: 'Existing Tag' } });
@ -308,7 +308,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
/>, />,
); );
const tagInput = screen.getByLabelText('Edit Bookmark'); const tagInput = screen.getByLabelText('Title');
await act(async () => { await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Brand New Tag' } }); fireEvent.change(tagInput, { target: { value: 'Brand New Tag' } });
@ -401,7 +401,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
/>, />,
); );
const tagInput = screen.getByLabelText('Edit Bookmark'); const tagInput = screen.getByLabelText('Title');
await act(async () => { await act(async () => {
fireEvent.change(tagInput, { target: { value: 'Props Tag' } }); fireEvent.change(tagInput, { target: { value: 'Props Tag' } });
@ -477,7 +477,7 @@ describe('BookmarkForm - Bookmark Editing', () => {
/>, />,
); );
const tagInput = screen.getByLabelText('Edit Bookmark'); const tagInput = screen.getByLabelText('Title');
await act(async () => { await act(async () => {
fireEvent.change(tagInput, { target: { value: 'New Tag' } }); fireEvent.change(tagInput, { target: { value: 'New Tag' } });

View file

@ -12,10 +12,10 @@ function MCPSelectContent() {
mcpValues, mcpValues,
isInitializing, isInitializing,
placeholderText, placeholderText,
configuredServers,
batchToggleServers, batchToggleServers,
getConfigDialogProps, getConfigDialogProps,
getServerStatusIconProps, getServerStatusIconProps,
availableMCPServers,
} = mcpServerManager; } = mcpServerManager;
const renderSelectedValues = useCallback( const renderSelectedValues = useCallback(
@ -78,7 +78,7 @@ function MCPSelectContent() {
return ( return (
<> <>
<MultiSelect <MultiSelect
items={configuredServers} items={availableMCPServers?.map((s) => s.serverName)}
selectedValues={mcpValues ?? []} selectedValues={mcpValues ?? []}
setSelectedValues={batchToggleServers} setSelectedValues={batchToggleServers}
renderSelectedValues={renderSelectedValues} renderSelectedValues={renderSelectedValues}
@ -99,9 +99,9 @@ function MCPSelectContent() {
function MCPSelect() { function MCPSelect() {
const { mcpServerManager } = useBadgeRowContext(); const { mcpServerManager } = useBadgeRowContext();
const { configuredServers } = mcpServerManager; const { availableMCPServers } = mcpServerManager;
if (!configuredServers || configuredServers.length === 0) { if (!availableMCPServers || availableMCPServers.length === 0) {
return null; return null;
} }

View file

@ -20,7 +20,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
setIsPinned, setIsPinned,
isInitializing, isInitializing,
placeholderText, placeholderText,
configuredServers, availableMCPServers,
getConfigDialogProps, getConfigDialogProps,
toggleServerSelection, toggleServerSelection,
getServerStatusIconProps, getServerStatusIconProps,
@ -33,7 +33,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
}); });
// Don't render if no MCP servers are configured // Don't render if no MCP servers are configured
if (!configuredServers || configuredServers.length === 0) { if (!availableMCPServers || availableMCPServers.length === 0) {
return null; return null;
} }
@ -85,19 +85,19 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
'border border-border-light bg-surface-secondary p-1 shadow-lg', 'border border-border-light bg-surface-secondary p-1 shadow-lg',
)} )}
> >
{configuredServers.map((serverName) => { {availableMCPServers.map((s) => {
const statusIconProps = getServerStatusIconProps(serverName); const statusIconProps = getServerStatusIconProps(s.serverName);
const isSelected = mcpValues?.includes(serverName) ?? false; const isSelected = mcpValues?.includes(s.serverName) ?? false;
const isServerInitializing = isInitializing(serverName); const isServerInitializing = isInitializing(s.serverName);
const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />; const statusIcon = statusIconProps && <MCPServerStatusIcon {...statusIconProps} />;
return ( return (
<Ariakit.MenuItem <Ariakit.MenuItem
key={serverName} key={s.serverName}
onClick={(event) => { onClick={(event) => {
event.preventDefault(); event.preventDefault();
toggleServerSelection(serverName); toggleServerSelection(s.serverName);
}} }}
disabled={isServerInitializing} disabled={isServerInitializing}
className={cn( className={cn(
@ -112,7 +112,7 @@ const MCPSubMenu = React.forwardRef<HTMLDivElement, MCPSubMenuProps>(
> >
<div className="flex flex-grow items-center gap-2"> <div className="flex flex-grow items-center gap-2">
<Ariakit.MenuItemCheck checked={isSelected} /> <Ariakit.MenuItemCheck checked={isSelected} />
<span>{serverName}</span> <span>{s.serverName}</span>
</div> </div>
{statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>} {statusIcon && <div className="ml-2 flex items-center">{statusIcon}</div>}
</Ariakit.MenuItem> </Ariakit.MenuItem>

View file

@ -21,12 +21,17 @@ export default function ServerInitializationSection({
}: ServerInitializationSectionProps) { }: ServerInitializationSectionProps) {
const localize = useLocalize(); const localize = useLocalize();
const { initializeServer, cancelOAuthFlow, isInitializing, isCancellable, getOAuthUrl } = const {
useMCPServerManager({ conversationId }); initializeServer,
availableMCPServers,
cancelOAuthFlow,
isInitializing,
isCancellable,
getOAuthUrl,
} = useMCPServerManager({ conversationId });
const { data: startupConfig } = useGetStartupConfig();
const { connectionStatus } = useMCPConnectionStatus({ const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0, enabled: !!availableMCPServers && availableMCPServers.length > 0,
}); });
const serverStatus = connectionStatus?.[serverName]; const serverStatus = connectionStatus?.[serverName];

View file

@ -49,7 +49,7 @@ export default function AgentConfig() {
setAction, setAction,
regularTools, regularTools,
agentsConfig, agentsConfig,
startupConfig, availableMCPServers,
mcpServersMap, mcpServersMap,
setActivePanel, setActivePanel,
endpointsConfig, endpointsConfig,
@ -305,7 +305,7 @@ export default function AgentConfig() {
</div> </div>
)} )}
{/* MCP Section */} {/* MCP Section */}
{startupConfig?.mcpServers != null && ( {availableMCPServers != null && availableMCPServers.length > 0 && (
<MCPTools <MCPTools
agentId={agent_id} agentId={agent_id}
mcpServerNames={mcpServerNames} mcpServerNames={mcpServerNames}
@ -491,7 +491,7 @@ export default function AgentConfig() {
setIsOpen={setShowToolDialog} setIsOpen={setShowToolDialog}
endpoint={EModelEndpoint.agents} endpoint={EModelEndpoint.agents}
/> />
{startupConfig?.mcpServers != null && ( {availableMCPServers != null && availableMCPServers.length > 0 && (
<MCPToolSelectDialog <MCPToolSelectDialog
agentId={agent_id} agentId={agent_id}
isOpen={showMCPToolDialog} isOpen={showMCPToolDialog}

View file

@ -1,4 +1,4 @@
import React, { useState, useMemo, useCallback } from 'react'; import React, { useState, useCallback } from 'react';
import { ChevronLeft, Trash2 } from 'lucide-react'; import { ChevronLeft, Trash2 } from 'lucide-react';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Button, useToastContext } from '@librechat/client'; import { Button, useToastContext } from '@librechat/client';
@ -8,8 +8,7 @@ import type { TUpdateUserPlugins } from 'librechat-data-provider';
import ServerInitializationSection from '~/components/MCP/ServerInitializationSection'; import ServerInitializationSection from '~/components/MCP/ServerInitializationSection';
import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection'; import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection';
import { MCPPanelProvider, useMCPPanelContext } from '~/Providers'; import { MCPPanelProvider, useMCPPanelContext } from '~/Providers';
import { useLocalize, useMCPConnectionStatus } from '~/hooks'; import { useLocalize, useMCPServerManager } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider';
import MCPPanelSkeleton from './MCPPanelSkeleton'; import MCPPanelSkeleton from './MCPPanelSkeleton';
function MCPPanelContent() { function MCPPanelContent() {
@ -17,9 +16,8 @@ function MCPPanelContent() {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const { conversationId } = useMCPPanelContext(); const { conversationId } = useMCPPanelContext();
const { data: startupConfig, isLoading: startupConfigLoading } = useGetStartupConfig(); const { availableMCPServers, isLoading, connectionStatus } = useMCPServerManager({
const { connectionStatus } = useMCPConnectionStatus({ conversationId,
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0,
}); });
const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>( const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState<string | null>(
@ -45,20 +43,6 @@ function MCPPanelContent() {
}, },
}); });
const mcpServerDefinitions = useMemo(() => {
if (!startupConfig?.mcpServers) {
return [];
}
return Object.entries(startupConfig.mcpServers).map(([serverName, config]) => ({
serverName,
iconPath: null,
config: {
...config,
customUserVars: config.customUserVars ?? {},
},
}));
}, [startupConfig?.mcpServers]);
const handleServerClickToEdit = (serverName: string) => { const handleServerClickToEdit = (serverName: string) => {
setSelectedServerNameForEditing(serverName); setSelectedServerNameForEditing(serverName);
}; };
@ -94,11 +78,11 @@ function MCPPanelContent() {
[updateUserPluginsMutation], [updateUserPluginsMutation],
); );
if (startupConfigLoading) { if (isLoading) {
return <MCPPanelSkeleton />; return <MCPPanelSkeleton />;
} }
if (mcpServerDefinitions.length === 0) { if (availableMCPServers.length === 0) {
return ( return (
<div className="p-4 text-center text-sm text-gray-500"> <div className="p-4 text-center text-sm text-gray-500">
{localize('com_sidepanel_mcp_no_servers_with_vars')} {localize('com_sidepanel_mcp_no_servers_with_vars')}
@ -108,7 +92,7 @@ function MCPPanelContent() {
if (selectedServerNameForEditing) { if (selectedServerNameForEditing) {
// Editing View // Editing View
const serverBeingEdited = mcpServerDefinitions.find( const serverBeingEdited = availableMCPServers.find(
(s) => s.serverName === selectedServerNameForEditing, (s) => s.serverName === selectedServerNameForEditing,
); );
@ -140,7 +124,7 @@ function MCPPanelContent() {
<div className="mb-4"> <div className="mb-4">
<CustomUserVarsSection <CustomUserVarsSection
serverName={selectedServerNameForEditing} serverName={selectedServerNameForEditing}
fields={serverBeingEdited.config.customUserVars} fields={serverBeingEdited.config.customUserVars || {}}
onSave={(authData) => { onSave={(authData) => {
if (selectedServerNameForEditing) { if (selectedServerNameForEditing) {
handleConfigSave(selectedServerNameForEditing, authData); handleConfigSave(selectedServerNameForEditing, authData);
@ -184,7 +168,7 @@ function MCPPanelContent() {
return ( return (
<div className="h-auto max-w-full overflow-x-hidden py-2"> <div className="h-auto max-w-full overflow-x-hidden py-2">
<div className="space-y-2"> <div className="space-y-2">
{mcpServerDefinitions.map((server) => { {availableMCPServers.map((server) => {
const serverStatus = connectionStatus?.[server.serverName]; const serverStatus = connectionStatus?.[server.serverName];
const isConnected = serverStatus?.connectionState === 'connected'; const isConnected = serverStatus?.connectionState === 'connected';

View file

@ -34,7 +34,7 @@ function MCPToolSelectDialog({
const { initializeServer } = useMCPServerManager(); const { initializeServer } = useMCPServerManager();
const { getValues, setValue } = useFormContext<AgentForm>(); const { getValues, setValue } = useFormContext<AgentForm>();
const { removeTool } = useRemoveMCPTool({ showToast: false }); const { removeTool } = useRemoveMCPTool({ showToast: false });
const { mcpServersMap, startupConfig } = useAgentPanelContext(); const { mcpServersMap, availableMCPServersMap } = useAgentPanelContext();
const { refetch: refetchMCPTools } = useMCPToolsQuery({ const { refetch: refetchMCPTools } = useMCPToolsQuery({
enabled: mcpServersMap.size > 0, enabled: mcpServersMap.size > 0,
}); });
@ -191,7 +191,7 @@ function MCPToolSelectDialog({
return; return;
} }
const serverConfig = startupConfig?.mcpServers?.[serverName]; const serverConfig = availableMCPServersMap?.[serverName];
const hasCustomUserVars = const hasCustomUserVars =
serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0; serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0;
@ -300,7 +300,7 @@ function MCPToolSelectDialog({
<CustomUserVarsSection <CustomUserVarsSection
serverName={configuringServer} serverName={configuringServer}
isSubmitting={isSavingCustomVars} isSubmitting={isSavingCustomVars}
fields={startupConfig?.mcpServers?.[configuringServer]?.customUserVars || {}} fields={availableMCPServersMap?.[configuringServer]?.customUserVars || {}}
onSave={(authData) => handleSaveCustomVars(configuringServer, authData)} onSave={(authData) => handleSaveCustomVars(configuringServer, authData)}
onRevoke={() => handleRevokeCustomVars(configuringServer)} onRevoke={() => handleRevokeCustomVars(configuringServer)}
/> />

View file

@ -0,0 +1 @@
export * from './queries';

View file

@ -0,0 +1,44 @@
import { useQuery, UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type * as t from 'librechat-data-provider';
/**
* Hook for fetching all accessible MCP servers with permission metadata
*/
export const useMCPServersQuery = <TData = t.MCPServersListResponse>(
config?: UseQueryOptions<t.MCPServersListResponse, unknown, TData>,
): QueryObserverResult<TData> => {
return useQuery<t.MCPServersListResponse, unknown, TData>(
[QueryKeys.mcpServers],
() => dataService.getMCPServers(),
{
staleTime: 1000 * 60 * 5, // 5 minutes - data stays fresh longer
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
retry: false,
...config,
},
);
};
/**
* Hook for fetching MCP-specific tools
* @param config - React Query configuration
* @returns MCP servers with their tools
*/
export const useMCPToolsQuery = <TData = t.MCPServersResponse>(
config?: UseQueryOptions<t.MCPServersResponse, unknown, TData>,
): QueryObserverResult<TData> => {
return useQuery<t.MCPServersResponse, unknown, TData>(
[QueryKeys.mcpTools],
() => dataService.getMCPTools(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 5 * 60 * 1000, // 5 minutes
...config,
},
);
};

View file

@ -11,6 +11,6 @@ export * from './connection';
export * from './mutations'; export * from './mutations';
export * from './prompts'; export * from './prompts';
export * from './queries'; export * from './queries';
export * from './mcp';
export * from './roles'; export * from './roles';
export * from './tags'; export * from './tags';
export * from './MCP';

View file

@ -1,29 +0,0 @@
/**
* Dedicated queries for MCP (Model Context Protocol) tools
* Decoupled from regular LibreChat tools
*/
import { useQuery } from '@tanstack/react-query';
import { QueryKeys, dataService } from 'librechat-data-provider';
import type { UseQueryOptions, QueryObserverResult } from '@tanstack/react-query';
import type { MCPServersResponse } from 'librechat-data-provider';
/**
* Hook for fetching MCP-specific tools
* @param config - React Query configuration
* @returns MCP servers with their tools
*/
export const useMCPToolsQuery = <TData = MCPServersResponse>(
config?: UseQueryOptions<MCPServersResponse, unknown, TData>,
): QueryObserverResult<TData> => {
return useQuery<MCPServersResponse, unknown, TData>(
[QueryKeys.mcpTools],
() => dataService.getMCPTools(),
{
refetchOnWindowFocus: false,
refetchOnReconnect: false,
refetchOnMount: false,
staleTime: 5 * 60 * 1000, // 5 minutes
...config,
},
);
};

View file

@ -74,7 +74,6 @@ export function useApplyAgentTemplate() {
return; return;
} }
// Merge model spec fields into ephemeral agent
const mergedAgent = { const mergedAgent = {
...ephemeralAgent, ...ephemeralAgent,
mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])], mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])],
@ -83,7 +82,6 @@ export function useApplyAgentTemplate() {
execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false, execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false,
}; };
// Deduplicate MCP servers
mergedAgent.mcp = [...new Set(mergedAgent.mcp)]; mergedAgent.mcp = [...new Set(mergedAgent.mcp)];
applyAgentTemplate(targetId, sourceId, mergedAgent); applyAgentTemplate(targetId, sourceId, mergedAgent);

View file

@ -5,7 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider';
import type { TStartupConfig, TUser } from 'librechat-data-provider'; import type { TStartupConfig, TUser } from 'librechat-data-provider';
import { cleanupTimestampedStorage } from '~/utils/timestamps'; import { cleanupTimestampedStorage } from '~/utils/timestamps';
import useSpeechSettingsInit from './useSpeechSettingsInit'; import useSpeechSettingsInit from './useSpeechSettingsInit';
import { useMCPToolsQuery } from '~/data-provider'; import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider';
import store from '~/store'; import store from '~/store';
export default function useAppStartup({ export default function useAppStartup({
@ -18,9 +18,10 @@ export default function useAppStartup({
const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset); const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset);
useSpeechSettingsInit(!!user); useSpeechSettingsInit(!!user);
const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery();
useMCPToolsQuery({ useMCPToolsQuery({
enabled: !!startupConfig?.mcpServers && !!user, enabled: !serversLoading && !!loadedServers && Object.keys(loadedServers).length > 0 && !!user,
}); });
/** Clean up old localStorage entries on startup */ /** Clean up old localStorage entries on startup */

View file

@ -6,7 +6,7 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import { ephemeralAgentByConvoId } from '~/store'; import { ephemeralAgentByConvoId } from '~/store';
import { setTimestamp } from '~/utils/timestamps'; import { setTimestamp } from '~/utils/timestamps';
import { useMCPSelect } from '../useMCPSelect'; import { useMCPSelect } from '../useMCPSelect';
import * as dataProvider from '~/data-provider'; import { MCPServerDefinition } from '../useMCPServerManager';
// Mock dependencies // Mock dependencies
jest.mock('~/utils/timestamps', () => ({ jest.mock('~/utils/timestamps', () => ({
@ -15,27 +15,28 @@ jest.mock('~/utils/timestamps', () => ({
jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b))); jest.mock('lodash/isEqual', () => jest.fn((a, b) => JSON.stringify(a) === JSON.stringify(b)));
jest.mock('~/data-provider', () => ({ // Helper to create MCPServerDefinition objects
...jest.requireActual('~/data-provider'), const createMCPServers = (serverNames: string[]): MCPServerDefinition[] => {
useGetStartupConfig: jest.fn(), return serverNames.map((serverName) => ({
})); serverName,
config: {
url: 'http://mcp',
},
effectivePermissions: 15, // All permissions (VIEW=1, EDIT=2, DELETE=4, SHARE=8)
}));
};
const createWrapper = (mcpServers: string[] = []) => { const createWrapper = (mcpServers: string[] = []) => {
// Create a new Jotai store for each test to ensure clean state // Create a new Jotai store for each test to ensure clean state
const store = createStore(); const store = createStore();
const servers = createMCPServers(mcpServers);
// Mock the startup config
(dataProvider.useGetStartupConfig as jest.Mock).mockReturnValue({
data: { mcpServers: Object.fromEntries(mcpServers.map((v) => [v, {}])) },
isLoading: false,
});
const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => (
<RecoilRoot> <RecoilRoot>
<Provider store={store}>{children}</Provider> <Provider store={store}>{children}</Provider>
</RecoilRoot> </RecoilRoot>
); );
return Wrapper; return { Wrapper, servers };
}; };
describe('useMCPSelect', () => { describe('useMCPSelect', () => {
@ -46,8 +47,9 @@ describe('useMCPSelect', () => {
describe('Basic Functionality', () => { describe('Basic Functionality', () => {
it('should initialize with default values', () => { it('should initialize with default values', () => {
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
expect(result.current.mcpValues).toEqual([]); expect(result.current.mcpValues).toEqual([]);
@ -58,16 +60,18 @@ describe('useMCPSelect', () => {
it('should use conversationId when provided', () => { it('should use conversationId when provided', () => {
const conversationId = 'test-convo-123'; const conversationId = 'test-convo-123';
const { result } = renderHook(() => useMCPSelect({ conversationId }), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), {
wrapper: Wrapper,
}); });
expect(result.current.mcpValues).toEqual([]); expect(result.current.mcpValues).toEqual([]);
}); });
it('should use NEW_CONVO constant when conversationId is null', () => { it('should use NEW_CONVO constant when conversationId is null', () => {
const { result } = renderHook(() => useMCPSelect({ conversationId: null }), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ conversationId: null, servers }), {
wrapper: Wrapper,
}); });
expect(result.current.mcpValues).toEqual([]); expect(result.current.mcpValues).toEqual([]);
@ -76,8 +80,9 @@ describe('useMCPSelect', () => {
describe('State Updates', () => { describe('State Updates', () => {
it('should update mcpValues when setMCPValues is called', async () => { it('should update mcpValues when setMCPValues is called', async () => {
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper(['value1', 'value2']);
wrapper: createWrapper(['value1', 'value2']), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
const newValues = ['value1', 'value2']; const newValues = ['value1', 'value2'];
@ -92,8 +97,9 @@ describe('useMCPSelect', () => {
}); });
it('should not update mcpValues if non-array is passed', () => { it('should not update mcpValues if non-array is passed', () => {
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
act(() => { act(() => {
@ -105,8 +111,9 @@ describe('useMCPSelect', () => {
}); });
it('should update isPinned state', () => { it('should update isPinned state', () => {
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
// Default is true // Default is true
@ -131,8 +138,9 @@ describe('useMCPSelect', () => {
describe('Timestamp Management', () => { describe('Timestamp Management', () => {
it('should set timestamp when mcpValues is updated with values', async () => { it('should set timestamp when mcpValues is updated with values', async () => {
const conversationId = 'test-convo'; const conversationId = 'test-convo';
const { result } = renderHook(() => useMCPSelect({ conversationId }), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), {
wrapper: Wrapper,
}); });
const newValues = ['value1', 'value2']; const newValues = ['value1', 'value2'];
@ -148,8 +156,9 @@ describe('useMCPSelect', () => {
}); });
it('should not set timestamp when mcpValues is empty', async () => { it('should not set timestamp when mcpValues is empty', async () => {
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
act(() => { act(() => {
@ -164,8 +173,9 @@ describe('useMCPSelect', () => {
describe('Race Conditions and Infinite Loops Prevention', () => { describe('Race Conditions and Infinite Loops Prevention', () => {
it('should not create infinite loop when syncing between Jotai and Recoil states', async () => { it('should not create infinite loop when syncing between Jotai and Recoil states', async () => {
const { result, rerender } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result, rerender } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
let renderCount = 0; let renderCount = 0;
@ -196,8 +206,9 @@ describe('useMCPSelect', () => {
}); });
it('should handle rapid consecutive updates without race conditions', async () => { it('should handle rapid consecutive updates without race conditions', async () => {
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
const updates = [ const updates = [
@ -222,8 +233,9 @@ describe('useMCPSelect', () => {
}); });
it('should maintain stable setter function reference', () => { it('should maintain stable setter function reference', () => {
const { result, rerender } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result, rerender } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
const firstSetMCPValues = result.current.setMCPValues; const firstSetMCPValues = result.current.setMCPValues;
@ -238,10 +250,11 @@ describe('useMCPSelect', () => {
}); });
it('should handle switching conversation IDs without issues', async () => { it('should handle switching conversation IDs without issues', async () => {
const { Wrapper, servers } = createWrapper(['convo1-value', 'convo2-value']);
const { result, rerender } = renderHook( const { result, rerender } = renderHook(
({ conversationId }) => useMCPSelect({ conversationId }), ({ conversationId }) => useMCPSelect({ conversationId, servers }),
{ {
wrapper: createWrapper(['convo1-value', 'convo2-value']), wrapper: Wrapper,
initialProps: { conversationId: 'convo1' }, initialProps: { conversationId: 'convo1' },
}, },
); );
@ -283,18 +296,18 @@ describe('useMCPSelect', () => {
describe('Ephemeral Agent Synchronization', () => { describe('Ephemeral Agent Synchronization', () => {
it('should sync mcpValues when ephemeralAgent is updated externally', async () => { it('should sync mcpValues when ephemeralAgent is updated externally', async () => {
// Create a shared wrapper for both hooks to share the same Recoil/Jotai context // Create a shared wrapper for both hooks to share the same Recoil/Jotai context
const wrapper = createWrapper(['external-value1', 'external-value2']); const { Wrapper, servers } = createWrapper(['external-value1', 'external-value2']);
// Create a component that uses both hooks to ensure they share state // Create a component that uses both hooks to ensure they share state
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const [ephemeralAgent, setEphemeralAgent] = useRecoilState( const [ephemeralAgent, setEphemeralAgent] = useRecoilState(
ephemeralAgentByConvoId(Constants.NEW_CONVO), ephemeralAgentByConvoId(Constants.NEW_CONVO),
); );
return { mcpHook, ephemeralAgent, setEphemeralAgent }; return { mcpHook, ephemeralAgent, setEphemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
// Simulate external update to ephemeralAgent (e.g., from another component) // Simulate external update to ephemeralAgent (e.g., from another component)
const externalMcpValues = ['external-value1', 'external-value2']; const externalMcpValues = ['external-value1', 'external-value2'];
@ -311,15 +324,15 @@ describe('useMCPSelect', () => {
}); });
it('should filter out MCPs not in configured servers', async () => { it('should filter out MCPs not in configured servers', async () => {
const wrapper = createWrapper(['server1', 'server2']); const { Wrapper, servers } = createWrapper(['server1', 'server2']);
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent }; return { mcpHook, setEphemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
act(() => { act(() => {
result.current.setEphemeralAgent({ result.current.setEphemeralAgent({
@ -333,15 +346,15 @@ describe('useMCPSelect', () => {
}); });
it('should clear all MCPs when none are in configured servers', async () => { it('should clear all MCPs when none are in configured servers', async () => {
const wrapper = createWrapper(['server1', 'server2']); const { Wrapper, servers } = createWrapper(['server1', 'server2']);
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent }; return { mcpHook, setEphemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
act(() => { act(() => {
result.current.setEphemeralAgent({ result.current.setEphemeralAgent({
@ -355,15 +368,15 @@ describe('useMCPSelect', () => {
}); });
it('should keep all MCPs when all are in configured servers', async () => { it('should keep all MCPs when all are in configured servers', async () => {
const wrapper = createWrapper(['server1', 'server2', 'server3']); const { Wrapper, servers } = createWrapper(['server1', 'server2', 'server3']);
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent }; return { mcpHook, setEphemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
act(() => { act(() => {
result.current.setEphemeralAgent({ result.current.setEphemeralAgent({
@ -378,16 +391,16 @@ describe('useMCPSelect', () => {
it('should update ephemeralAgent when mcpValues changes through hook', async () => { it('should update ephemeralAgent when mcpValues changes through hook', async () => {
// Create a shared wrapper for both hooks // Create a shared wrapper for both hooks
const wrapper = createWrapper(['hook-value1', 'hook-value2']); const { Wrapper, servers } = createWrapper(['hook-value1', 'hook-value2']);
// Create a component that uses both the hook and accesses Recoil state // Create a component that uses both the hook and accesses Recoil state
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO)); const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, ephemeralAgent }; return { mcpHook, ephemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
const newValues = ['hook-value1', 'hook-value2']; const newValues = ['hook-value1', 'hook-value2'];
@ -404,16 +417,16 @@ describe('useMCPSelect', () => {
it('should handle empty ephemeralAgent.mcp array correctly', async () => { it('should handle empty ephemeralAgent.mcp array correctly', async () => {
// Create a shared wrapper // Create a shared wrapper
const wrapper = createWrapper(['initial-value']); const { Wrapper, servers } = createWrapper(['initial-value']);
// Create a component that uses both hooks // Create a component that uses both hooks
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent }; return { mcpHook, setEphemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
// Set initial values // Set initial values
act(() => { act(() => {
@ -438,16 +451,16 @@ describe('useMCPSelect', () => {
it('should handle ephemeralAgent with clear mcp value', async () => { it('should handle ephemeralAgent with clear mcp value', async () => {
// Create a shared wrapper // Create a shared wrapper
const wrapper = createWrapper(['server1', 'server2']); const { Wrapper, servers } = createWrapper(['server1', 'server2']);
// Create a component that uses both hooks // Create a component that uses both hooks
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent }; return { mcpHook, setEphemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
// Set initial values // Set initial values
act(() => { act(() => {
@ -473,15 +486,21 @@ describe('useMCPSelect', () => {
it('should properly sync non-empty arrays from ephemeralAgent', async () => { it('should properly sync non-empty arrays from ephemeralAgent', async () => {
// Additional test to ensure non-empty arrays DO sync // Additional test to ensure non-empty arrays DO sync
const wrapper = createWrapper(['value1', 'value2', 'value3', 'value4', 'value5']); const { Wrapper, servers } = createWrapper([
'value1',
'value2',
'value3',
'value4',
'value5',
]);
const TestComponent = () => { const TestComponent = () => {
const mcpHook = useMCPSelect({}); const mcpHook = useMCPSelect({ servers });
const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO));
return { mcpHook, setEphemeralAgent }; return { mcpHook, setEphemeralAgent };
}; };
const { result } = renderHook(() => TestComponent(), { wrapper }); const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper });
// Set initial values through ephemeralAgent with non-empty array // Set initial values through ephemeralAgent with non-empty array
const initialValues = ['value1', 'value2']; const initialValues = ['value1', 'value2'];
@ -513,8 +532,9 @@ describe('useMCPSelect', () => {
describe('Edge Cases', () => { describe('Edge Cases', () => {
it('should handle undefined conversationId', () => { it('should handle undefined conversationId', () => {
const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), { const { Wrapper, servers } = createWrapper(['test']);
wrapper: createWrapper(['test']), const { result } = renderHook(() => useMCPSelect({ conversationId: undefined, servers }), {
wrapper: Wrapper,
}); });
expect(result.current.mcpValues).toEqual([]); expect(result.current.mcpValues).toEqual([]);
@ -527,8 +547,9 @@ describe('useMCPSelect', () => {
}); });
it('should handle empty string conversationId', () => { it('should handle empty string conversationId', () => {
const { result } = renderHook(() => useMCPSelect({ conversationId: '' }), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result } = renderHook(() => useMCPSelect({ conversationId: '', servers }), {
wrapper: Wrapper,
}); });
expect(result.current.mcpValues).toEqual([]); expect(result.current.mcpValues).toEqual([]);
@ -536,8 +557,9 @@ describe('useMCPSelect', () => {
it('should handle very large arrays without performance issues', async () => { it('should handle very large arrays without performance issues', async () => {
const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`); const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`);
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper(largeArray);
wrapper: createWrapper(largeArray), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
const startTime = performance.now(); const startTime = performance.now();
@ -558,8 +580,9 @@ describe('useMCPSelect', () => {
}); });
it('should cleanup properly on unmount', () => { it('should cleanup properly on unmount', () => {
const { unmount } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { unmount } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
// Should unmount without errors // Should unmount without errors
@ -570,8 +593,9 @@ describe('useMCPSelect', () => {
describe('Memory Leak Prevention', () => { describe('Memory Leak Prevention', () => {
it('should not leak memory on repeated updates', async () => { it('should not leak memory on repeated updates', async () => {
const values = Array.from({ length: 100 }, (_, i) => `value-${i}`); const values = Array.from({ length: 100 }, (_, i) => `value-${i}`);
const { result } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper(values);
wrapper: createWrapper(values), const { result } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
// Perform many updates to test for memory leaks // Perform many updates to test for memory leaks
@ -586,8 +610,9 @@ describe('useMCPSelect', () => {
}); });
it('should handle component remounting', () => { it('should handle component remounting', () => {
const { result, unmount } = renderHook(() => useMCPSelect({}), { const { Wrapper, servers } = createWrapper();
wrapper: createWrapper(), const { result, unmount } = renderHook(() => useMCPSelect({ servers }), {
wrapper: Wrapper,
}); });
act(() => { act(() => {
@ -597,8 +622,9 @@ describe('useMCPSelect', () => {
unmount(); unmount();
// Remount // Remount
const { result: newResult } = renderHook(() => useMCPSelect({}), { const { Wrapper: Wrapper2, servers: servers2 } = createWrapper();
wrapper: createWrapper(), const { result: newResult } = renderHook(() => useMCPSelect({ servers: servers2 }), {
wrapper: Wrapper2,
}); });
// Should handle remounting gracefully // Should handle remounting gracefully

View file

@ -4,15 +4,20 @@ import isEqual from 'lodash/isEqual';
import { useRecoilState } from 'recoil'; import { useRecoilState } from 'recoil';
import { Constants, LocalStorageKeys } from 'librechat-data-provider'; import { Constants, LocalStorageKeys } from 'librechat-data-provider';
import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store'; import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store';
import { useGetStartupConfig } from '~/data-provider';
import { setTimestamp } from '~/utils/timestamps'; import { setTimestamp } from '~/utils/timestamps';
import { MCPServerDefinition } from './useMCPServerManager';
export function useMCPSelect({ conversationId }: { conversationId?: string | null }) { export function useMCPSelect({
conversationId,
servers,
}: {
conversationId?: string | null;
servers: MCPServerDefinition[];
}) {
const key = conversationId ?? Constants.NEW_CONVO; const key = conversationId ?? Constants.NEW_CONVO;
const { data: startupConfig } = useGetStartupConfig();
const configuredServers = useMemo(() => { const configuredServers = useMemo(() => {
return new Set(Object.keys(startupConfig?.mcpServers ?? {})); return new Set(servers?.map((s) => s.serverName));
}, [startupConfig?.mcpServers]); }, [servers]);
const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom); const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom);
const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key)); const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key));

View file

@ -1,7 +1,7 @@
import { useCallback, useState, useMemo, useRef, useEffect } from 'react'; import { useCallback, useState, useMemo, useRef, useEffect } from 'react';
import { useToastContext } from '@librechat/client'; import { useToastContext } from '@librechat/client';
import { useQueryClient } from '@tanstack/react-query'; import { useQueryClient } from '@tanstack/react-query';
import { Constants, QueryKeys } from 'librechat-data-provider'; import { Constants, QueryKeys, MCPOptions } from 'librechat-data-provider';
import { import {
useCancelMCPOAuthMutation, useCancelMCPOAuthMutation,
useUpdateUserPluginsMutation, useUpdateUserPluginsMutation,
@ -10,7 +10,15 @@ import {
import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider'; import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider';
import type { ConfigFieldDetail } from '~/common'; import type { ConfigFieldDetail } from '~/common';
import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks'; import { useLocalize, useMCPSelect, useMCPConnectionStatus } from '~/hooks';
import { useGetStartupConfig } from '~/data-provider'; import { useGetStartupConfig, useMCPServersQuery } from '~/data-provider';
export interface MCPServerDefinition {
serverName: string;
config: MCPOptions;
mcp_id?: string;
_id?: string; // MongoDB ObjectId for database servers (used for permissions)
effectivePermissions: number; // Permission bits (VIEW=1, EDIT=2, DELETE=4, SHARE=8)
}
interface ServerState { interface ServerState {
isInitializing: boolean; isInitializing: boolean;
@ -24,12 +32,40 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
const localize = useLocalize(); const localize = useLocalize();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { showToast } = useToastContext(); const { showToast } = useToastContext();
const { data: startupConfig } = useGetStartupConfig(); const { data: startupConfig } = useGetStartupConfig(); // Keep for UI config only
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId });
const { data: loadedServers, isLoading } = useMCPServersQuery();
const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false);
const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null); const [selectedToolForConfig, setSelectedToolForConfig] = useState<TPlugin | null>(null);
const previousFocusRef = useRef<HTMLElement | null>(null); const previousFocusRef = useRef<HTMLElement | null>(null);
const configuredServers = useMemo(() => {
if (!loadedServers) return [];
return Object.keys(loadedServers).filter((name) => loadedServers[name]?.chatMenu !== false);
}, [loadedServers]);
const availableMCPServers: MCPServerDefinition[] = useMemo<MCPServerDefinition[]>(() => {
const definitions: MCPServerDefinition[] = [];
if (loadedServers) {
for (const [serverName, metadata] of Object.entries(loadedServers)) {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const { _id, mcp_id, effectivePermissions, author, updatedAt, createdAt, ...config } =
metadata;
definitions.push({
serverName,
mcp_id,
effectivePermissions: effectivePermissions || 1,
config,
});
}
}
return definitions;
}, [loadedServers]);
const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({
conversationId,
servers: availableMCPServers,
});
const mcpValuesRef = useRef(mcpValues); const mcpValuesRef = useRef(mcpValues);
// fixes the issue where OAuth flows would deselect all the servers except the one that is being authenticated on success // fixes the issue where OAuth flows would deselect all the servers except the one that is being authenticated on success
@ -37,13 +73,6 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
mcpValuesRef.current = mcpValues; mcpValuesRef.current = mcpValues;
}, [mcpValues]); }, [mcpValues]);
const configuredServers = useMemo(() => {
if (!startupConfig?.mcpServers) return [];
return Object.entries(startupConfig.mcpServers)
.filter(([, config]) => config.chatMenu !== false)
.map(([serverName]) => serverName);
}, [startupConfig?.mcpServers]);
const reinitializeMutation = useReinitializeMCPServerMutation(); const reinitializeMutation = useReinitializeMCPServerMutation();
const cancelOAuthMutation = useCancelMCPOAuthMutation(); const cancelOAuthMutation = useCancelMCPOAuthMutation();
@ -52,6 +81,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' });
await Promise.all([ await Promise.all([
queryClient.invalidateQueries([QueryKeys.mcpServers]),
queryClient.invalidateQueries([QueryKeys.mcpTools]), queryClient.invalidateQueries([QueryKeys.mcpTools]),
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]), queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]), queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
@ -81,7 +111,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
}); });
const { connectionStatus } = useMCPConnectionStatus({ const { connectionStatus } = useMCPConnectionStatus({
enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0, enabled: !isLoading && configuredServers.length > 0,
}); });
const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => { const updateServerState = useCallback((serverName: string, updates: Partial<ServerState>) => {
@ -289,7 +319,12 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
startServerPolling(serverName); startServerPolling(serverName);
} else { } else {
await queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]); await Promise.all([
queryClient.invalidateQueries([QueryKeys.mcpServers]),
queryClient.invalidateQueries([QueryKeys.mcpTools]),
queryClient.invalidateQueries([QueryKeys.mcpAuthValues]),
queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]),
]);
showToast({ showToast({
message: localize('com_ui_mcp_initialized_success', { 0: serverName }), message: localize('com_ui_mcp_initialized_success', { 0: serverName }),
@ -494,7 +529,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
]); ]);
const serverData = mcpData?.servers?.[serverName]; const serverData = mcpData?.servers?.[serverName];
const serverStatus = connectionStatus?.[serverName]; const serverStatus = connectionStatus?.[serverName];
const serverConfig = startupConfig?.mcpServers?.[serverName]; const serverConfig = loadedServers?.[serverName];
const handleConfigClick = (e: React.MouseEvent) => { const handleConfigClick = (e: React.MouseEvent) => {
e.stopPropagation(); e.stopPropagation();
@ -548,14 +583,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
hasCustomUserVars, hasCustomUserVars,
}; };
}, },
[ [queryClient, isCancellable, isInitializing, cancelOAuthFlow, connectionStatus, loadedServers],
queryClient,
isCancellable,
isInitializing,
cancelOAuthFlow,
connectionStatus,
startupConfig?.mcpServers,
],
); );
const getConfigDialogProps = useCallback(() => { const getConfigDialogProps = useCallback(() => {
@ -600,7 +628,10 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin
]); ]);
return { return {
configuredServers, availableMCPServers,
availableMCPServersMap: loadedServers,
isLoading,
connectionStatus,
initializeServer, initializeServer,
cancelOAuthFlow, cancelOAuthFlow,
isInitializing, isInitializing,

View file

@ -19,8 +19,7 @@ import PromptsAccordion from '~/components/Prompts/PromptsAccordion';
import Parameters from '~/components/SidePanel/Parameters/Panel'; import Parameters from '~/components/SidePanel/Parameters/Panel';
import FilesPanel from '~/components/SidePanel/Files/Panel'; import FilesPanel from '~/components/SidePanel/Files/Panel';
import MCPPanel from '~/components/SidePanel/MCP/MCPPanel'; import MCPPanel from '~/components/SidePanel/MCP/MCPPanel';
import { useGetStartupConfig } from '~/data-provider'; import { useHasAccess, useMCPServerManager } from '~/hooks';
import { useHasAccess } from '~/hooks';
export default function useSideNavLinks({ export default function useSideNavLinks({
hidePanel, hidePanel,
@ -61,7 +60,8 @@ export default function useSideNavLinks({
permissionType: PermissionTypes.AGENTS, permissionType: PermissionTypes.AGENTS,
permission: Permissions.CREATE, permission: Permissions.CREATE,
}); });
const { data: startupConfig } = useGetStartupConfig();
const { availableMCPServers } = useMCPServerManager();
const Links = useMemo(() => { const Links = useMemo(() => {
const links: NavLink[] = []; const links: NavLink[] = [];
@ -153,12 +153,12 @@ export default function useSideNavLinks({
} }
if ( if (
startupConfig?.mcpServers && availableMCPServers &&
Object.values(startupConfig.mcpServers).some( availableMCPServers.some(
(server: any) => (server: any) =>
(server.customUserVars && Object.keys(server.customUserVars).length > 0) || (server.config.customUserVars && Object.keys(server.config.customUserVars).length > 0) ||
server.isOAuth || server.config.isOAuth ||
server.startup === false, server.config.startup === false,
) )
) { ) {
links.push({ links.push({
@ -192,7 +192,7 @@ export default function useSideNavLinks({
hasAccessToBookmarks, hasAccessToBookmarks,
hasAccessToCreateAgents, hasAccessToCreateAgents,
hidePanel, hidePanel,
startupConfig, availableMCPServers,
]); ]);
return Links; return Links;

View file

@ -58,6 +58,10 @@
"e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets", "e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets",
"test:client": "cd client && npm run test:ci", "test:client": "cd client && npm run test:ci",
"test:api": "cd api && npm run test:ci", "test:api": "cd api && npm run test:ci",
"test:packages:api": "cd packages/api && npm run test:ci",
"test:packages:data-provider": "cd packages/data-provider && npm run test:ci",
"test:packages:data-schemas": "cd packages/data-schemas && npm run test:ci",
"test:all": "npm run test:client && npm run test:api && npm run test:packages:api && npm run test:packages:data-provider && npm run test:packages:data-schemas",
"e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots", "e2e:update": "playwright test --config=e2e/playwright.config.js --update-snapshots",
"e2e:report": "npx playwright show-report e2e/playwright-report", "e2e:report": "npx playwright show-report e2e/playwright-report",
"lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"", "lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"",

View file

@ -34,9 +34,6 @@ export class MCPServersInitializer {
public static async initialize(rawConfigs: t.MCPServers): Promise<void> { public static async initialize(rawConfigs: t.MCPServers): Promise<void> {
if (await statusCache.isInitialized()) return; if (await statusCache.isInitialized()) return;
/** Store raw configs immediately so they're available even if initialization fails/is slow */
registry.setRawConfigs(rawConfigs);
if (await isLeader()) { if (await isLeader()) {
// Leader performs initialization // Leader performs initialization
await statusCache.reset(); await statusCache.reset();

View file

@ -33,14 +33,6 @@ class MCPServersRegistry {
false, false,
); );
/**
* Stores the raw MCP configuration as a fallback when servers haven't been initialized yet.
* Should be called during initialization before inspecting servers.
*/
public setRawConfigs(configs: t.MCPServers): void {
this.rawConfigs = configs;
}
public readonly privateServersCache: PrivateServerConfigsCache = public readonly privateServersCache: PrivateServerConfigsCache =
PrivateServerConfigsCacheFactory.create(); PrivateServerConfigsCacheFactory.create();

View file

@ -228,6 +228,7 @@ export const agents = ({ path = '', options }: { path?: string; options?: object
export const mcp = { export const mcp = {
tools: `${BASE_URL}/api/mcp/tools`, tools: `${BASE_URL}/api/mcp/tools`,
servers: `${BASE_URL}/api/mcp/servers`,
}; };
export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`; export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`;

View file

@ -6,6 +6,7 @@ import * as ag from './types/agents';
import * as m from './types/mutations'; import * as m from './types/mutations';
import * as q from './types/queries'; import * as q from './types/queries';
import * as f from './types/files'; import * as f from './types/files';
import * as mcp from './types/mcpServers';
import * as config from './config'; import * as config from './config';
import request from './request'; import request from './request';
import * as s from './schemas'; import * as s from './schemas';
@ -538,6 +539,18 @@ export const deleteAgentAction = async ({
}), }),
); );
/**
* MCP Servers
*/
/**
*
* Ensure and List loaded mcp server configs from the cache Enriched with effective permissions.
*/
export const getMCPServers = async (): Promise<mcp.MCPServersListResponse> => {
return request.get(endpoints.mcp.servers);
};
/** /**
* Imports a conversations file. * Imports a conversations file.
* *

View file

@ -57,6 +57,8 @@ export enum QueryKeys {
resourcePermissions = 'resourcePermissions', resourcePermissions = 'resourcePermissions',
effectivePermissions = 'effectivePermissions', effectivePermissions = 'effectivePermissions',
graphToken = 'graphToken', graphToken = 'graphToken',
/* MCP Servers */
mcpServers = 'mcpServers',
} }
// Dynamic query keys that require parameters // Dynamic query keys that require parameters

View file

@ -180,3 +180,32 @@ export const MCPOptionsSchema = z.union([
export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema); export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema);
export type MCPOptions = z.infer<typeof MCPOptionsSchema>; export type MCPOptions = z.infer<typeof MCPOptionsSchema>;
/**
* Helper to omit server-managed fields that should not come from UI
*/
const omitServerManagedFields = <T extends z.ZodObject<z.ZodRawShape>>(schema: T) =>
schema.omit({
startup: true,
timeout: true,
initTimeout: true,
chatMenu: true,
serverInstructions: true,
requiresOAuth: true,
customUserVars: true,
oauth_headers: true,
});
/**
* MCP Server configuration that comes from UI input only
* Omits server-managed fields like startup, timeout, customUserVars, etc.
* Allows: title, description, url, iconPath, oauth (user credentials)
*/
export const MCPServerUserInputSchema = z.union([
omitServerManagedFields(StdioOptionsSchema),
omitServerManagedFields(WebSocketOptionsSchema),
omitServerManagedFields(SSEOptionsSchema),
omitServerManagedFields(StreamableHTTPOptionsSchema),
]);
export type MCPServerUserInput = z.infer<typeof MCPServerUserInputSchema>;

View file

@ -1,4 +1,5 @@
import type { MCPOptions } from '../mcp'; import { PermissionBits } from '../accessPermissions';
import type { MCPOptions, MCPServerUserInput } from '../mcp';
/** /**
* Base MCP Server interface * Base MCP Server interface
@ -19,3 +20,33 @@ export interface IMCPServerDB {
* Similar to Agent type - includes populated author fields * Similar to Agent type - includes populated author fields
*/ */
export type MCPServerDB = IMCPServerDB; export type MCPServerDB = IMCPServerDB;
/**
* Parameters for creating a new user-managed MCP server
* Note: Only UI-editable fields are allowed (excludes server-managed fields)
*/
export type MCPServerCreateParams = {
config: MCPServerUserInput; // UI fields only (title, description, url, oauth, iconPath)
};
/**
* Parameters for updating an existing user-managed MCP server
* Note: Only UI-editable fields are allowed (excludes server-managed fields)
*/
export type MCPServerUpdateParams = {
config?: MCPServerUserInput; // UI fields only (title, description, url, oauth, iconPath)
};
/**
* Response for MCP server list endpoint
*/
export type MCPServerDBObjectResponse = {
_id?: string;
mcp_id?: string;
author?: string | null;
createdAt?: Date;
updatedAt?: Date;
effectivePermissions?: PermissionBits;
} & MCPOptions;
export type MCPServersListResponse = Record<string, MCPServerDBObjectResponse>;