From ef1b7f0157091f56ad1e6c4fd8133e4c191eed12 Mon Sep 17 00:00:00 2001 From: Atef Bellaaj Date: Wed, 26 Nov 2025 21:26:40 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=A7=A9=20refactor:=20Decouple=20MCP=20Con?= =?UTF-8?q?fig=20from=20Startup=20Config=20(#10689)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 Co-authored-by: Danny Avila --- .github/workflows/frontend-review.yml | 1 + api/app/clients/tools/util/handleTools.js | 3 +- api/server/controllers/mcp.js | 37 +++- api/server/routes/__tests__/mcp.spec.js | 59 ++++++ api/server/routes/config.js | 45 +---- api/server/routes/mcp.js | 11 +- api/server/services/MCP.js | 21 ++- api/server/services/MCP.spec.js | 90 +++++++-- api/server/services/ToolService.js | 1 + client/src/Providers/AgentPanelContext.tsx | 23 ++- client/src/common/types.ts | 3 + .../Bookmarks/__tests__/BookmarkForm.test.tsx | 10 +- .../src/components/Chat/Input/MCPSelect.tsx | 8 +- .../src/components/Chat/Input/MCPSubMenu.tsx | 18 +- .../MCP/ServerInitializationSection.tsx | 13 +- .../SidePanel/Agents/AgentConfig.tsx | 6 +- .../src/components/SidePanel/MCP/MCPPanel.tsx | 34 +--- .../components/Tools/MCPToolSelectDialog.tsx | 6 +- client/src/data-provider/MCP/index.ts | 1 + client/src/data-provider/MCP/queries.ts | 44 +++++ client/src/data-provider/index.ts | 2 +- client/src/data-provider/mcp.ts | 29 --- .../hooks/Agents/useApplyModelSpecAgents.ts | 2 - client/src/hooks/Config/useAppStartup.ts | 5 +- .../hooks/MCP/__tests__/useMCPSelect.test.tsx | 174 ++++++++++-------- client/src/hooks/MCP/useMCPSelect.ts | 15 +- client/src/hooks/MCP/useMCPServerManager.ts | 77 +++++--- client/src/hooks/Nav/useSideNavLinks.ts | 18 +- package.json | 4 + .../src/mcp/registry/MCPServersInitializer.ts | 3 - .../src/mcp/registry/MCPServersRegistry.ts | 8 - packages/data-provider/src/api-endpoints.ts | 1 + packages/data-provider/src/data-service.ts | 13 ++ packages/data-provider/src/keys.ts | 2 + packages/data-provider/src/mcp.ts | 29 +++ .../data-provider/src/types/mcpServers.ts | 33 +++- 36 files changed, 548 insertions(+), 301 deletions(-) create mode 100644 client/src/data-provider/MCP/index.ts create mode 100644 client/src/data-provider/MCP/queries.ts delete mode 100644 client/src/data-provider/mcp.ts diff --git a/.github/workflows/frontend-review.yml b/.github/workflows/frontend-review.yml index 7064c18c13..907d07d307 100644 --- a/.github/workflows/frontend-review.yml +++ b/.github/workflows/frontend-review.yml @@ -5,6 +5,7 @@ on: branches: - main - dev + - dev-staging - release/* paths: - 'client/**' diff --git a/api/app/clients/tools/util/handleTools.js b/api/app/clients/tools/util/handleTools.js index 8a3e09760c..550e362701 100644 --- a/api/app/clients/tools/util/handleTools.js +++ b/api/app/clients/tools/util/handleTools.js @@ -10,6 +10,7 @@ const { createSafeUser, mcpToolPattern, loadWebSearchAuth, + mcpServersRegistry, } = require('@librechat/api'); const { Tools, @@ -347,7 +348,7 @@ Anchor pattern: \\ue202turn{N}{type}{index} where N=turn number, type=search|new /** Placeholder used for UI purposes */ continue; } - if (serverName && options.req?.config?.mcpConfig?.[serverName] == null) { + if (serverName && (await mcpServersRegistry.getServerConfig(serverName, user)) == undefined) { logger.warn( `MCP server "${serverName}" for "${toolName}" tool is not configured${agent?.id != null && agent.id ? ` but attached to "${agent.id}"` : ''}`, ); diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js index 5bc6f8f23c..2a557e5a8b 100644 --- a/api/server/controllers/mcp.js +++ b/api/server/controllers/mcp.js @@ -4,11 +4,7 @@ */ const { logger } = require('@librechat/data-schemas'); const { Constants } = require('librechat-data-provider'); -const { - cacheMCPServerTools, - getMCPServerTools, - getAppConfig, -} = require('~/server/services/Config'); +const { cacheMCPServerTools, getMCPServerTools } = require('~/server/services/Config'); const { getMCPManager } = require('~/config'); const { mcpServersRegistry } = require('@librechat/api'); @@ -23,13 +19,14 @@ const getMCPTools = async (req, res) => { return res.status(401).json({ message: 'Unauthorized' }); } - const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role })); - if (!appConfig?.mcpConfig) { + const mcpConfig = await mcpServersRegistry.getAllServerConfigs(userId); + const configuredServers = mcpConfig ? Object.keys(mcpConfig) : []; + + if (!mcpConfig || Object.keys(mcpConfig).length == 0) { return res.status(200).json({ servers: {} }); } const mcpManager = getMCPManager(); - const configuredServers = Object.keys(appConfig.mcpConfig); const mcpServers = {}; const cachePromises = configuredServers.map((serverName) => @@ -71,7 +68,7 @@ const getMCPTools = async (req, res) => { const serverTools = serverToolsMap.get(serverName); // Get server config once - const serverConfig = appConfig.mcpConfig[serverName]; + const serverConfig = mcpConfig[serverName]; const rawServerConfig = await mcpServersRegistry.getServerConfig(serverName, userId); // Initialize server object with all server-level data @@ -127,7 +124,29 @@ const getMCPTools = async (req, res) => { 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 = { getMCPTools, + getMCPServersList, }; diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index ae87a6d900..5fb8b86695 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -18,6 +18,7 @@ jest.mock('@librechat/api', () => ({ mcpServersRegistry: { getServerConfig: jest.fn(), getOAuthServers: jest.fn(), + getAllServerConfigs: jest.fn(), }, })); @@ -1415,4 +1416,62 @@ describe('MCP Routes', () => { 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' }); + }); + }); }); diff --git a/api/server/routes/config.js b/api/server/routes/config.js index 6f97639dd1..a2dc5b79d2 100644 --- a/api/server/routes/config.js +++ b/api/server/routes/config.js @@ -1,18 +1,11 @@ const express = require('express'); const { logger } = require('@librechat/data-schemas'); const { isEnabled, getBalanceConfig } = require('@librechat/api'); -const { - Constants, - CacheKeys, - removeNullishValues, - defaultSocialLogins, -} = require('librechat-data-provider'); +const { Constants, CacheKeys, defaultSocialLogins } = require('librechat-data-provider'); const { getLdapConfig } = require('~/server/services/Config/ldap'); const { getAppConfig } = require('~/server/services/Config/app'); const { getProjectByName } = require('~/models/Project'); -const { getMCPManager } = require('~/config'); const { getLogStores } = require('~/cache'); -const { mcpServersRegistry } = require('@librechat/api'); const router = express.Router(); const emailLoginEnabled = @@ -30,46 +23,11 @@ const publicSharedLinksEnabled = const sharePointFilePickerEnabled = isEnabled(process.env.ENABLE_SHAREPOINT_FILEPICKER); 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) { const cache = getLogStores(CacheKeys.CONFIG_STORE); const cachedStartupConfig = await cache.get(CacheKeys.STARTUP_CONFIG); if (cachedStartupConfig) { - const appConfig = await getAppConfig({ role: req.user?.role }); - await getMCPServers(cachedStartupConfig, appConfig); res.send(cachedStartupConfig); return; } @@ -190,7 +148,6 @@ router.get('/', async function (req, res) { } await cache.set(CacheKeys.STARTUP_CONFIG, payload); - await getMCPServers(payload, appConfig); return res.status(200).send(payload); } catch (err) { logger.error('Error in startup config', err); diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index 39c4f4fa43..851255b59d 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -14,6 +14,7 @@ const { findToken, updateToken, createToken, deleteTokens } = require('~/models' const { getUserPluginAuthValue } = require('~/server/services/PluginService'); const { updateMCPServerTools } = require('~/server/services/Config/mcp'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); +const { getMCPServersList } = require('~/server/controllers/mcp'); const { getMCPTools } = require('~/server/controllers/mcp'); const { requireJwtAuth } = require('~/server/middleware'); const { findPluginAuthsByKeys } = require('~/models'); @@ -428,11 +429,12 @@ router.get('/connection/status', requireJwtAuth, async (req, res) => { ); const connectionStatus = {}; - for (const [serverName] of Object.entries(mcpConfig)) { + for (const [serverName, config] of Object.entries(mcpConfig)) { try { connectionStatus[serverName] = await getServerConnectionStatus( user.id, serverName, + config, appConnections, userConnections, oauthServers, @@ -487,6 +489,7 @@ router.get('/connection/status/:serverName', requireJwtAuth, async (req, res) => const serverStatus = await getServerConnectionStatus( user.id, serverName, + mcpConfig[serverName], appConnections, userConnections, oauthServers, @@ -566,5 +569,11 @@ async function getOAuthHeaders(serverName, userId) { const serverConfig = await mcpServersRegistry.getServerConfig(serverName, userId); 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; diff --git a/api/server/services/MCP.js b/api/server/services/MCP.js index e91e5e7904..1e261bdacf 100644 --- a/api/server/services/MCP.js +++ b/api/server/services/MCP.js @@ -435,8 +435,7 @@ function createToolInstance({ res, toolName, serverName, toolDefinition, provide * @returns {Object} Object containing mcpConfig, appConnections, userConnections, and oauthServers */ async function getMCPSetupData(userId) { - const config = await getAppConfig(); - const mcpConfig = config?.mcpConfig; + const mcpConfig = await mcpServersRegistry.getAllServerConfigs(userId); if (!mcpConfig) { 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); } const userConnections = mcpManager.getUserConnections(userId) || new Map(); - const oauthServers = await mcpServersRegistry.getOAuthServers(); + const oauthServers = await mcpServersRegistry.getOAuthServers(userId); return { mcpConfig, @@ -524,24 +523,26 @@ async function checkOAuthFlowStatus(userId, serverName) { * Get connection status for a specific MCP server * @param {string} userId - The user ID * @param {string} serverName - The server name - * @param {Map} appConnections - App-level connections - * @param {Map} userConnections - User-level connections + * @param {import('@librechat/api').ParsedServerConfig} config - The server configuration + * @param {Map} appConnections - App-level connections + * @param {Map} userConnections - User-level connections * @param {Set} oauthServers - Set of OAuth servers * @returns {Object} Object containing requiresOAuth and connectionState */ async function getServerConnectionStatus( userId, serverName, + config, appConnections, userConnections, oauthServers, ) { - const getConnectionState = () => - appConnections.get(serverName)?.connectionState ?? - userConnections.get(serverName)?.connectionState ?? - 'disconnected'; + const connection = appConnections.get(serverName) || userConnections.get(serverName); + const isStaleOrDoNotExist = connection ? connection?.isStale(config.lastUpdatedAt) : true; - const baseConnectionState = getConnectionState(); + const baseConnectionState = isStaleOrDoNotExist + ? 'disconnected' + : connection?.connectionState || 'disconnected'; let finalConnectionState = baseConnectionState; // connection state overrides specific to OAuth servers diff --git a/api/server/services/MCP.spec.js b/api/server/services/MCP.spec.js index 18857c4893..8f7c467743 100644 --- a/api/server/services/MCP.spec.js +++ b/api/server/services/MCP.spec.js @@ -52,6 +52,7 @@ jest.mock('@librechat/api', () => ({ convertWithResolvedRefs: jest.fn((params) => params), mcpServersRegistry: { 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', () => { const mockUserId = 'user-123'; const mockConfig = { - mcpServers: { - server1: { type: 'stdio' }, - server2: { type: 'http' }, - }, + server1: { type: 'stdio' }, + server2: { type: 'http' }, }; - let mockGetAppConfig; beforeEach(() => { - mockGetAppConfig = require('./Config').getAppConfig; mockGetMCPManager.mockReturnValue({ appConnections: { getAll: jest.fn(() => new Map()) }, getUserConnections: jest.fn(() => new Map()), }); mockMcpServersRegistry.getOAuthServers.mockResolvedValue(new Set()); + mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig); }); 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 mockUserConnections = new Map([['server2', { status: 'disconnected' }]]); const mockOAuthServers = new Set(['server2']); const mockMCPManager = { - appConnections: { getAll: jest.fn(() => mockAppConnections) }, + appConnections: { getAll: jest.fn(() => Promise.resolve(mockAppConnections)) }, getUserConnections: jest.fn(() => mockUserConnections), }; 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); - expect(mockGetAppConfig).toHaveBeenCalled(); + expect(mockMcpServersRegistry.getAllServerConfigs).toHaveBeenCalledWith(mockUserId); expect(mockGetMCPManager).toHaveBeenCalledWith(mockUserId); expect(mockMCPManager.appConnections.getAll).toHaveBeenCalled(); expect(mockMCPManager.getUserConnections).toHaveBeenCalledWith(mockUserId); - expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalled(); + expect(mockMcpServersRegistry.getOAuthServers).toHaveBeenCalledWith(mockUserId); expect(result).toEqual({ - mcpConfig: mockConfig.mcpServers, + mcpConfig: mockConfig, appConnections: mockAppConnections, userConnections: mockUserConnections, 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 () => { - mockGetAppConfig.mockResolvedValue({}); + mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(null); await expect(getMCPSetupData(mockUserId)).rejects.toThrow('MCP config not found'); }); it('should handle null values from MCP manager gracefully', async () => { - mockGetAppConfig.mockResolvedValue({ mcpConfig: mockConfig.mcpServers }); + mockMcpServersRegistry.getAllServerConfigs.mockResolvedValue(mockConfig); const mockMCPManager = { - appConnections: { getAll: jest.fn(() => null) }, + appConnections: { getAll: jest.fn(() => Promise.resolve(null)) }, getUserConnections: jest.fn(() => null), }; 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); expect(result).toEqual({ - mcpConfig: mockConfig.mcpServers, + mcpConfig: mockConfig, appConnections: new Map(), userConnections: new Map(), oauthServers: new Set(), @@ -329,15 +327,25 @@ describe('tests for the new helper functions used by the MCP connection status e describe('getServerConnectionStatus', () => { const mockUserId = 'user-123'; const mockServerName = 'test-server'; + const mockConfig = { lastUpdatedAt: Date.now() }; 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 oauthServers = new Set(); const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, 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 () => { 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 result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -376,6 +393,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, 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 () => { - const appConnections = new Map([[mockServerName, { connectionState: 'connected' }]]); - const userConnections = new Map([[mockServerName, { connectionState: 'disconnected' }]]); + const appConnections = new Map([ + [ + mockServerName, + { + connectionState: 'connected', + isStale: jest.fn(() => false), + }, + ], + ]); + const userConnections = new Map([ + [ + mockServerName, + { + connectionState: 'disconnected', + isStale: jest.fn(() => false), + }, + ], + ]); const oauthServers = new Set(); const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -420,6 +455,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -454,6 +490,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -491,6 +528,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -524,6 +562,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -549,6 +588,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -571,13 +611,22 @@ describe('tests for the new helper functions used by the MCP connection status e mockGetFlowStateManager.mockReturnValue(mockFlowManager); 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 oauthServers = new Set([mockServerName]); const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, @@ -606,6 +655,7 @@ describe('tests for the new helper functions used by the MCP connection status e const result = await getServerConnectionStatus( mockUserId, mockServerName, + mockConfig, appConnections, userConnections, oauthServers, diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index fda896ae0b..352f573aaa 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -427,6 +427,7 @@ async function loadAgentTools({ req, res, agent, signal, tool_resources, openAIA /** @type {Record>} */ let userMCPAuthMap; + //TODO pass config from registry if (hasCustomUserVars(req.config)) { userMCPAuthMap = await getUserMCPAuthMap({ tools: agent.tools, diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 4effd7d679..cb0fbe9dc9 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -8,7 +8,12 @@ import { useGetStartupConfig, useMCPToolsQuery, } from '~/data-provider'; -import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks'; +import { + useLocalize, + useGetAgentsConfig, + useMCPConnectionStatus, + useMCPServerManager, +} from '~/hooks'; import { Panel, isEphemeralAgent } from '~/common'; const AgentPanelContext = createContext(undefined); @@ -29,7 +34,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) const [action, setAction] = useState(undefined); const [activePanel, setActivePanel] = useState(Panel.builder); const [agent_id, setCurrentAgentId] = useState(undefined); - + const { availableMCPServers, isLoading, availableMCPServersMap } = useMCPServerManager(); const { data: startupConfig } = useGetStartupConfig(); const { data: actions } = useGetActionsQuery(EModelEndpoint.agents, { enabled: !isEphemeralAgent(agent_id), @@ -38,19 +43,23 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents); 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 mcpServerNames = useMemo( - () => Object.keys(startupConfig?.mcpServers ?? {}), - [startupConfig], + () => availableMCPServers.map((s) => s.serverName), + [availableMCPServers], ); const { connectionStatus } = useMCPConnectionStatus({ enabled: !isEphemeralAgent(agent_id) && mcpServerNames.length > 0, }); - + //TODO to refactor when tools come from tool box const mcpServersMap = useMemo(() => { const configuredServers = new Set(mcpServerNames); const serversMap = new Map(); @@ -127,6 +136,8 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) setActivePanel, endpointsConfig, setCurrentAgentId, + availableMCPServers, + availableMCPServersMap, }; return {children}; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 9b0a21098a..ec9876c9e4 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -7,6 +7,7 @@ import type { ColumnDef } from '@tanstack/react-table'; import type * as t from 'librechat-data-provider'; import type { LucideIcon } from 'lucide-react'; import type { TranslationKeys } from '~/hooks'; +import { MCPServerDefinition } from '~/hooks/MCP/useMCPServerManager'; export function isEphemeralAgent(agentId: string | null | undefined): boolean { return agentId == null || agentId === '' || agentId === Constants.EPHEMERAL_AGENT_ID; @@ -246,6 +247,8 @@ export type AgentPanelContextType = { endpointsConfig?: t.TEndpointsConfig | null; /** Pre-computed MCP server information indexed by server key */ mcpServersMap: Map; + availableMCPServers: MCPServerDefinition[]; + availableMCPServersMap: t.MCPServersListResponse | undefined; }; export type AgentModelPanelProps = { diff --git a/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx b/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx index 276dd7debd..bcc9788fe8 100644 --- a/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx +++ b/client/src/components/Bookmarks/__tests__/BookmarkForm.test.tsx @@ -223,7 +223,7 @@ describe('BookmarkForm - Bookmark Editing', () => { />, ); - const tagInput = screen.getByLabelText('Edit Bookmark'); + const tagInput = screen.getByLabelText('Title'); await act(async () => { 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 () => { 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 () => { 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 () => { 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 () => { fireEvent.change(tagInput, { target: { value: 'New Tag' } }); diff --git a/client/src/components/Chat/Input/MCPSelect.tsx b/client/src/components/Chat/Input/MCPSelect.tsx index 5d088d26a1..f634d3b11e 100644 --- a/client/src/components/Chat/Input/MCPSelect.tsx +++ b/client/src/components/Chat/Input/MCPSelect.tsx @@ -12,10 +12,10 @@ function MCPSelectContent() { mcpValues, isInitializing, placeholderText, - configuredServers, batchToggleServers, getConfigDialogProps, getServerStatusIconProps, + availableMCPServers, } = mcpServerManager; const renderSelectedValues = useCallback( @@ -78,7 +78,7 @@ function MCPSelectContent() { return ( <> s.serverName)} selectedValues={mcpValues ?? []} setSelectedValues={batchToggleServers} renderSelectedValues={renderSelectedValues} @@ -99,9 +99,9 @@ function MCPSelectContent() { function MCPSelect() { const { mcpServerManager } = useBadgeRowContext(); - const { configuredServers } = mcpServerManager; + const { availableMCPServers } = mcpServerManager; - if (!configuredServers || configuredServers.length === 0) { + if (!availableMCPServers || availableMCPServers.length === 0) { return null; } diff --git a/client/src/components/Chat/Input/MCPSubMenu.tsx b/client/src/components/Chat/Input/MCPSubMenu.tsx index b329b5cf6c..5c07785c9c 100644 --- a/client/src/components/Chat/Input/MCPSubMenu.tsx +++ b/client/src/components/Chat/Input/MCPSubMenu.tsx @@ -20,7 +20,7 @@ const MCPSubMenu = React.forwardRef( setIsPinned, isInitializing, placeholderText, - configuredServers, + availableMCPServers, getConfigDialogProps, toggleServerSelection, getServerStatusIconProps, @@ -33,7 +33,7 @@ const MCPSubMenu = React.forwardRef( }); // Don't render if no MCP servers are configured - if (!configuredServers || configuredServers.length === 0) { + if (!availableMCPServers || availableMCPServers.length === 0) { return null; } @@ -85,19 +85,19 @@ const MCPSubMenu = React.forwardRef( 'border border-border-light bg-surface-secondary p-1 shadow-lg', )} > - {configuredServers.map((serverName) => { - const statusIconProps = getServerStatusIconProps(serverName); - const isSelected = mcpValues?.includes(serverName) ?? false; - const isServerInitializing = isInitializing(serverName); + {availableMCPServers.map((s) => { + const statusIconProps = getServerStatusIconProps(s.serverName); + const isSelected = mcpValues?.includes(s.serverName) ?? false; + const isServerInitializing = isInitializing(s.serverName); const statusIcon = statusIconProps && ; return ( { event.preventDefault(); - toggleServerSelection(serverName); + toggleServerSelection(s.serverName); }} disabled={isServerInitializing} className={cn( @@ -112,7 +112,7 @@ const MCPSubMenu = React.forwardRef( >
- {serverName} + {s.serverName}
{statusIcon &&
{statusIcon}
}
diff --git a/client/src/components/MCP/ServerInitializationSection.tsx b/client/src/components/MCP/ServerInitializationSection.tsx index aae8205db0..d736b5cd7b 100644 --- a/client/src/components/MCP/ServerInitializationSection.tsx +++ b/client/src/components/MCP/ServerInitializationSection.tsx @@ -21,12 +21,17 @@ export default function ServerInitializationSection({ }: ServerInitializationSectionProps) { const localize = useLocalize(); - const { initializeServer, cancelOAuthFlow, isInitializing, isCancellable, getOAuthUrl } = - useMCPServerManager({ conversationId }); + const { + initializeServer, + availableMCPServers, + cancelOAuthFlow, + isInitializing, + isCancellable, + getOAuthUrl, + } = useMCPServerManager({ conversationId }); - const { data: startupConfig } = useGetStartupConfig(); const { connectionStatus } = useMCPConnectionStatus({ - enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0, + enabled: !!availableMCPServers && availableMCPServers.length > 0, }); const serverStatus = connectionStatus?.[serverName]; diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 4c4890c82f..940607549c 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -49,7 +49,7 @@ export default function AgentConfig() { setAction, regularTools, agentsConfig, - startupConfig, + availableMCPServers, mcpServersMap, setActivePanel, endpointsConfig, @@ -305,7 +305,7 @@ export default function AgentConfig() { )} {/* MCP Section */} - {startupConfig?.mcpServers != null && ( + {availableMCPServers != null && availableMCPServers.length > 0 && ( - {startupConfig?.mcpServers != null && ( + {availableMCPServers != null && availableMCPServers.length > 0 && ( 0, + const { availableMCPServers, isLoading, connectionStatus } = useMCPServerManager({ + conversationId, }); const [selectedServerNameForEditing, setSelectedServerNameForEditing] = useState( @@ -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) => { setSelectedServerNameForEditing(serverName); }; @@ -94,11 +78,11 @@ function MCPPanelContent() { [updateUserPluginsMutation], ); - if (startupConfigLoading) { + if (isLoading) { return ; } - if (mcpServerDefinitions.length === 0) { + if (availableMCPServers.length === 0) { return (
{localize('com_sidepanel_mcp_no_servers_with_vars')} @@ -108,7 +92,7 @@ function MCPPanelContent() { if (selectedServerNameForEditing) { // Editing View - const serverBeingEdited = mcpServerDefinitions.find( + const serverBeingEdited = availableMCPServers.find( (s) => s.serverName === selectedServerNameForEditing, ); @@ -140,7 +124,7 @@ function MCPPanelContent() {
{ if (selectedServerNameForEditing) { handleConfigSave(selectedServerNameForEditing, authData); @@ -184,7 +168,7 @@ function MCPPanelContent() { return (
- {mcpServerDefinitions.map((server) => { + {availableMCPServers.map((server) => { const serverStatus = connectionStatus?.[server.serverName]; const isConnected = serverStatus?.connectionState === 'connected'; diff --git a/client/src/components/Tools/MCPToolSelectDialog.tsx b/client/src/components/Tools/MCPToolSelectDialog.tsx index b628f6a41a..1b6936a959 100644 --- a/client/src/components/Tools/MCPToolSelectDialog.tsx +++ b/client/src/components/Tools/MCPToolSelectDialog.tsx @@ -34,7 +34,7 @@ function MCPToolSelectDialog({ const { initializeServer } = useMCPServerManager(); const { getValues, setValue } = useFormContext(); const { removeTool } = useRemoveMCPTool({ showToast: false }); - const { mcpServersMap, startupConfig } = useAgentPanelContext(); + const { mcpServersMap, availableMCPServersMap } = useAgentPanelContext(); const { refetch: refetchMCPTools } = useMCPToolsQuery({ enabled: mcpServersMap.size > 0, }); @@ -191,7 +191,7 @@ function MCPToolSelectDialog({ return; } - const serverConfig = startupConfig?.mcpServers?.[serverName]; + const serverConfig = availableMCPServersMap?.[serverName]; const hasCustomUserVars = serverConfig?.customUserVars && Object.keys(serverConfig.customUserVars).length > 0; @@ -300,7 +300,7 @@ function MCPToolSelectDialog({ handleSaveCustomVars(configuringServer, authData)} onRevoke={() => handleRevokeCustomVars(configuringServer)} /> diff --git a/client/src/data-provider/MCP/index.ts b/client/src/data-provider/MCP/index.ts new file mode 100644 index 0000000000..3cf1ef310b --- /dev/null +++ b/client/src/data-provider/MCP/index.ts @@ -0,0 +1 @@ +export * from './queries'; diff --git a/client/src/data-provider/MCP/queries.ts b/client/src/data-provider/MCP/queries.ts new file mode 100644 index 0000000000..afc17f3a93 --- /dev/null +++ b/client/src/data-provider/MCP/queries.ts @@ -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 = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [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 = ( + config?: UseQueryOptions, +): QueryObserverResult => { + return useQuery( + [QueryKeys.mcpTools], + () => dataService.getMCPTools(), + { + refetchOnWindowFocus: false, + refetchOnReconnect: false, + refetchOnMount: false, + staleTime: 5 * 60 * 1000, // 5 minutes + ...config, + }, + ); +}; diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index 1e2582cb84..8c798aa57a 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -11,6 +11,6 @@ export * from './connection'; export * from './mutations'; export * from './prompts'; export * from './queries'; -export * from './mcp'; export * from './roles'; export * from './tags'; +export * from './MCP'; diff --git a/client/src/data-provider/mcp.ts b/client/src/data-provider/mcp.ts deleted file mode 100644 index 726f4415e6..0000000000 --- a/client/src/data-provider/mcp.ts +++ /dev/null @@ -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 = ( - config?: UseQueryOptions, -): QueryObserverResult => { - return useQuery( - [QueryKeys.mcpTools], - () => dataService.getMCPTools(), - { - refetchOnWindowFocus: false, - refetchOnReconnect: false, - refetchOnMount: false, - staleTime: 5 * 60 * 1000, // 5 minutes - ...config, - }, - ); -}; diff --git a/client/src/hooks/Agents/useApplyModelSpecAgents.ts b/client/src/hooks/Agents/useApplyModelSpecAgents.ts index 4e51a42339..94d62a058a 100644 --- a/client/src/hooks/Agents/useApplyModelSpecAgents.ts +++ b/client/src/hooks/Agents/useApplyModelSpecAgents.ts @@ -74,7 +74,6 @@ export function useApplyAgentTemplate() { return; } - // Merge model spec fields into ephemeral agent const mergedAgent = { ...ephemeralAgent, mcp: [...(ephemeralAgent?.mcp ?? []), ...(modelSpec.mcpServers ?? [])], @@ -83,7 +82,6 @@ export function useApplyAgentTemplate() { execute_code: ephemeralAgent?.execute_code ?? modelSpec.executeCode ?? false, }; - // Deduplicate MCP servers mergedAgent.mcp = [...new Set(mergedAgent.mcp)]; applyAgentTemplate(targetId, sourceId, mergedAgent); diff --git a/client/src/hooks/Config/useAppStartup.ts b/client/src/hooks/Config/useAppStartup.ts index d6324f7e71..52b4325eea 100644 --- a/client/src/hooks/Config/useAppStartup.ts +++ b/client/src/hooks/Config/useAppStartup.ts @@ -5,7 +5,7 @@ import { LocalStorageKeys } from 'librechat-data-provider'; import type { TStartupConfig, TUser } from 'librechat-data-provider'; import { cleanupTimestampedStorage } from '~/utils/timestamps'; import useSpeechSettingsInit from './useSpeechSettingsInit'; -import { useMCPToolsQuery } from '~/data-provider'; +import { useMCPToolsQuery, useMCPServersQuery } from '~/data-provider'; import store from '~/store'; export default function useAppStartup({ @@ -18,9 +18,10 @@ export default function useAppStartup({ const [defaultPreset, setDefaultPreset] = useRecoilState(store.defaultPreset); useSpeechSettingsInit(!!user); + const { data: loadedServers, isLoading: serversLoading } = useMCPServersQuery(); useMCPToolsQuery({ - enabled: !!startupConfig?.mcpServers && !!user, + enabled: !serversLoading && !!loadedServers && Object.keys(loadedServers).length > 0 && !!user, }); /** Clean up old localStorage entries on startup */ diff --git a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx index 4741bba328..26595b611c 100644 --- a/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx +++ b/client/src/hooks/MCP/__tests__/useMCPSelect.test.tsx @@ -6,7 +6,7 @@ import { Constants, LocalStorageKeys } from 'librechat-data-provider'; import { ephemeralAgentByConvoId } from '~/store'; import { setTimestamp } from '~/utils/timestamps'; import { useMCPSelect } from '../useMCPSelect'; -import * as dataProvider from '~/data-provider'; +import { MCPServerDefinition } from '../useMCPServerManager'; // Mock dependencies 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('~/data-provider', () => ({ - ...jest.requireActual('~/data-provider'), - useGetStartupConfig: jest.fn(), -})); +// Helper to create MCPServerDefinition objects +const createMCPServers = (serverNames: string[]): MCPServerDefinition[] => { + 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[] = []) => { // Create a new Jotai store for each test to ensure clean state const store = createStore(); - - // Mock the startup config - (dataProvider.useGetStartupConfig as jest.Mock).mockReturnValue({ - data: { mcpServers: Object.fromEntries(mcpServers.map((v) => [v, {}])) }, - isLoading: false, - }); + const servers = createMCPServers(mcpServers); const Wrapper: React.FC<{ children: React.ReactNode }> = ({ children }) => ( {children} ); - return Wrapper; + return { Wrapper, servers }; }; describe('useMCPSelect', () => { @@ -46,8 +47,9 @@ describe('useMCPSelect', () => { describe('Basic Functionality', () => { it('should initialize with default values', () => { - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); expect(result.current.mcpValues).toEqual([]); @@ -58,16 +60,18 @@ describe('useMCPSelect', () => { it('should use conversationId when provided', () => { const conversationId = 'test-convo-123'; - const { result } = renderHook(() => useMCPSelect({ conversationId }), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), { + wrapper: Wrapper, }); expect(result.current.mcpValues).toEqual([]); }); it('should use NEW_CONVO constant when conversationId is null', () => { - const { result } = renderHook(() => useMCPSelect({ conversationId: null }), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ conversationId: null, servers }), { + wrapper: Wrapper, }); expect(result.current.mcpValues).toEqual([]); @@ -76,8 +80,9 @@ describe('useMCPSelect', () => { describe('State Updates', () => { it('should update mcpValues when setMCPValues is called', async () => { - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(['value1', 'value2']), + const { Wrapper, servers } = createWrapper(['value1', 'value2']); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); const newValues = ['value1', 'value2']; @@ -92,8 +97,9 @@ describe('useMCPSelect', () => { }); it('should not update mcpValues if non-array is passed', () => { - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); act(() => { @@ -105,8 +111,9 @@ describe('useMCPSelect', () => { }); it('should update isPinned state', () => { - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); // Default is true @@ -131,8 +138,9 @@ describe('useMCPSelect', () => { describe('Timestamp Management', () => { it('should set timestamp when mcpValues is updated with values', async () => { const conversationId = 'test-convo'; - const { result } = renderHook(() => useMCPSelect({ conversationId }), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ conversationId, servers }), { + wrapper: Wrapper, }); const newValues = ['value1', 'value2']; @@ -148,8 +156,9 @@ describe('useMCPSelect', () => { }); it('should not set timestamp when mcpValues is empty', async () => { - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); act(() => { @@ -164,8 +173,9 @@ describe('useMCPSelect', () => { describe('Race Conditions and Infinite Loops Prevention', () => { it('should not create infinite loop when syncing between Jotai and Recoil states', async () => { - const { result, rerender } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result, rerender } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); let renderCount = 0; @@ -196,8 +206,9 @@ describe('useMCPSelect', () => { }); it('should handle rapid consecutive updates without race conditions', async () => { - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); const updates = [ @@ -222,8 +233,9 @@ describe('useMCPSelect', () => { }); it('should maintain stable setter function reference', () => { - const { result, rerender } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result, rerender } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); const firstSetMCPValues = result.current.setMCPValues; @@ -238,10 +250,11 @@ describe('useMCPSelect', () => { }); it('should handle switching conversation IDs without issues', async () => { + const { Wrapper, servers } = createWrapper(['convo1-value', 'convo2-value']); const { result, rerender } = renderHook( - ({ conversationId }) => useMCPSelect({ conversationId }), + ({ conversationId }) => useMCPSelect({ conversationId, servers }), { - wrapper: createWrapper(['convo1-value', 'convo2-value']), + wrapper: Wrapper, initialProps: { conversationId: 'convo1' }, }, ); @@ -283,18 +296,18 @@ describe('useMCPSelect', () => { describe('Ephemeral Agent Synchronization', () => { it('should sync mcpValues when ephemeralAgent is updated externally', async () => { // 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 const TestComponent = () => { - const mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const [ephemeralAgent, setEphemeralAgent] = useRecoilState( ephemeralAgentByConvoId(Constants.NEW_CONVO), ); 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) const externalMcpValues = ['external-value1', 'external-value2']; @@ -311,15 +324,15 @@ describe('useMCPSelect', () => { }); it('should filter out MCPs not in configured servers', async () => { - const wrapper = createWrapper(['server1', 'server2']); + const { Wrapper, servers } = createWrapper(['server1', 'server2']); const TestComponent = () => { - const mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); return { mcpHook, setEphemeralAgent }; }; - const { result } = renderHook(() => TestComponent(), { wrapper }); + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); act(() => { result.current.setEphemeralAgent({ @@ -333,15 +346,15 @@ describe('useMCPSelect', () => { }); 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 mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); return { mcpHook, setEphemeralAgent }; }; - const { result } = renderHook(() => TestComponent(), { wrapper }); + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); act(() => { result.current.setEphemeralAgent({ @@ -355,15 +368,15 @@ describe('useMCPSelect', () => { }); 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 mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); return { mcpHook, setEphemeralAgent }; }; - const { result } = renderHook(() => TestComponent(), { wrapper }); + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); act(() => { result.current.setEphemeralAgent({ @@ -378,16 +391,16 @@ describe('useMCPSelect', () => { it('should update ephemeralAgent when mcpValues changes through hook', async () => { // 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 const TestComponent = () => { - const mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const ephemeralAgent = useRecoilValue(ephemeralAgentByConvoId(Constants.NEW_CONVO)); return { mcpHook, ephemeralAgent }; }; - const { result } = renderHook(() => TestComponent(), { wrapper }); + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); const newValues = ['hook-value1', 'hook-value2']; @@ -404,16 +417,16 @@ describe('useMCPSelect', () => { it('should handle empty ephemeralAgent.mcp array correctly', async () => { // Create a shared wrapper - const wrapper = createWrapper(['initial-value']); + const { Wrapper, servers } = createWrapper(['initial-value']); // Create a component that uses both hooks const TestComponent = () => { - const mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); return { mcpHook, setEphemeralAgent }; }; - const { result } = renderHook(() => TestComponent(), { wrapper }); + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); // Set initial values act(() => { @@ -438,16 +451,16 @@ describe('useMCPSelect', () => { it('should handle ephemeralAgent with clear mcp value', async () => { // Create a shared wrapper - const wrapper = createWrapper(['server1', 'server2']); + const { Wrapper, servers } = createWrapper(['server1', 'server2']); // Create a component that uses both hooks const TestComponent = () => { - const mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); return { mcpHook, setEphemeralAgent }; }; - const { result } = renderHook(() => TestComponent(), { wrapper }); + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); // Set initial values act(() => { @@ -473,15 +486,21 @@ describe('useMCPSelect', () => { it('should properly sync non-empty arrays from ephemeralAgent', async () => { // 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 mcpHook = useMCPSelect({}); + const mcpHook = useMCPSelect({ servers }); const setEphemeralAgent = useSetRecoilState(ephemeralAgentByConvoId(Constants.NEW_CONVO)); return { mcpHook, setEphemeralAgent }; }; - const { result } = renderHook(() => TestComponent(), { wrapper }); + const { result } = renderHook(() => TestComponent(), { wrapper: Wrapper }); // Set initial values through ephemeralAgent with non-empty array const initialValues = ['value1', 'value2']; @@ -513,8 +532,9 @@ describe('useMCPSelect', () => { describe('Edge Cases', () => { it('should handle undefined conversationId', () => { - const { result } = renderHook(() => useMCPSelect({ conversationId: undefined }), { - wrapper: createWrapper(['test']), + const { Wrapper, servers } = createWrapper(['test']); + const { result } = renderHook(() => useMCPSelect({ conversationId: undefined, servers }), { + wrapper: Wrapper, }); expect(result.current.mcpValues).toEqual([]); @@ -527,8 +547,9 @@ describe('useMCPSelect', () => { }); it('should handle empty string conversationId', () => { - const { result } = renderHook(() => useMCPSelect({ conversationId: '' }), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result } = renderHook(() => useMCPSelect({ conversationId: '', servers }), { + wrapper: Wrapper, }); expect(result.current.mcpValues).toEqual([]); @@ -536,8 +557,9 @@ describe('useMCPSelect', () => { it('should handle very large arrays without performance issues', async () => { const largeArray = Array.from({ length: 1000 }, (_, i) => `value-${i}`); - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(largeArray), + const { Wrapper, servers } = createWrapper(largeArray); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); const startTime = performance.now(); @@ -558,8 +580,9 @@ describe('useMCPSelect', () => { }); it('should cleanup properly on unmount', () => { - const { unmount } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { unmount } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); // Should unmount without errors @@ -570,8 +593,9 @@ describe('useMCPSelect', () => { describe('Memory Leak Prevention', () => { it('should not leak memory on repeated updates', async () => { const values = Array.from({ length: 100 }, (_, i) => `value-${i}`); - const { result } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(values), + const { Wrapper, servers } = createWrapper(values); + const { result } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); // Perform many updates to test for memory leaks @@ -586,8 +610,9 @@ describe('useMCPSelect', () => { }); it('should handle component remounting', () => { - const { result, unmount } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper, servers } = createWrapper(); + const { result, unmount } = renderHook(() => useMCPSelect({ servers }), { + wrapper: Wrapper, }); act(() => { @@ -597,8 +622,9 @@ describe('useMCPSelect', () => { unmount(); // Remount - const { result: newResult } = renderHook(() => useMCPSelect({}), { - wrapper: createWrapper(), + const { Wrapper: Wrapper2, servers: servers2 } = createWrapper(); + const { result: newResult } = renderHook(() => useMCPSelect({ servers: servers2 }), { + wrapper: Wrapper2, }); // Should handle remounting gracefully diff --git a/client/src/hooks/MCP/useMCPSelect.ts b/client/src/hooks/MCP/useMCPSelect.ts index 1ee2fe05b3..ec9dfe0bbb 100644 --- a/client/src/hooks/MCP/useMCPSelect.ts +++ b/client/src/hooks/MCP/useMCPSelect.ts @@ -4,15 +4,20 @@ import isEqual from 'lodash/isEqual'; import { useRecoilState } from 'recoil'; import { Constants, LocalStorageKeys } from 'librechat-data-provider'; import { ephemeralAgentByConvoId, mcpValuesAtomFamily, mcpPinnedAtom } from '~/store'; -import { useGetStartupConfig } from '~/data-provider'; 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 { data: startupConfig } = useGetStartupConfig(); const configuredServers = useMemo(() => { - return new Set(Object.keys(startupConfig?.mcpServers ?? {})); - }, [startupConfig?.mcpServers]); + return new Set(servers?.map((s) => s.serverName)); + }, [servers]); const [isPinned, setIsPinned] = useAtom(mcpPinnedAtom); const [mcpValues, setMCPValuesRaw] = useAtom(mcpValuesAtomFamily(key)); diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 4d819b0ae6..e3f47502dc 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -1,7 +1,7 @@ import { useCallback, useState, useMemo, useRef, useEffect } from 'react'; import { useToastContext } from '@librechat/client'; import { useQueryClient } from '@tanstack/react-query'; -import { Constants, QueryKeys } from 'librechat-data-provider'; +import { Constants, QueryKeys, MCPOptions } from 'librechat-data-provider'; import { useCancelMCPOAuthMutation, useUpdateUserPluginsMutation, @@ -10,7 +10,15 @@ import { import type { TUpdateUserPlugins, TPlugin, MCPServersResponse } from 'librechat-data-provider'; import type { ConfigFieldDetail } from '~/common'; 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 { isInitializing: boolean; @@ -24,12 +32,40 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin const localize = useLocalize(); const queryClient = useQueryClient(); const { showToast } = useToastContext(); - const { data: startupConfig } = useGetStartupConfig(); - const { mcpValues, setMCPValues, isPinned, setIsPinned } = useMCPSelect({ conversationId }); + const { data: startupConfig } = useGetStartupConfig(); // Keep for UI config only + + const { data: loadedServers, isLoading } = useMCPServersQuery(); const [isConfigModalOpen, setIsConfigModalOpen] = useState(false); const [selectedToolForConfig, setSelectedToolForConfig] = useState(null); const previousFocusRef = useRef(null); + const configuredServers = useMemo(() => { + if (!loadedServers) return []; + return Object.keys(loadedServers).filter((name) => loadedServers[name]?.chatMenu !== false); + }, [loadedServers]); + + const availableMCPServers: MCPServerDefinition[] = useMemo(() => { + 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); // 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; }, [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 cancelOAuthMutation = useCancelMCPOAuthMutation(); @@ -52,6 +81,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); await Promise.all([ + queryClient.invalidateQueries([QueryKeys.mcpServers]), queryClient.invalidateQueries([QueryKeys.mcpTools]), queryClient.invalidateQueries([QueryKeys.mcpAuthValues]), queryClient.invalidateQueries([QueryKeys.mcpConnectionStatus]), @@ -81,7 +111,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin }); const { connectionStatus } = useMCPConnectionStatus({ - enabled: !!startupConfig?.mcpServers && Object.keys(startupConfig.mcpServers).length > 0, + enabled: !isLoading && configuredServers.length > 0, }); const updateServerState = useCallback((serverName: string, updates: Partial) => { @@ -289,7 +319,12 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin startServerPolling(serverName); } 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({ 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 serverStatus = connectionStatus?.[serverName]; - const serverConfig = startupConfig?.mcpServers?.[serverName]; + const serverConfig = loadedServers?.[serverName]; const handleConfigClick = (e: React.MouseEvent) => { e.stopPropagation(); @@ -548,14 +583,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin hasCustomUserVars, }; }, - [ - queryClient, - isCancellable, - isInitializing, - cancelOAuthFlow, - connectionStatus, - startupConfig?.mcpServers, - ], + [queryClient, isCancellable, isInitializing, cancelOAuthFlow, connectionStatus, loadedServers], ); const getConfigDialogProps = useCallback(() => { @@ -600,7 +628,10 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin ]); return { - configuredServers, + availableMCPServers, + availableMCPServersMap: loadedServers, + isLoading, + connectionStatus, initializeServer, cancelOAuthFlow, isInitializing, diff --git a/client/src/hooks/Nav/useSideNavLinks.ts b/client/src/hooks/Nav/useSideNavLinks.ts index 369574737b..6ec798eaa6 100644 --- a/client/src/hooks/Nav/useSideNavLinks.ts +++ b/client/src/hooks/Nav/useSideNavLinks.ts @@ -19,8 +19,7 @@ import PromptsAccordion from '~/components/Prompts/PromptsAccordion'; import Parameters from '~/components/SidePanel/Parameters/Panel'; import FilesPanel from '~/components/SidePanel/Files/Panel'; import MCPPanel from '~/components/SidePanel/MCP/MCPPanel'; -import { useGetStartupConfig } from '~/data-provider'; -import { useHasAccess } from '~/hooks'; +import { useHasAccess, useMCPServerManager } from '~/hooks'; export default function useSideNavLinks({ hidePanel, @@ -61,7 +60,8 @@ export default function useSideNavLinks({ permissionType: PermissionTypes.AGENTS, permission: Permissions.CREATE, }); - const { data: startupConfig } = useGetStartupConfig(); + + const { availableMCPServers } = useMCPServerManager(); const Links = useMemo(() => { const links: NavLink[] = []; @@ -153,12 +153,12 @@ export default function useSideNavLinks({ } if ( - startupConfig?.mcpServers && - Object.values(startupConfig.mcpServers).some( + availableMCPServers && + availableMCPServers.some( (server: any) => - (server.customUserVars && Object.keys(server.customUserVars).length > 0) || - server.isOAuth || - server.startup === false, + (server.config.customUserVars && Object.keys(server.config.customUserVars).length > 0) || + server.config.isOAuth || + server.config.startup === false, ) ) { links.push({ @@ -192,7 +192,7 @@ export default function useSideNavLinks({ hasAccessToBookmarks, hasAccessToCreateAgents, hidePanel, - startupConfig, + availableMCPServers, ]); return Links; diff --git a/package.json b/package.json index 1062c62d2b..4ef97f29df 100644 --- a/package.json +++ b/package.json @@ -58,6 +58,10 @@ "e2e:github": "act -W .github/workflows/playwright.yml --secret-file my.secrets", "test:client": "cd client && 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:report": "npx playwright show-report e2e/playwright-report", "lint:fix": "eslint --fix \"{,!(node_modules|venv)/**/}*.{js,jsx,ts,tsx}\"", diff --git a/packages/api/src/mcp/registry/MCPServersInitializer.ts b/packages/api/src/mcp/registry/MCPServersInitializer.ts index d2bd2475c6..e23cfd1ea9 100644 --- a/packages/api/src/mcp/registry/MCPServersInitializer.ts +++ b/packages/api/src/mcp/registry/MCPServersInitializer.ts @@ -34,9 +34,6 @@ export class MCPServersInitializer { public static async initialize(rawConfigs: t.MCPServers): Promise { 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()) { // Leader performs initialization await statusCache.reset(); diff --git a/packages/api/src/mcp/registry/MCPServersRegistry.ts b/packages/api/src/mcp/registry/MCPServersRegistry.ts index 7b329f2ec6..a9b3681c66 100644 --- a/packages/api/src/mcp/registry/MCPServersRegistry.ts +++ b/packages/api/src/mcp/registry/MCPServersRegistry.ts @@ -33,14 +33,6 @@ class MCPServersRegistry { 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 = PrivateServerConfigsCacheFactory.create(); diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index 65b8d0f2fa..417375386d 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -228,6 +228,7 @@ export const agents = ({ path = '', options }: { path?: string; options?: object export const mcp = { tools: `${BASE_URL}/api/mcp/tools`, + servers: `${BASE_URL}/api/mcp/servers`, }; export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index c7d1a1c052..1f75e20762 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -6,6 +6,7 @@ import * as ag from './types/agents'; import * as m from './types/mutations'; import * as q from './types/queries'; import * as f from './types/files'; +import * as mcp from './types/mcpServers'; import * as config from './config'; import request from './request'; 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 => { + return request.get(endpoints.mcp.servers); +}; + /** * Imports a conversations file. * diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 1d6e1d813c..fa4656722d 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -57,6 +57,8 @@ export enum QueryKeys { resourcePermissions = 'resourcePermissions', effectivePermissions = 'effectivePermissions', graphToken = 'graphToken', + /* MCP Servers */ + mcpServers = 'mcpServers', } // Dynamic query keys that require parameters diff --git a/packages/data-provider/src/mcp.ts b/packages/data-provider/src/mcp.ts index f22a412f8c..dd359d750a 100644 --- a/packages/data-provider/src/mcp.ts +++ b/packages/data-provider/src/mcp.ts @@ -180,3 +180,32 @@ export const MCPOptionsSchema = z.union([ export const MCPServersSchema = z.record(z.string(), MCPOptionsSchema); export type MCPOptions = z.infer; + +/** + * Helper to omit server-managed fields that should not come from UI + */ +const omitServerManagedFields = >(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; diff --git a/packages/data-provider/src/types/mcpServers.ts b/packages/data-provider/src/types/mcpServers.ts index ec1fde2511..ad27445b75 100644 --- a/packages/data-provider/src/types/mcpServers.ts +++ b/packages/data-provider/src/types/mcpServers.ts @@ -1,4 +1,5 @@ -import type { MCPOptions } from '../mcp'; +import { PermissionBits } from '../accessPermissions'; +import type { MCPOptions, MCPServerUserInput } from '../mcp'; /** * Base MCP Server interface @@ -19,3 +20,33 @@ export interface IMCPServerDB { * Similar to Agent type - includes populated author fields */ 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;