diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index e97afeccd..c5e074b8f 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -1,16 +1,9 @@ const { logger } = require('@librechat/data-schemas'); -const { CacheKeys, Constants } = require('librechat-data-provider'); -const { - getToolkitKey, - checkPluginAuth, - filterUniquePlugins, - convertMCPToolToPlugin, - convertMCPToolsToPlugins, -} = require('@librechat/api'); -const { getCachedTools, setCachedTools, mergeUserTools } = require('~/server/services/Config'); +const { CacheKeys } = require('librechat-data-provider'); +const { getToolkitKey, checkPluginAuth, filterUniquePlugins } = require('@librechat/api'); +const { getCachedTools, setCachedTools } = require('~/server/services/Config'); const { availableTools, toolkits } = require('~/app/clients/tools'); const { getAppConfig } = require('~/server/services/Config'); -const { getMCPManager } = require('~/config'); const { getLogStores } = require('~/cache'); const getAvailablePluginsController = async (req, res) => { @@ -72,69 +65,27 @@ const getAvailableTools = async (req, res) => { } const cache = getLogStores(CacheKeys.CONFIG_STORE); const cachedToolsArray = await cache.get(CacheKeys.TOOLS); - const cachedUserTools = await getCachedTools({ userId }); const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role })); - /** @type {TPlugin[]} */ - let mcpPlugins; - if (appConfig?.mcpConfig) { - const mcpManager = getMCPManager(); - mcpPlugins = - cachedUserTools != null - ? convertMCPToolsToPlugins({ functionTools: cachedUserTools, mcpManager }) - : undefined; - } - - if ( - cachedToolsArray != null && - (appConfig?.mcpConfig != null ? mcpPlugins != null && mcpPlugins.length > 0 : true) - ) { - const dedupedTools = filterUniquePlugins([...(mcpPlugins ?? []), ...cachedToolsArray]); - res.status(200).json(dedupedTools); + // Return early if we have cached tools + if (cachedToolsArray != null) { + res.status(200).json(cachedToolsArray); return; } /** @type {Record | null} Get tool definitions to filter which tools are actually available */ - let toolDefinitions = await getCachedTools({ includeGlobal: true }); - let prelimCachedTools; + let toolDefinitions = await getCachedTools(); if (toolDefinitions == null && appConfig?.availableTools != null) { logger.warn('[getAvailableTools] Tool cache was empty, re-initializing from app config'); - await setCachedTools(appConfig.availableTools, { isGlobal: true }); + await setCachedTools(appConfig.availableTools); toolDefinitions = appConfig.availableTools; } /** @type {import('@librechat/api').LCManifestTool[]} */ let pluginManifest = availableTools; - if (appConfig?.mcpConfig != null) { - try { - const mcpManager = getMCPManager(); - const mcpTools = await mcpManager.getAllToolFunctions(userId); - prelimCachedTools = prelimCachedTools ?? {}; - for (const [toolKey, toolData] of Object.entries(mcpTools)) { - const plugin = convertMCPToolToPlugin({ - toolKey, - toolData, - mcpManager, - }); - if (plugin) { - pluginManifest.push(plugin); - } - prelimCachedTools[toolKey] = toolData; - } - await mergeUserTools({ userId, cachedUserTools, userTools: prelimCachedTools }); - } catch (error) { - logger.error( - '[getAvailableTools] Error loading MCP Tools, servers may still be initializing:', - error, - ); - } - } else if (prelimCachedTools != null) { - await setCachedTools(prelimCachedTools, { isGlobal: true }); - } - /** @type {TPlugin[]} Deduplicate and authenticate plugins */ const uniquePlugins = filterUniquePlugins(pluginManifest); const authenticatedPlugins = uniquePlugins.map((plugin) => { @@ -145,7 +96,7 @@ const getAvailableTools = async (req, res) => { } }); - /** Filter plugins based on availability and add MCP-specific auth config */ + /** Filter plugins based on availability */ const toolsOutput = []; for (const plugin of authenticatedPlugins) { const isToolDefined = toolDefinitions?.[plugin.pluginKey] !== undefined; @@ -159,39 +110,13 @@ const getAvailableTools = async (req, res) => { continue; } - const toolToAdd = { ...plugin }; - - if (plugin.pluginKey.includes(Constants.mcp_delimiter)) { - const parts = plugin.pluginKey.split(Constants.mcp_delimiter); - const serverName = parts[parts.length - 1]; - const serverConfig = appConfig?.mcpConfig?.[serverName]; - - if (serverConfig?.customUserVars) { - const customVarKeys = Object.keys(serverConfig.customUserVars); - if (customVarKeys.length === 0) { - toolToAdd.authConfig = []; - toolToAdd.authenticated = true; - } else { - toolToAdd.authConfig = Object.entries(serverConfig.customUserVars).map( - ([key, value]) => ({ - authField: key, - label: value.title || key, - description: value.description || '', - }), - ); - toolToAdd.authenticated = false; - } - } - } - - toolsOutput.push(toolToAdd); + toolsOutput.push(plugin); } const finalTools = filterUniquePlugins(toolsOutput); await cache.set(CacheKeys.TOOLS, finalTools); - const dedupedTools = filterUniquePlugins([...(mcpPlugins ?? []), ...finalTools]); - res.status(200).json(dedupedTools); + res.status(200).json(finalTools); } catch (error) { logger.error('[getAvailableTools]', error); res.status(500).json({ message: error.message }); diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js index cdbcabd75..d7d3f83a8 100644 --- a/api/server/controllers/PluginController.spec.js +++ b/api/server/controllers/PluginController.spec.js @@ -1,4 +1,3 @@ -const { Constants } = require('librechat-data-provider'); const { getCachedTools, getAppConfig } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); @@ -17,18 +16,10 @@ jest.mock('~/server/services/Config', () => ({ includedTools: [], }), setCachedTools: jest.fn(), - mergeUserTools: jest.fn(), })); // loadAndFormatTools mock removed - no longer used in PluginController - -jest.mock('~/config', () => ({ - getMCPManager: jest.fn(() => ({ - getAllToolFunctions: jest.fn().mockResolvedValue({}), - getRawConfig: jest.fn().mockReturnValue({}), - })), - getFlowStateManager: jest.fn(), -})); +// getMCPManager mock removed - no longer used in PluginController jest.mock('~/app/clients/tools', () => ({ availableTools: [], @@ -159,52 +150,6 @@ describe('PluginController', () => { }); describe('getAvailableTools', () => { - it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => { - const mockUserTools = { - [`tool1${Constants.mcp_delimiter}server1`]: { - type: 'function', - function: { - name: `tool1${Constants.mcp_delimiter}server1`, - description: 'Tool 1', - parameters: { type: 'object', properties: {} }, - }, - }, - }; - - mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValueOnce(mockUserTools); - mockReq.config = { - mcpConfig: { - server1: {}, - }, - paths: { structuredTools: '/mock/path' }, - }; - - // Mock MCP manager to return empty tools initially (since getAllToolFunctions is called) - const mockMCPManager = { - getAllToolFunctions: jest.fn().mockResolvedValue({}), - getRawConfig: jest.fn().mockReturnValue({}), - }; - require('~/config').getMCPManager.mockReturnValue(mockMCPManager); - - // Mock second call to return tool definitions (includeGlobal: true) - getCachedTools.mockResolvedValueOnce(mockUserTools); - - await getAvailableTools(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(200); - const responseData = mockRes.json.mock.calls[0][0]; - expect(responseData).toBeDefined(); - expect(Array.isArray(responseData)).toBe(true); - expect(responseData.length).toBeGreaterThan(0); - const convertedTool = responseData.find( - (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`, - ); - expect(convertedTool).toBeDefined(); - // The real convertMCPToolsToPlugins extracts the name from the delimiter - expect(convertedTool.name).toBe('tool1'); - }); - it('should use filterUniquePlugins to deduplicate combined tools', async () => { const mockUserTools = { 'user-tool': { @@ -229,9 +174,6 @@ describe('PluginController', () => { paths: { structuredTools: '/mock/path' }, }; - // Mock second call to return tool definitions - getCachedTools.mockResolvedValueOnce(mockUserTools); - await getAvailableTools(mockReq, mockRes); expect(mockRes.status).toHaveBeenCalledWith(200); @@ -254,14 +196,7 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(mockPlugin); mockCache.get.mockResolvedValue(null); - // First call returns null for user tools - getCachedTools.mockResolvedValueOnce(null); - mockReq.config = { - mcpConfig: null, - paths: { structuredTools: '/mock/path' }, - }; - - // Second call (with includeGlobal: true) returns the tool definitions + // getCachedTools returns the tool definitions getCachedTools.mockResolvedValueOnce({ tool1: { type: 'function', @@ -272,6 +207,10 @@ describe('PluginController', () => { }, }, }); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; await getAvailableTools(mockReq, mockRes); @@ -302,14 +241,7 @@ describe('PluginController', () => { }); mockCache.get.mockResolvedValue(null); - // First call returns null for user tools - getCachedTools.mockResolvedValueOnce(null); - mockReq.config = { - mcpConfig: null, - paths: { structuredTools: '/mock/path' }, - }; - - // Second call (with includeGlobal: true) returns the tool definitions + // getCachedTools returns the tool definitions getCachedTools.mockResolvedValueOnce({ toolkit1_function: { type: 'function', @@ -320,6 +252,10 @@ describe('PluginController', () => { }, }, }); + mockReq.config = { + mcpConfig: null, + paths: { structuredTools: '/mock/path' }, + }; await getAvailableTools(mockReq, mockRes); @@ -331,126 +267,7 @@ describe('PluginController', () => { }); }); - describe('plugin.icon behavior', () => { - const callGetAvailableToolsWithMCPServer = async (serverConfig) => { - mockCache.get.mockResolvedValue(null); - - const functionTools = { - [`test-tool${Constants.mcp_delimiter}test-server`]: { - type: 'function', - function: { - name: `test-tool${Constants.mcp_delimiter}test-server`, - description: 'A test tool', - parameters: { type: 'object', properties: {} }, - }, - }, - }; - - // Mock the MCP manager to return tools and server config - const mockMCPManager = { - getAllToolFunctions: jest.fn().mockResolvedValue(functionTools), - getRawConfig: jest.fn().mockReturnValue(serverConfig), - }; - require('~/config').getMCPManager.mockReturnValue(mockMCPManager); - - // First call returns empty user tools - getCachedTools.mockResolvedValueOnce({}); - - // Mock getAppConfig to return the mcpConfig - mockReq.config = { - mcpConfig: { - 'test-server': serverConfig, - }, - }; - - // Second call (with includeGlobal: true) returns the tool definitions - getCachedTools.mockResolvedValueOnce(functionTools); - - await getAvailableTools(mockReq, mockRes); - const responseData = mockRes.json.mock.calls[0][0]; - return responseData.find( - (tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`, - ); - }; - - it('should set plugin.icon when iconPath is defined', async () => { - const serverConfig = { - iconPath: '/path/to/icon.png', - }; - const testTool = await callGetAvailableToolsWithMCPServer(serverConfig); - expect(testTool.icon).toBe('/path/to/icon.png'); - }); - - it('should set plugin.icon to undefined when iconPath is not defined', async () => { - const serverConfig = {}; - const testTool = await callGetAvailableToolsWithMCPServer(serverConfig); - expect(testTool.icon).toBeUndefined(); - }); - }); - describe('helper function integration', () => { - it('should properly handle MCP tools with custom user variables', async () => { - const appConfig = { - mcpConfig: { - 'test-server': { - customUserVars: { - API_KEY: { title: 'API Key', description: 'Your API key' }, - }, - }, - }, - }; - - // Mock MCP tools returned by getAllToolFunctions - const mcpToolFunctions = { - [`tool1${Constants.mcp_delimiter}test-server`]: { - type: 'function', - function: { - name: `tool1${Constants.mcp_delimiter}test-server`, - description: 'Tool 1', - parameters: {}, - }, - }, - }; - - // Mock the MCP manager to return tools - const mockMCPManager = { - getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions), - getRawConfig: jest.fn().mockReturnValue({ - customUserVars: { - API_KEY: { title: 'API Key', description: 'Your API key' }, - }, - }), - }; - require('~/config').getMCPManager.mockReturnValue(mockMCPManager); - - mockCache.get.mockResolvedValue(null); - mockReq.config = appConfig; - - // First call returns user tools (empty in this case) - getCachedTools.mockResolvedValueOnce({}); - - // Second call (with includeGlobal: true) returns tool definitions including our MCP tool - getCachedTools.mockResolvedValueOnce(mcpToolFunctions); - - await getAvailableTools(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(200); - const responseData = mockRes.json.mock.calls[0][0]; - expect(Array.isArray(responseData)).toBe(true); - - // Find the MCP tool in the response - const mcpTool = responseData.find( - (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`, - ); - - // The actual implementation adds authConfig and sets authenticated to false when customUserVars exist - expect(mcpTool).toBeDefined(); - expect(mcpTool.authConfig).toEqual([ - { authField: 'API_KEY', label: 'API Key', description: 'Your API key' }, - ]); - expect(mcpTool.authenticated).toBe(false); - }); - it('should handle error cases gracefully', async () => { mockCache.get.mockRejectedValue(new Error('Cache error')); @@ -472,23 +289,13 @@ describe('PluginController', () => { it('should handle null cachedTools and cachedUserTools', async () => { mockCache.get.mockResolvedValue(null); - // First call returns null for user tools - getCachedTools.mockResolvedValueOnce(null); + // getCachedTools returns empty object instead of null + getCachedTools.mockResolvedValueOnce({}); mockReq.config = { mcpConfig: null, paths: { structuredTools: '/mock/path' }, }; - // Mock MCP manager to return no tools - const mockMCPManager = { - getAllToolFunctions: jest.fn().mockResolvedValue({}), - getRawConfig: jest.fn().mockReturnValue({}), - }; - require('~/config').getMCPManager.mockReturnValue(mockMCPManager); - - // Second call (with includeGlobal: true) returns empty object instead of null - getCachedTools.mockResolvedValueOnce({}); - await getAvailableTools(mockReq, mockRes); // Should handle null values gracefully @@ -503,9 +310,9 @@ describe('PluginController', () => { paths: { structuredTools: '/mock/path' }, }; - // Mock getCachedTools to return undefined for both calls + // Mock getCachedTools to return undefined getCachedTools.mockReset(); - getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined); + getCachedTools.mockResolvedValueOnce(undefined); await getAvailableTools(mockReq, mockRes); @@ -514,51 +321,6 @@ describe('PluginController', () => { expect(mockRes.json).toHaveBeenCalledWith([]); }); - it('should handle `cachedToolsArray` and `mcpPlugins` both being defined', async () => { - const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }]; - // Use MCP delimiter for the user tool so convertMCPToolsToPlugins works - const userTools = { - [`user-tool${Constants.mcp_delimiter}server1`]: { - type: 'function', - function: { - name: `user-tool${Constants.mcp_delimiter}server1`, - description: 'User tool', - parameters: {}, - }, - }, - }; - - mockCache.get.mockResolvedValue(cachedTools); - getCachedTools.mockResolvedValueOnce(userTools); - mockReq.config = { - mcpConfig: { - server1: {}, - }, - paths: { structuredTools: '/mock/path' }, - }; - - // Mock MCP manager to return empty tools initially - const mockMCPManager = { - getAllToolFunctions: jest.fn().mockResolvedValue({}), - getRawConfig: jest.fn().mockReturnValue({}), - }; - require('~/config').getMCPManager.mockReturnValue(mockMCPManager); - - // The controller expects a second call to getCachedTools - getCachedTools.mockResolvedValueOnce({ - 'cached-tool': { type: 'function', function: { name: 'cached-tool' } }, - [`user-tool${Constants.mcp_delimiter}server1`]: - userTools[`user-tool${Constants.mcp_delimiter}server1`], - }); - - await getAvailableTools(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(200); - const responseData = mockRes.json.mock.calls[0][0]; - // Should have both cached and user tools - expect(responseData.length).toBeGreaterThanOrEqual(2); - }); - it('should handle empty toolDefinitions object', async () => { mockCache.get.mockResolvedValue(null); // Reset getCachedTools to ensure clean state @@ -569,76 +331,12 @@ describe('PluginController', () => { // Ensure no plugins are available require('~/app/clients/tools').availableTools.length = 0; - // Reset MCP manager to default state - const mockMCPManager = { - getAllToolFunctions: jest.fn().mockResolvedValue({}), - getRawConfig: jest.fn().mockReturnValue({}), - }; - require('~/config').getMCPManager.mockReturnValue(mockMCPManager); - await getAvailableTools(mockReq, mockRes); // With empty tool definitions, no tools should be in the final output expect(mockRes.json).toHaveBeenCalledWith([]); }); - it('should handle MCP tools without customUserVars', async () => { - const appConfig = { - mcpConfig: { - 'test-server': { - // No customUserVars defined - }, - }, - }; - - const mockUserTools = { - [`tool1${Constants.mcp_delimiter}test-server`]: { - type: 'function', - function: { - name: `tool1${Constants.mcp_delimiter}test-server`, - description: 'Tool 1', - parameters: { type: 'object', properties: {} }, - }, - }, - }; - - // Mock the MCP manager to return the tools - const mockMCPManager = { - getAllToolFunctions: jest.fn().mockResolvedValue(mockUserTools), - getRawConfig: jest.fn().mockReturnValue({ - // No customUserVars defined - }), - }; - require('~/config').getMCPManager.mockReturnValue(mockMCPManager); - - mockCache.get.mockResolvedValue(null); - mockReq.config = appConfig; - // First call returns empty user tools - getCachedTools.mockResolvedValueOnce({}); - - // Second call (with includeGlobal: true) returns the tool definitions - getCachedTools.mockResolvedValueOnce(mockUserTools); - - // Ensure no plugins in availableTools for clean test - require('~/app/clients/tools').availableTools.length = 0; - - await getAvailableTools(mockReq, mockRes); - - expect(mockRes.status).toHaveBeenCalledWith(200); - const responseData = mockRes.json.mock.calls[0][0]; - expect(Array.isArray(responseData)).toBe(true); - expect(responseData.length).toBeGreaterThan(0); - - const mcpTool = responseData.find( - (tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}test-server`, - ); - - expect(mcpTool).toBeDefined(); - expect(mcpTool.authenticated).toBe(true); - // The actual implementation sets authConfig to empty array when no customUserVars - expect(mcpTool.authConfig).toEqual([]); - }); - it('should handle undefined filteredTools and includedTools', async () => { mockReq.config = {}; mockCache.get.mockResolvedValue(null); @@ -667,16 +365,13 @@ describe('PluginController', () => { require('~/app/clients/tools').availableTools.push(mockToolkit); mockCache.get.mockResolvedValue(null); - // First call returns empty object + // getCachedTools returns empty object to avoid null reference error getCachedTools.mockResolvedValueOnce({}); mockReq.config = { mcpConfig: null, paths: { structuredTools: '/mock/path' }, }; - // Second call (with includeGlobal: true) returns empty object to avoid null reference error - getCachedTools.mockResolvedValueOnce({}); - await getAvailableTools(mockReq, mockRes); // Should handle null toolDefinitions gracefully @@ -697,15 +392,12 @@ describe('PluginController', () => { mockCache.get.mockResolvedValue(null); - // First call returns null for user tools - getCachedTools.mockResolvedValueOnce(null); - mockReq.config = { mcpConfig: null, paths: { structuredTools: '/mock/path' }, }; - // CRITICAL: Second call (with includeGlobal: true) returns undefined + // CRITICAL: getCachedTools returns undefined // This is what causes the bug when trying to access toolDefinitions[plugin.pluginKey] getCachedTools.mockResolvedValueOnce(undefined); @@ -744,9 +436,8 @@ describe('PluginController', () => { { name: 'Tool 2', pluginKey: 'tool2', description: 'Tool 2' }, ); - // First call: Simulate cache cleared state (returns null for both global and user tools) + // Simulate cache cleared state (returns null) mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValueOnce(null); // User tools getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared) mockReq.config = { @@ -761,7 +452,7 @@ describe('PluginController', () => { await getAvailableTools(mockReq, mockRes); // Should have re-initialized the cache with tools from appConfig - expect(setCachedTools).toHaveBeenCalledWith(mockAppTools, { isGlobal: true }); + expect(setCachedTools).toHaveBeenCalledWith(mockAppTools); // Should still return tools successfully expect(mockRes.status).toHaveBeenCalledWith(200); @@ -784,7 +475,6 @@ describe('PluginController', () => { // Cache returns null (cleared state) mockCache.get.mockResolvedValue(null); - getCachedTools.mockResolvedValueOnce(null); // User tools getCachedTools.mockResolvedValueOnce(null); // Global tools (cache cleared) mockReq.config = { diff --git a/api/server/controllers/UserController.js b/api/server/controllers/UserController.js index e5c480c63..5d8dd9a5d 100644 --- a/api/server/controllers/UserController.js +++ b/api/server/controllers/UserController.js @@ -1,37 +1,34 @@ const { logger } = require('@librechat/data-schemas'); +const { Tools, CacheKeys, Constants, FileSources } = require('librechat-data-provider'); const { webSearchKeys, - extractWebSearchEnvVars, - normalizeHttpError, + MCPOAuthHandler, MCPTokenStorage, + normalizeHttpError, + extractWebSearchEnvVars, } = require('@librechat/api'); const { getFiles, + findToken, updateUser, deleteFiles, deleteConvos, deletePresets, deleteMessages, deleteUserById, + deleteAllSharedLinks, deleteAllUserSessions, } = require('~/models'); const { updateUserPluginAuth, deleteUserPluginAuth } = require('~/server/services/PluginService'); const { updateUserPluginsService, deleteUserKey } = require('~/server/services/UserService'); const { verifyEmail, resendVerificationEmail } = require('~/server/services/AuthService'); +const { getAppConfig, clearMCPServerTools } = require('~/server/services/Config'); const { needsRefresh, getNewS3URL } = require('~/server/services/Files/S3/crud'); -const { Tools, Constants, FileSources } = require('librechat-data-provider'); const { processDeleteRequest } = require('~/server/services/Files/process'); const { Transaction, Balance, User, Token } = require('~/db/models'); -const { getAppConfig } = require('~/server/services/Config'); +const { getMCPManager, getFlowStateManager } = require('~/config'); const { deleteToolCalls } = require('~/models/ToolCall'); -const { deleteAllSharedLinks } = require('~/models'); -const { getMCPManager } = require('~/config'); -const { MCPOAuthHandler } = require('@librechat/api'); -const { getFlowStateManager } = require('~/config'); -const { CacheKeys } = require('librechat-data-provider'); const { getLogStores } = require('~/cache'); -const { clearMCPServerTools } = require('~/server/services/Config/mcpToolsCache'); -const { findToken } = require('~/models'); const getUserController = async (req, res) => { const appConfig = await getAppConfig({ role: req.user?.role }); diff --git a/api/server/controllers/mcp.js b/api/server/controllers/mcp.js new file mode 100644 index 000000000..40607a584 --- /dev/null +++ b/api/server/controllers/mcp.js @@ -0,0 +1,105 @@ +/** + * MCP Tools Controller + * Handles MCP-specific tool endpoints, decoupled from regular LibreChat tools + */ +const { logger } = require('@librechat/data-schemas'); +const { Constants } = require('librechat-data-provider'); +const { convertMCPToolToPlugin } = require('@librechat/api'); +const { getAppConfig, getMCPServerTools } = require('~/server/services/Config'); +const { getMCPManager } = require('~/config'); + +/** + * Get all MCP tools available to the user + * Returns only MCP tools, not regular LibreChat tools + */ +const getMCPTools = async (req, res) => { + try { + const userId = req.user?.id; + if (!userId) { + logger.warn('[getMCPTools] User ID not found in request'); + return res.status(401).json({ message: 'Unauthorized' }); + } + + const appConfig = req.config ?? (await getAppConfig({ role: req.user?.role })); + if (!appConfig?.mcpConfig) { + return res.status(200).json([]); + } + + const mcpManager = getMCPManager(); + const configuredServers = Object.keys(appConfig.mcpConfig); + const mcpTools = []; + + // Fetch tools from each configured server + for (const serverName of configuredServers) { + try { + // First check server-specific cache + let serverTools = await getMCPServerTools(serverName); + + if (!serverTools) { + // If not cached, fetch from MCP manager + const allTools = await mcpManager.getAllToolFunctions(userId); + serverTools = {}; + + // Filter tools for this specific server + for (const [toolKey, toolData] of Object.entries(allTools)) { + if (toolKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) { + serverTools[toolKey] = toolData; + } + } + + // Cache server tools if found + if (Object.keys(serverTools).length > 0) { + const { cacheMCPServerTools } = require('~/server/services/Config'); + await cacheMCPServerTools({ serverName, serverTools }); + } + } + + // Convert to plugin format + for (const [toolKey, toolData] of Object.entries(serverTools)) { + const plugin = convertMCPToolToPlugin({ + toolKey, + toolData, + mcpManager, + }); + + if (plugin) { + // Add authentication config from server config + const serverConfig = appConfig.mcpConfig[serverName]; + if (serverConfig?.customUserVars) { + const customVarKeys = Object.keys(serverConfig.customUserVars); + if (customVarKeys.length === 0) { + plugin.authConfig = []; + plugin.authenticated = true; + } else { + plugin.authConfig = Object.entries(serverConfig.customUserVars).map( + ([key, value]) => ({ + authField: key, + label: value.title || key, + description: value.description || '', + }), + ); + plugin.authenticated = false; + } + } else { + plugin.authConfig = []; + plugin.authenticated = true; + } + + mcpTools.push(plugin); + } + } + } catch (error) { + logger.error(`[getMCPTools] Error loading tools for server ${serverName}:`, error); + } + } + + res.status(200).json(mcpTools); + } catch (error) { + logger.error('[getMCPTools]', error); + res.status(500).json({ message: error.message }); + } +}; + +module.exports = { + getMCPTools, +}; diff --git a/api/server/controllers/mcp.spec.js b/api/server/controllers/mcp.spec.js new file mode 100644 index 000000000..6dea3ac34 --- /dev/null +++ b/api/server/controllers/mcp.spec.js @@ -0,0 +1,561 @@ +const { getMCPTools } = require('./mcp'); +const { getAppConfig, getMCPServerTools } = require('~/server/services/Config'); +const { getMCPManager } = require('~/config'); +const { convertMCPToolToPlugin } = require('@librechat/api'); + +jest.mock('@librechat/data-schemas', () => ({ + logger: { + debug: jest.fn(), + error: jest.fn(), + warn: jest.fn(), + }, +})); + +jest.mock('librechat-data-provider', () => ({ + Constants: { + mcp_delimiter: '~~~', + }, +})); + +jest.mock('@librechat/api', () => ({ + convertMCPToolToPlugin: jest.fn(), +})); + +jest.mock('~/server/services/Config', () => ({ + getAppConfig: jest.fn(), + getMCPServerTools: jest.fn(), + cacheMCPServerTools: jest.fn(), +})); + +jest.mock('~/config', () => ({ + getMCPManager: jest.fn(), +})); + +describe('MCP Controller', () => { + let mockReq, mockRes, mockMCPManager; + + beforeEach(() => { + jest.clearAllMocks(); + + mockReq = { + user: { id: 'test-user-id', role: 'user' }, + config: null, + }; + + mockRes = { + status: jest.fn().mockReturnThis(), + json: jest.fn(), + }; + + mockMCPManager = { + getAllToolFunctions: jest.fn().mockResolvedValue({}), + }; + + getMCPManager.mockReturnValue(mockMCPManager); + getAppConfig.mockResolvedValue({ + mcpConfig: {}, + }); + getMCPServerTools.mockResolvedValue(null); + }); + + describe('getMCPTools', () => { + it('should return 401 when user ID is not found', async () => { + mockReq.user = null; + + await getMCPTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(401); + expect(mockRes.json).toHaveBeenCalledWith({ message: 'Unauthorized' }); + const { logger } = require('@librechat/data-schemas'); + expect(logger.warn).toHaveBeenCalledWith('[getMCPTools] User ID not found in request'); + }); + + it('should return empty array when no mcpConfig exists', async () => { + getAppConfig.mockResolvedValue({ + // No mcpConfig + }); + + await getMCPTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([]); + }); + + it('should use cached server tools when available', async () => { + const cachedTools = { + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + }; + + getMCPServerTools.mockResolvedValue(cachedTools); + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: {}, + }, + }); + + const mockPlugin = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + convertMCPToolToPlugin.mockReturnValue(mockPlugin); + + await getMCPTools(mockReq, mockRes); + + expect(getMCPServerTools).toHaveBeenCalledWith('server1'); + expect(mockMCPManager.getAllToolFunctions).not.toHaveBeenCalled(); + expect(convertMCPToolToPlugin).toHaveBeenCalledWith({ + toolKey: 'tool1~~~server1', + toolData: cachedTools['tool1~~~server1'], + mcpManager: mockMCPManager, + }); + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin, + authConfig: [], + authenticated: true, + }, + ]); + }); + + it('should fetch from MCP manager when cache is empty', async () => { + getMCPServerTools.mockResolvedValue(null); + + const allTools = { + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + 'tool2~~~server2': { + type: 'function', + function: { + name: 'tool2', + description: 'Tool 2', + parameters: {}, + }, + }, + }; + + mockMCPManager.getAllToolFunctions.mockResolvedValue(allTools); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: {}, + }, + }); + + const mockPlugin = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + convertMCPToolToPlugin.mockReturnValue(mockPlugin); + + await getMCPTools(mockReq, mockRes); + + expect(getMCPServerTools).toHaveBeenCalledWith('server1'); + expect(mockMCPManager.getAllToolFunctions).toHaveBeenCalledWith('test-user-id'); + + // Should cache the server tools + const { cacheMCPServerTools } = require('~/server/services/Config'); + expect(cacheMCPServerTools).toHaveBeenCalledWith({ + serverName: 'server1', + serverTools: { + 'tool1~~~server1': allTools['tool1~~~server1'], + }, + }); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin, + authConfig: [], + authenticated: true, + }, + ]); + }); + + it('should handle custom user variables in server config', async () => { + getMCPServerTools.mockResolvedValue({ + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + }); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: { + customUserVars: { + API_KEY: { + title: 'API Key', + description: 'Your API key', + }, + SECRET: { + title: 'Secret Token', + description: 'Your secret token', + }, + }, + }, + }, + }); + + const mockPlugin = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + convertMCPToolToPlugin.mockReturnValue(mockPlugin); + + await getMCPTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin, + authConfig: [ + { + authField: 'API_KEY', + label: 'API Key', + description: 'Your API key', + }, + { + authField: 'SECRET', + label: 'Secret Token', + description: 'Your secret token', + }, + ], + authenticated: false, + }, + ]); + }); + + it('should handle empty custom user variables', async () => { + getMCPServerTools.mockResolvedValue({ + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + }); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: { + customUserVars: {}, + }, + }, + }); + + const mockPlugin = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + convertMCPToolToPlugin.mockReturnValue(mockPlugin); + + await getMCPTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin, + authConfig: [], + authenticated: true, + }, + ]); + }); + + it('should handle multiple servers', async () => { + getMCPServerTools.mockResolvedValue(null); + + const allTools = { + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + 'tool2~~~server2': { + type: 'function', + function: { + name: 'tool2', + description: 'Tool 2', + parameters: {}, + }, + }, + }; + + mockMCPManager.getAllToolFunctions.mockResolvedValue(allTools); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: {}, + server2: {}, + }, + }); + + const mockPlugin1 = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + const mockPlugin2 = { + name: 'Tool 2', + pluginKey: 'tool2~~~server2', + description: 'Tool 2', + }; + + convertMCPToolToPlugin.mockReturnValueOnce(mockPlugin1).mockReturnValueOnce(mockPlugin2); + + await getMCPTools(mockReq, mockRes); + + expect(getMCPServerTools).toHaveBeenCalledWith('server1'); + expect(getMCPServerTools).toHaveBeenCalledWith('server2'); + expect(mockMCPManager.getAllToolFunctions).toHaveBeenCalledTimes(2); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin1, + authConfig: [], + authenticated: true, + }, + { + ...mockPlugin2, + authConfig: [], + authenticated: true, + }, + ]); + }); + + it('should handle server-specific errors gracefully', async () => { + getMCPServerTools.mockResolvedValue(null); + mockMCPManager.getAllToolFunctions.mockRejectedValue(new Error('Server connection failed')); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: {}, + server2: {}, + }, + }); + + await getMCPTools(mockReq, mockRes); + + const { logger } = require('@librechat/data-schemas'); + expect(logger.error).toHaveBeenCalledWith( + '[getMCPTools] Error loading tools for server server1:', + expect.any(Error), + ); + expect(logger.error).toHaveBeenCalledWith( + '[getMCPTools] Error loading tools for server server2:', + expect.any(Error), + ); + + // Should still return 200 with empty array + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([]); + }); + + it('should skip tools when convertMCPToolToPlugin returns null', async () => { + getMCPServerTools.mockResolvedValue({ + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + 'tool2~~~server1': { + type: 'function', + function: { + name: 'tool2', + description: 'Tool 2', + parameters: {}, + }, + }, + }); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: {}, + }, + }); + + const mockPlugin = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + + // First tool returns plugin, second returns null + convertMCPToolToPlugin.mockReturnValueOnce(mockPlugin).mockReturnValueOnce(null); + + await getMCPTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin, + authConfig: [], + authenticated: true, + }, + ]); + }); + + it('should use req.config when available', async () => { + const reqConfig = { + mcpConfig: { + server1: {}, + }, + }; + mockReq.config = reqConfig; + + getMCPServerTools.mockResolvedValue({ + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + }); + + const mockPlugin = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + convertMCPToolToPlugin.mockReturnValue(mockPlugin); + + await getMCPTools(mockReq, mockRes); + + // Should not call getAppConfig when req.config is available + expect(getAppConfig).not.toHaveBeenCalled(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin, + authConfig: [], + authenticated: true, + }, + ]); + }); + + it('should handle general error in getMCPTools', async () => { + const error = new Error('Unexpected error'); + getAppConfig.mockRejectedValue(error); + + await getMCPTools(mockReq, mockRes); + + const { logger } = require('@librechat/data-schemas'); + expect(logger.error).toHaveBeenCalledWith('[getMCPTools]', error); + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ message: 'Unexpected error' }); + }); + + it('should handle custom user variables without title or description', async () => { + getMCPServerTools.mockResolvedValue({ + 'tool1~~~server1': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + }); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: { + customUserVars: { + MY_VAR: { + // No title or description + }, + }, + }, + }, + }); + + const mockPlugin = { + name: 'Tool 1', + pluginKey: 'tool1~~~server1', + description: 'Tool 1', + }; + convertMCPToolToPlugin.mockReturnValue(mockPlugin); + + await getMCPTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([ + { + ...mockPlugin, + authConfig: [ + { + authField: 'MY_VAR', + label: 'MY_VAR', // Falls back to key + description: '', // Empty string + }, + ], + authenticated: false, + }, + ]); + }); + + it('should not cache when no tools are found for a server', async () => { + getMCPServerTools.mockResolvedValue(null); + + const allTools = { + 'tool1~~~otherserver': { + type: 'function', + function: { + name: 'tool1', + description: 'Tool 1', + parameters: {}, + }, + }, + }; + + mockMCPManager.getAllToolFunctions.mockResolvedValue(allTools); + + getAppConfig.mockResolvedValue({ + mcpConfig: { + server1: {}, + }, + }); + + await getMCPTools(mockReq, mockRes); + + const { cacheMCPServerTools } = require('~/server/services/Config'); + expect(cacheMCPServerTools).not.toHaveBeenCalled(); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([]); + }); + }); +}); diff --git a/api/server/routes/__tests__/mcp.spec.js b/api/server/routes/__tests__/mcp.spec.js index b572340c5..1eb55224f 100644 --- a/api/server/routes/__tests__/mcp.spec.js +++ b/api/server/routes/__tests__/mcp.spec.js @@ -47,8 +47,8 @@ jest.mock('~/server/services/Config', () => ({ loadCustomConfig: jest.fn(), })); -jest.mock('~/server/services/Config/mcpToolsCache', () => ({ - updateMCPUserTools: jest.fn(), +jest.mock('~/server/services/Config/mcp', () => ({ + updateMCPServerTools: jest.fn(), })); jest.mock('~/server/services/MCP', () => ({ @@ -778,10 +778,10 @@ describe('MCP Routes', () => { require('~/cache').getLogStores.mockReturnValue({}); const { getCachedTools, setCachedTools } = require('~/server/services/Config'); - const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache'); + const { updateMCPServerTools } = require('~/server/services/Config/mcp'); getCachedTools.mockResolvedValue({}); setCachedTools.mockResolvedValue(); - updateMCPUserTools.mockResolvedValue(); + updateMCPServerTools.mockResolvedValue(); require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({ success: true, @@ -836,10 +836,10 @@ describe('MCP Routes', () => { ]); const { getCachedTools, setCachedTools } = require('~/server/services/Config'); - const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache'); + const { updateMCPServerTools } = require('~/server/services/Config/mcp'); getCachedTools.mockResolvedValue({}); setCachedTools.mockResolvedValue(); - updateMCPUserTools.mockResolvedValue(); + updateMCPServerTools.mockResolvedValue(); require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({ success: true, diff --git a/api/server/routes/mcp.js b/api/server/routes/mcp.js index bff919158..fa0166830 100644 --- a/api/server/routes/mcp.js +++ b/api/server/routes/mcp.js @@ -5,15 +5,24 @@ const { MCPOAuthHandler, getUserMCPAuthMap } = require('@librechat/api'); const { getMCPManager, getFlowStateManager, getOAuthReconnectionManager } = require('~/config'); const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP'); const { findToken, updateToken, createToken, deleteTokens } = require('~/models'); -const { updateMCPUserTools } = require('~/server/services/Config/mcpToolsCache'); const { getUserPluginAuthValue } = require('~/server/services/PluginService'); +const { updateMCPServerTools } = require('~/server/services/Config/mcp'); const { reinitMCPServer } = require('~/server/services/Tools/mcp'); +const { getMCPTools } = require('~/server/controllers/mcp'); const { requireJwtAuth } = require('~/server/middleware'); const { findPluginAuthsByKeys } = require('~/models'); const { getLogStores } = require('~/cache'); const router = Router(); +/** + * Get all MCP tools available to the user + * Returns only MCP tools, completely decoupled from regular LibreChat tools + */ +router.get('/tools', requireJwtAuth, async (req, res) => { + return getMCPTools(req, res); +}); + /** * Initiate OAuth flow * This endpoint is called when the user clicks the auth link in the UI @@ -149,8 +158,7 @@ router.get('/:serverName/oauth/callback', async (req, res) => { oauthReconnectionManager.clearReconnection(flowState.userId, serverName); const tools = await userConnection.fetchTools(); - await updateMCPUserTools({ - userId: flowState.userId, + await updateMCPServerTools({ serverName, tools, }); diff --git a/api/server/services/Config/app.js b/api/server/services/Config/app.js index a5e771eff..ec6af7743 100644 --- a/api/server/services/Config/app.js +++ b/api/server/services/Config/app.js @@ -36,7 +36,7 @@ async function getAppConfig(options = {}) { } if (baseConfig.availableTools) { - await setCachedTools(baseConfig.availableTools, { isGlobal: true }); + await setCachedTools(baseConfig.availableTools); } await cache.set(BASE_CONFIG_KEY, baseConfig); diff --git a/api/server/services/Config/getCachedTools.js b/api/server/services/Config/getCachedTools.js index 669c179a8..e33b2046a 100644 --- a/api/server/services/Config/getCachedTools.js +++ b/api/server/services/Config/getCachedTools.js @@ -3,89 +3,32 @@ const getLogStores = require('~/cache/getLogStores'); /** * Cache key generators for different tool access patterns - * These will support future permission-based caching */ const ToolCacheKeys = { /** Global tools available to all users */ GLOBAL: 'tools:global', - /** Tools available to a specific user */ - USER: (userId) => `tools:user:${userId}`, - /** Tools available to a specific role */ - ROLE: (roleId) => `tools:role:${roleId}`, - /** Tools available to a specific group */ - GROUP: (groupId) => `tools:group:${groupId}`, - /** Combined effective tools for a user (computed from all sources) */ - EFFECTIVE: (userId) => `tools:effective:${userId}`, + /** MCP tools cached by server name */ + MCP_SERVER: (serverName) => `tools:mcp:${serverName}`, }; /** * Retrieves available tools from cache * @function getCachedTools * @param {Object} options - Options for retrieving tools - * @param {string} [options.userId] - User ID for user-specific tools - * @param {string[]} [options.roleIds] - Role IDs for role-based tools - * @param {string[]} [options.groupIds] - Group IDs for group-based tools - * @param {boolean} [options.includeGlobal=true] - Whether to include global tools + * @param {string} [options.serverName] - MCP server name to get cached tools for * @returns {Promise} The available tools object or null if not cached */ async function getCachedTools(options = {}) { const cache = getLogStores(CacheKeys.CONFIG_STORE); - const { userId, roleIds = [], groupIds = [], includeGlobal = true } = options; + const { serverName } = options; - // For now, return global tools (current behavior) - // This will be expanded to merge tools from different sources - if (!userId && includeGlobal) { - return await cache.get(ToolCacheKeys.GLOBAL); + // Return MCP server-specific tools if requested + if (serverName) { + return await cache.get(ToolCacheKeys.MCP_SERVER(serverName)); } - // Future implementation will merge tools from multiple sources - // based on user permissions, roles, and groups - if (userId) { - /** @type {LCAvailableTools | null} Check if we have pre-computed effective tools for this user */ - const effectiveTools = await cache.get(ToolCacheKeys.EFFECTIVE(userId)); - if (effectiveTools) { - return effectiveTools; - } - - /** @type {LCAvailableTools | null} Otherwise, compute from individual sources */ - const toolSources = []; - - if (includeGlobal) { - const globalTools = await cache.get(ToolCacheKeys.GLOBAL); - if (globalTools) { - toolSources.push(globalTools); - } - } - - // User-specific tools - const userTools = await cache.get(ToolCacheKeys.USER(userId)); - if (userTools) { - toolSources.push(userTools); - } - - // Role-based tools - for (const roleId of roleIds) { - const roleTools = await cache.get(ToolCacheKeys.ROLE(roleId)); - if (roleTools) { - toolSources.push(roleTools); - } - } - - // Group-based tools - for (const groupId of groupIds) { - const groupTools = await cache.get(ToolCacheKeys.GROUP(groupId)); - if (groupTools) { - toolSources.push(groupTools); - } - } - - // Merge all tool sources (for now, simple merge - future will handle conflicts) - if (toolSources.length > 0) { - return mergeToolSources(toolSources); - } - } - - return null; + // Default to global tools + return await cache.get(ToolCacheKeys.GLOBAL); } /** @@ -93,49 +36,34 @@ async function getCachedTools(options = {}) { * @function setCachedTools * @param {Object} tools - The tools object to cache * @param {Object} options - Options for caching tools - * @param {string} [options.userId] - User ID for user-specific tools - * @param {string} [options.roleId] - Role ID for role-based tools - * @param {string} [options.groupId] - Group ID for group-based tools - * @param {boolean} [options.isGlobal=false] - Whether these are global tools + * @param {string} [options.serverName] - MCP server name for server-specific tools * @param {number} [options.ttl] - Time to live in milliseconds * @returns {Promise} Whether the operation was successful */ async function setCachedTools(tools, options = {}) { const cache = getLogStores(CacheKeys.CONFIG_STORE); - const { userId, roleId, groupId, isGlobal = false, ttl } = options; + const { serverName, ttl } = options; - let cacheKey; - if (isGlobal || (!userId && !roleId && !groupId)) { - cacheKey = ToolCacheKeys.GLOBAL; - } else if (userId) { - cacheKey = ToolCacheKeys.USER(userId); - } else if (roleId) { - cacheKey = ToolCacheKeys.ROLE(roleId); - } else if (groupId) { - cacheKey = ToolCacheKeys.GROUP(groupId); + // Cache by MCP server if specified + if (serverName) { + return await cache.set(ToolCacheKeys.MCP_SERVER(serverName), tools, ttl); } - if (!cacheKey) { - throw new Error('Invalid cache key options provided'); - } - - return await cache.set(cacheKey, tools, ttl); + // Default to global cache + return await cache.set(ToolCacheKeys.GLOBAL, tools, ttl); } /** * Invalidates cached tools * @function invalidateCachedTools * @param {Object} options - Options for invalidating tools - * @param {string} [options.userId] - User ID to invalidate - * @param {string} [options.roleId] - Role ID to invalidate - * @param {string} [options.groupId] - Group ID to invalidate + * @param {string} [options.serverName] - MCP server name to invalidate * @param {boolean} [options.invalidateGlobal=false] - Whether to invalidate global tools - * @param {boolean} [options.invalidateEffective=true] - Whether to invalidate effective tools * @returns {Promise} */ async function invalidateCachedTools(options = {}) { const cache = getLogStores(CacheKeys.CONFIG_STORE); - const { userId, roleId, groupId, invalidateGlobal = false, invalidateEffective = true } = options; + const { serverName, invalidateGlobal = false } = options; const keysToDelete = []; @@ -143,116 +71,45 @@ async function invalidateCachedTools(options = {}) { keysToDelete.push(ToolCacheKeys.GLOBAL); } - if (userId) { - keysToDelete.push(ToolCacheKeys.USER(userId)); - if (invalidateEffective) { - keysToDelete.push(ToolCacheKeys.EFFECTIVE(userId)); - } - } - - if (roleId) { - keysToDelete.push(ToolCacheKeys.ROLE(roleId)); - // TODO: In future, invalidate all users with this role - } - - if (groupId) { - keysToDelete.push(ToolCacheKeys.GROUP(groupId)); - // TODO: In future, invalidate all users in this group + if (serverName) { + keysToDelete.push(ToolCacheKeys.MCP_SERVER(serverName)); } await Promise.all(keysToDelete.map((key) => cache.delete(key))); } /** - * Computes and caches effective tools for a user - * @function computeEffectiveTools - * @param {string} userId - The user ID - * @param {Object} context - Context containing user's roles and groups - * @param {string[]} [context.roleIds=[]] - User's role IDs - * @param {string[]} [context.groupIds=[]] - User's group IDs - * @param {number} [ttl] - Time to live for the computed result - * @returns {Promise} The computed effective tools + * Gets MCP tools for a specific server from cache or merges with global tools + * @function getMCPServerTools + * @param {string} serverName - The MCP server name + * @returns {Promise} The available tools for the server */ -async function computeEffectiveTools(userId, context = {}, ttl) { - const { roleIds = [], groupIds = [] } = context; +async function getMCPServerTools(serverName) { + const cache = getLogStores(CacheKeys.CONFIG_STORE); + const serverTools = await cache.get(ToolCacheKeys.MCP_SERVER(serverName)); - // Get all tool sources - const tools = await getCachedTools({ - userId, - roleIds, - groupIds, - includeGlobal: true, - }); - - if (tools) { - // Cache the computed result - const cache = getLogStores(CacheKeys.CONFIG_STORE); - await cache.set(ToolCacheKeys.EFFECTIVE(userId), tools, ttl); + if (serverTools) { + return serverTools; } - return tools; -} - -/** - * Merges multiple tool sources into a single tools object - * @function mergeToolSources - * @param {Object[]} sources - Array of tool objects to merge - * @returns {Object} Merged tools object - */ -function mergeToolSources(sources) { - // For now, simple merge that combines all tools - // Future implementation will handle: - // - Permission precedence (deny > allow) - // - Tool property conflicts - // - Metadata merging - const merged = {}; - - for (const source of sources) { - if (!source || typeof source !== 'object') { - continue; - } - - for (const [toolId, toolConfig] of Object.entries(source)) { - // Simple last-write-wins for now - // Future: merge based on permission levels - merged[toolId] = toolConfig; - } - } - - return merged; + return null; } /** * Middleware-friendly function to get tools for a request * @function getToolsForRequest - * @param {Object} req - Express request object + * @param {Object} [req] - Express request object * @returns {Promise} Available tools for the request */ -async function getToolsForRequest(req) { - const userId = req.user?.id; - - // For now, return global tools if no user - if (!userId) { - return getCachedTools({ includeGlobal: true }); - } - - // Future: Extract roles and groups from req.user - const roleIds = req.user?.roles || []; - const groupIds = req.user?.groups || []; - - return getCachedTools({ - userId, - roleIds, - groupIds, - includeGlobal: true, - }); +async function getToolsForRequest(_req) { + return getCachedTools(); } module.exports = { ToolCacheKeys, getCachedTools, setCachedTools, + getMCPServerTools, getToolsForRequest, invalidateCachedTools, - computeEffectiveTools, }; diff --git a/api/server/services/Config/index.js b/api/server/services/Config/index.js index f1767ddec..63c9108c9 100644 --- a/api/server/services/Config/index.js +++ b/api/server/services/Config/index.js @@ -1,7 +1,7 @@ const appConfig = require('./app'); +const mcpToolsCache = require('./mcp'); const { config } = require('./EndpointService'); const getCachedTools = require('./getCachedTools'); -const mcpToolsCache = require('./mcpToolsCache'); const loadCustomConfig = require('./loadCustomConfig'); const loadConfigModels = require('./loadConfigModels'); const loadDefaultModels = require('./loadDefaultModels'); diff --git a/api/server/services/Config/mcpToolsCache.js b/api/server/services/Config/mcp.js similarity index 56% rename from api/server/services/Config/mcpToolsCache.js rename to api/server/services/Config/mcp.js index d335868d8..aab2edf3c 100644 --- a/api/server/services/Config/mcpToolsCache.js +++ b/api/server/services/Config/mcp.js @@ -4,27 +4,20 @@ const { getCachedTools, setCachedTools } = require('./getCachedTools'); const { getLogStores } = require('~/cache'); /** - * Updates MCP tools in the cache for a specific server and user + * Updates MCP tools in the cache for a specific server * @param {Object} params - Parameters for updating MCP tools - * @param {string} params.userId - User ID * @param {string} params.serverName - MCP server name * @param {Array} params.tools - Array of tool objects from MCP server * @returns {Promise} */ -async function updateMCPUserTools({ userId, serverName, tools }) { +async function updateMCPServerTools({ serverName, tools }) { try { - const userTools = await getCachedTools({ userId }); - + const serverTools = {}; const mcpDelimiter = Constants.mcp_delimiter; - for (const key of Object.keys(userTools)) { - if (key.endsWith(`${mcpDelimiter}${serverName}`)) { - delete userTools[key]; - } - } for (const tool of tools) { - const name = `${tool.name}${Constants.mcp_delimiter}${serverName}`; - userTools[name] = { + const name = `${tool.name}${mcpDelimiter}${serverName}`; + serverTools[name] = { type: 'function', ['function']: { name, @@ -34,12 +27,12 @@ async function updateMCPUserTools({ userId, serverName, tools }) { }; } - await setCachedTools(userTools, { userId }); + await setCachedTools(serverTools, { serverName }); const cache = getLogStores(CacheKeys.CONFIG_STORE); await cache.delete(CacheKeys.TOOLS); - logger.debug(`[MCP Cache] Updated ${tools.length} tools for ${serverName} user ${userId}`); - return userTools; + logger.debug(`[MCP Cache] Updated ${tools.length} tools for server ${serverName}`); + return serverTools; } catch (error) { logger.error(`[MCP Cache] Failed to update tools for ${serverName}:`, error); throw error; @@ -57,9 +50,9 @@ async function mergeAppTools(appTools) { if (!count) { return; } - const cachedTools = await getCachedTools({ includeGlobal: true }); + const cachedTools = await getCachedTools(); const mergedTools = { ...cachedTools, ...appTools }; - await setCachedTools(mergedTools, { isGlobal: true }); + await setCachedTools(mergedTools); const cache = getLogStores(CacheKeys.CONFIG_STORE); await cache.delete(CacheKeys.TOOLS); logger.debug(`Merged ${count} app-level tools`); @@ -70,30 +63,23 @@ async function mergeAppTools(appTools) { } /** - * Merges user-level tools with global tools + * Caches MCP server tools (no longer merges with global) * @param {object} params - * @param {string} params.userId - * @param {Record} params.cachedUserTools - * @param {import('@librechat/api').LCAvailableTools} params.userTools + * @param {string} params.serverName + * @param {import('@librechat/api').LCAvailableTools} params.serverTools * @returns {Promise} */ -async function mergeUserTools({ userId, cachedUserTools, userTools }) { +async function cacheMCPServerTools({ serverName, serverTools }) { try { - if (!userId) { - return; - } - const count = Object.keys(userTools).length; + const count = Object.keys(serverTools).length; if (!count) { return; } - const cachedTools = cachedUserTools ?? (await getCachedTools({ userId })); - const mergedTools = { ...cachedTools, ...userTools }; - await setCachedTools(mergedTools, { userId }); - const cache = getLogStores(CacheKeys.CONFIG_STORE); - await cache.delete(CacheKeys.TOOLS); - logger.debug(`Merged ${count} user-level tools`); + // Only cache server-specific tools, no merging with global + await setCachedTools(serverTools, { serverName }); + logger.debug(`Cached ${count} MCP server tools for ${serverName}`); } catch (error) { - logger.error('Failed to merge user-level tools:', error); + logger.error(`Failed to cache MCP server tools for ${serverName}:`, error); throw error; } } @@ -101,13 +87,12 @@ async function mergeUserTools({ userId, cachedUserTools, userTools }) { /** * Clears all MCP tools for a specific server * @param {Object} params - Parameters for clearing MCP tools - * @param {string} [params.userId] - User ID (if clearing user-specific tools) * @param {string} params.serverName - MCP server name * @returns {Promise} */ -async function clearMCPServerTools({ userId, serverName }) { +async function clearMCPServerTools({ serverName }) { try { - const tools = await getCachedTools({ userId, includeGlobal: !userId }); + const tools = await getCachedTools(); // Remove all tools for this server const mcpDelimiter = Constants.mcp_delimiter; @@ -120,14 +105,14 @@ async function clearMCPServerTools({ userId, serverName }) { } if (removedCount > 0) { - await setCachedTools(tools, userId ? { userId } : { isGlobal: true }); + await setCachedTools(tools); const cache = getLogStores(CacheKeys.CONFIG_STORE); await cache.delete(CacheKeys.TOOLS); + // Also clear the server-specific cache + await cache.delete(`tools:mcp:${serverName}`); - logger.debug( - `[MCP Cache] Removed ${removedCount} tools for ${serverName}${userId ? ` user ${userId}` : ' (global)'}`, - ); + logger.debug(`[MCP Cache] Removed ${removedCount} tools for ${serverName} (global)`); } } catch (error) { logger.error(`[MCP Cache] Failed to clear tools for ${serverName}:`, error); @@ -137,7 +122,7 @@ async function clearMCPServerTools({ userId, serverName }) { module.exports = { mergeAppTools, - mergeUserTools, - updateMCPUserTools, + cacheMCPServerTools, clearMCPServerTools, + updateMCPServerTools, }; diff --git a/api/server/services/Tools/mcp.js b/api/server/services/Tools/mcp.js index 069447e3b..c9c369868 100644 --- a/api/server/services/Tools/mcp.js +++ b/api/server/services/Tools/mcp.js @@ -2,7 +2,7 @@ const { logger } = require('@librechat/data-schemas'); const { CacheKeys, Constants } = require('librechat-data-provider'); const { findToken, createToken, updateToken, deleteTokens } = require('~/models'); const { getMCPManager, getFlowStateManager } = require('~/config'); -const { updateMCPUserTools } = require('~/server/services/Config'); +const { updateMCPServerTools } = require('~/server/services/Config'); const { getLogStores } = require('~/cache'); /** @@ -97,8 +97,7 @@ async function reinitMCPServer({ if (userConnection && !oauthRequired) { tools = await userConnection.fetchTools(); - availableTools = await updateMCPUserTools({ - userId: req.user.id, + availableTools = await updateMCPServerTools({ serverName, tools, }); diff --git a/client/src/Providers/AgentPanelContext.tsx b/client/src/Providers/AgentPanelContext.tsx index 135066c78..e97e25403 100644 --- a/client/src/Providers/AgentPanelContext.tsx +++ b/client/src/Providers/AgentPanelContext.tsx @@ -1,14 +1,16 @@ import React, { createContext, useContext, useState, useMemo } from 'react'; import { Constants, EModelEndpoint } from 'librechat-data-provider'; -import type { MCP, Action, TPlugin, AgentToolType } from 'librechat-data-provider'; +import type { MCP, Action, TPlugin } from 'librechat-data-provider'; import type { AgentPanelContextType, MCPServerInfo } from '~/common'; -import { useAvailableToolsQuery, useGetActionsQuery, useGetStartupConfig } from '~/data-provider'; +import { + useAvailableToolsQuery, + useGetActionsQuery, + useGetStartupConfig, + useMCPToolsQuery, +} from '~/data-provider'; import { useLocalize, useGetAgentsConfig, useMCPConnectionStatus } from '~/hooks'; import { Panel } from '~/common'; -type GroupedToolType = AgentToolType & { tools?: AgentToolType[] }; -type GroupedToolsRecord = Record; - const AgentPanelContext = createContext(undefined); export function useAgentPanelContext() { @@ -32,11 +34,16 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) enabled: !!agent_id, }); - const { data: pluginTools } = useAvailableToolsQuery(EModelEndpoint.agents, { + const { data: regularTools } = useAvailableToolsQuery(EModelEndpoint.agents, { + enabled: !!agent_id, + }); + + const { data: mcpTools } = useMCPToolsQuery({ enabled: !!agent_id, }); const { data: startupConfig } = useGetStartupConfig(); + const { agentsConfig, endpointsConfig } = useGetAgentsConfig(); const mcpServerNames = useMemo( () => Object.keys(startupConfig?.mcpServers ?? {}), [startupConfig], @@ -46,61 +53,43 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) enabled: !!agent_id && mcpServerNames.length > 0, }); - const processedData = useMemo(() => { - if (!pluginTools) { - return { - tools: [], - groupedTools: {}, - mcpServersMap: new Map(), - }; - } - - const tools: AgentToolType[] = []; - const groupedTools: GroupedToolsRecord = {}; - + const mcpServersMap = useMemo(() => { const configuredServers = new Set(mcpServerNames); - const mcpServersMap = new Map(); + const serversMap = new Map(); - for (const pluginTool of pluginTools) { - const tool: AgentToolType = { - tool_id: pluginTool.pluginKey, - metadata: pluginTool as TPlugin, - }; + if (mcpTools) { + for (const pluginTool of mcpTools) { + if (pluginTool.pluginKey.includes(Constants.mcp_delimiter)) { + const [_toolName, serverName] = pluginTool.pluginKey.split(Constants.mcp_delimiter); - tools.push(tool); + if (!serversMap.has(serverName)) { + const metadata = { + name: serverName, + pluginKey: serverName, + description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, + icon: pluginTool.icon || '', + } as TPlugin; - if (tool.tool_id.includes(Constants.mcp_delimiter)) { - const [_toolName, serverName] = tool.tool_id.split(Constants.mcp_delimiter); + serversMap.set(serverName, { + serverName, + tools: [], + isConfigured: configuredServers.has(serverName), + isConnected: connectionStatus?.[serverName]?.connectionState === 'connected', + metadata, + }); + } - if (!mcpServersMap.has(serverName)) { - const metadata = { - name: serverName, - pluginKey: serverName, - description: `${localize('com_ui_tool_collection_prefix')} ${serverName}`, - icon: pluginTool.icon || '', - } as TPlugin; - - mcpServersMap.set(serverName, { - serverName, - tools: [], - isConfigured: configuredServers.has(serverName), - isConnected: connectionStatus?.[serverName]?.connectionState === 'connected', - metadata, + serversMap.get(serverName)!.tools.push({ + tool_id: pluginTool.pluginKey, + metadata: pluginTool as TPlugin, }); } - - mcpServersMap.get(serverName)!.tools.push(tool); - } else { - // Non-MCP tool - groupedTools[tool.tool_id] = { - tool_id: tool.tool_id, - metadata: tool.metadata, - }; } } + // Add configured servers that don't have tools yet for (const mcpServerName of mcpServerNames) { - if (mcpServersMap.has(mcpServerName)) { + if (serversMap.has(mcpServerName)) { continue; } const metadata = { @@ -110,7 +99,7 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) description: `${localize('com_ui_tool_collection_prefix')} ${mcpServerName}`, } as TPlugin; - mcpServersMap.set(mcpServerName, { + serversMap.set(mcpServerName, { tools: [], metadata, isConfigured: true, @@ -119,14 +108,8 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) }); } - return { - tools, - groupedTools, - mcpServersMap, - }; - }, [pluginTools, localize, mcpServerNames, connectionStatus]); - - const { agentsConfig, endpointsConfig } = useGetAgentsConfig(); + return serversMap; + }, [mcpTools, localize, mcpServerNames, connectionStatus]); const value: AgentPanelContextType = { mcp, @@ -137,16 +120,15 @@ export function AgentPanelProvider({ children }: { children: React.ReactNode }) setMcps, agent_id, setAction, - pluginTools, + mcpTools, activePanel, + regularTools, agentsConfig, startupConfig, + mcpServersMap, setActivePanel, endpointsConfig, setCurrentAgentId, - tools: processedData.tools, - groupedTools: processedData.groupedTools, - mcpServersMap: processedData.mcpServersMap, }; return {children}; diff --git a/client/src/common/types.ts b/client/src/common/types.ts index 5ea6ad6bb..211c7a7a7 100644 --- a/client/src/common/types.ts +++ b/client/src/common/types.ts @@ -232,10 +232,9 @@ export type AgentPanelContextType = { mcps?: t.MCP[]; setMcp: React.Dispatch>; setMcps: React.Dispatch>; - groupedTools: Record; activePanel?: string; - tools: t.AgentToolType[]; - pluginTools?: t.TPlugin[]; + regularTools?: t.TPlugin[]; + mcpTools?: t.TPlugin[]; setActivePanel: React.Dispatch>; setCurrentAgentId: React.Dispatch>; agent_id?: string; diff --git a/client/src/components/SidePanel/Agents/AgentConfig.tsx b/client/src/components/SidePanel/Agents/AgentConfig.tsx index 7f296fe8c..dcb047374 100644 --- a/client/src/components/SidePanel/Agents/AgentConfig.tsx +++ b/client/src/components/SidePanel/Agents/AgentConfig.tsx @@ -48,12 +48,12 @@ export default function AgentConfig({ createMutation }: Pick @@ -326,16 +326,15 @@ export default function AgentConfig({ createMutation }: Pick
- {/* Render all visible IDs (including groups with subtools selected) */} + {/* Render all visible IDs */} {toolIds.map((toolId, i) => { - if (!allTools) return null; - const tool = allTools[toolId]; + const tool = regularTools?.find((t) => t.pluginKey === toolId); if (!tool) return null; return ( ); diff --git a/client/src/components/SidePanel/Agents/AgentTool.tsx b/client/src/components/SidePanel/Agents/AgentTool.tsx index 07857c7be..5685506fe 100644 --- a/client/src/components/SidePanel/Agents/AgentTool.tsx +++ b/client/src/components/SidePanel/Agents/AgentTool.tsx @@ -1,68 +1,47 @@ import React, { useState } from 'react'; -import * as Ariakit from '@ariakit/react'; -import { ChevronDown } from 'lucide-react'; import { useFormContext } from 'react-hook-form'; -import * as AccordionPrimitive from '@radix-ui/react-accordion'; import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-query'; import { - Accordion, - AccordionItem, - AccordionContent, + OGDialog, TrashIcon, CircleHelpIcon, - OGDialog, - OGDialogTrigger, - Label, - Checkbox, - OGDialogTemplate, useToastContext, + OGDialogTrigger, + OGDialogTemplate, } from '@librechat/client'; -import type { AgentToolType } from 'librechat-data-provider'; +import type { TPlugin } from 'librechat-data-provider'; import type { AgentForm } from '~/common'; import { useLocalize } from '~/hooks'; import { cn } from '~/utils'; export default function AgentTool({ tool, - allTools, + regularTools, }: { tool: string; - allTools?: Record; + regularTools?: TPlugin[]; agent_id?: string; }) { - const [isHovering, setIsHovering] = useState(false); - const [isFocused, setIsFocused] = useState(false); - const [hoveredToolId, setHoveredToolId] = useState(null); - const [accordionValue, setAccordionValue] = useState(''); const localize = useLocalize(); const { showToast } = useToastContext(); const updateUserPlugins = useUpdateUserPluginsMutation(); const { getValues, setValue } = useFormContext(); - if (!allTools) { + + const [isFocused, setIsFocused] = useState(false); + const [isHovering, setIsHovering] = useState(false); + + if (!regularTools) { return null; } - const currentTool = allTools[tool]; - const getSelectedTools = () => { - if (!currentTool?.tools) return []; - const formTools = getValues('tools') || []; - return currentTool.tools.filter((t) => formTools.includes(t.tool_id)).map((t) => t.tool_id); - }; - const updateFormTools = (newSelectedTools: string[]) => { - const currentTools = getValues('tools') || []; - const otherTools = currentTools.filter( - (t: string) => !currentTool?.tools?.some((st) => st.tool_id === t), - ); - setValue('tools', [...otherTools, ...newSelectedTools]); - }; + const currentTool = regularTools.find((t) => t.pluginKey === tool); + + if (!currentTool) { + return null; + } const removeTool = (toolId: string) => { if (toolId) { - const toolIdsToRemove = - isGroup && currentTool.tools - ? [toolId, ...currentTool.tools.map((t) => t.tool_id)] - : [toolId]; - updateUserPlugins.mutate( { pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true }, { @@ -70,9 +49,7 @@ export default function AgentTool({ showToast({ message: `Error while deleting the tool: ${error}`, status: 'error' }); }, onSuccess: () => { - const remainingToolIds = getValues('tools')?.filter( - (toolId: string) => !toolIdsToRemove.includes(toolId), - ); + const remainingToolIds = getValues('tools')?.filter((id: string) => id !== toolId); setValue('tools', remainingToolIds); showToast({ message: 'Tool deleted successfully', status: 'success' }); }, @@ -81,327 +58,82 @@ export default function AgentTool({ } }; - if (!currentTool) { - return null; - } - - const isGroup = currentTool.tools && currentTool.tools.length > 0; - const selectedTools = getSelectedTools(); - const isExpanded = accordionValue === currentTool.tool_id; - - if (!isGroup) { - return ( - -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - onFocus={() => setIsFocused(true)} - onBlur={(e) => { - // Check if focus is moving to a child element - if (!e.currentTarget.contains(e.relatedTarget)) { - setIsFocused(false); - } - }} - > -
- {currentTool.metadata.icon && ( -
-
-
- )} -
- {currentTool.metadata.name} -
-
- - - - -
- - {localize('com_ui_delete_tool_confirm')} - - } - selection={{ - selectHandler: () => removeTool(currentTool.tool_id), - selectClasses: - 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white', - selectText: localize('com_ui_delete'), - }} - /> - - ); - } - - // Group tool with accordion return ( - - -
setIsHovering(true)} - onMouseLeave={() => setIsHovering(false)} - onFocus={() => setIsFocused(true)} - onBlur={(e) => { - // Check if focus is moving to a child element - if (!e.currentTarget.contains(e.relatedTarget)) { - setIsFocused(false); - } - }} - > - - - - -
-
-
- - - -
- - -
- {currentTool.tools?.map((subTool) => ( - - ))} +
setIsHovering(true)} + onMouseLeave={() => setIsHovering(false)} + onFocus={() => setIsFocused(true)} + onBlur={(e) => { + // Check if focus is moving to a child element + if (!e.currentTarget.contains(e.relatedTarget)) { + setIsFocused(false); + } + }} + > +
+ {currentTool.icon && ( +
+
- - - + )} +
+ {currentTool.name} +
+
+ + + + +
- {localize('com_ui_delete_tool_confirm')} - + <> +
+

+ {localize('com_ui_delete_tool_confirm')}{' '} + "{currentTool.name}"? +

+ {currentTool.description && ( +
+ +

{currentTool.description}

+
+ )} +
+ } selection={{ - selectHandler: () => removeTool(currentTool.tool_id), + selectHandler: () => removeTool(tool), selectClasses: - 'bg-red-700 dark:bg-red-600 hover:bg-red-800 dark:hover:bg-red-800 transition-color duration-200 text-white', + 'bg-red-700 hover:bg-red-800 dark:bg-red-600 dark:hover:bg-red-800 text-white', selectText: localize('com_ui_delete'), }} /> diff --git a/client/src/components/SidePanel/MCP/MCPPanel.tsx b/client/src/components/SidePanel/MCP/MCPPanel.tsx index adcb81e0e..5bb6d18b8 100644 --- a/client/src/components/SidePanel/MCP/MCPPanel.tsx +++ b/client/src/components/SidePanel/MCP/MCPPanel.tsx @@ -56,7 +56,7 @@ function MCPPanelContent() { showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); await Promise.all([ - queryClient.refetchQueries([QueryKeys.tools]), + queryClient.refetchQueries([QueryKeys.mcpTools]), queryClient.refetchQueries([QueryKeys.mcpAuthValues]), queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]), ]); diff --git a/client/src/components/Tools/MCPToolSelectDialog.tsx b/client/src/components/Tools/MCPToolSelectDialog.tsx index 268cd3864..62710d032 100644 --- a/client/src/components/Tools/MCPToolSelectDialog.tsx +++ b/client/src/components/Tools/MCPToolSelectDialog.tsx @@ -7,8 +7,8 @@ import { useUpdateUserPluginsMutation } from 'librechat-data-provider/react-quer import type { TError, AgentToolType } from 'librechat-data-provider'; import type { AgentForm, TPluginStoreDialogProps } from '~/common'; import { useLocalize, usePluginDialogHelpers, useMCPServerManager } from '~/hooks'; -import { useGetStartupConfig, useAvailableToolsQuery } from '~/data-provider'; import CustomUserVarsSection from '~/components/MCP/CustomUserVarsSection'; +import { useGetStartupConfig, useMCPToolsQuery } from '~/data-provider'; import { PluginPagination } from '~/components/Plugins/Store'; import { useAgentPanelContext } from '~/Providers'; import MCPToolItem from './MCPToolItem'; @@ -27,8 +27,8 @@ function MCPToolSelectDialog({ const { mcpServersMap } = useAgentPanelContext(); const { initializeServer } = useMCPServerManager(); const { data: startupConfig } = useGetStartupConfig(); + const { refetch: refetchMCPTools } = useMCPToolsQuery(); const { getValues, setValue } = useFormContext(); - const { refetch: refetchAvailableTools } = useAvailableToolsQuery(EModelEndpoint.agents); const [isInitializing, setIsInitializing] = useState(null); const [configuringServer, setConfiguringServer] = useState(null); @@ -90,15 +90,15 @@ function MCPToolSelectDialog({ setIsInitializing(null); }, onSuccess: async () => { - const { data: updatedAvailableTools } = await refetchAvailableTools(); + const { data: updatedMCPTools } = await refetchMCPTools(); const currentTools = getValues('tools') || []; const toolsToAdd: string[] = [ `${Constants.mcp_server}${Constants.mcp_delimiter}${serverName}`, ]; - if (updatedAvailableTools) { - updatedAvailableTools.forEach((tool) => { + if (updatedMCPTools) { + updatedMCPTools.forEach((tool) => { if (tool.pluginKey.endsWith(`${Constants.mcp_delimiter}${serverName}`)) { toolsToAdd.push(tool.pluginKey); } diff --git a/client/src/components/Tools/ToolSelectDialog.tsx b/client/src/components/Tools/ToolSelectDialog.tsx index cdd70b907..14bab5060 100644 --- a/client/src/components/Tools/ToolSelectDialog.tsx +++ b/client/src/components/Tools/ToolSelectDialog.tsx @@ -8,7 +8,7 @@ import type { AssistantsEndpoint, EModelEndpoint, TPluginAction, - AgentToolType, + TPlugin, TError, } from 'librechat-data-provider'; import type { AgentForm, TPluginStoreDialogProps } from '~/common'; @@ -27,7 +27,8 @@ function ToolSelectDialog({ const localize = useLocalize(); const isAgentTools = isAgentsEndpoint(endpoint); const { getValues, setValue } = useFormContext(); - const { groupedTools, pluginTools } = useAgentPanelContext(); + // Only use regular tools, not MCP tools + const { regularTools } = useAgentPanelContext(); const { maxPage, @@ -68,19 +69,8 @@ function ToolSelectDialog({ const handleInstall = (pluginAction: TPluginAction) => { const addFunction = () => { const installedToolIds: string[] = getValues('tools') || []; - // Add the parent installedToolIds.push(pluginAction.pluginKey); - - // If this tool is a group, add subtools too - const groupObj = groupedTools?.[pluginAction.pluginKey]; - if (groupObj?.tools && groupObj.tools.length > 0) { - for (const sub of groupObj.tools) { - if (!installedToolIds.includes(sub.tool_id)) { - installedToolIds.push(sub.tool_id); - } - } - } - setValue('tools', Array.from(new Set(installedToolIds))); // no duplicates just in case + setValue('tools', Array.from(new Set(installedToolIds))); }; if (!pluginAction.auth) { @@ -98,19 +88,12 @@ function ToolSelectDialog({ }; const onRemoveTool = (toolId: string) => { - const groupObj = groupedTools?.[toolId]; - const toolIdsToRemove = [toolId]; - if (groupObj?.tools && groupObj.tools.length > 0) { - toolIdsToRemove.push(...groupObj.tools.map((sub) => sub.tool_id)); - } - // Remove these from the formTools updateUserPlugins.mutate( { pluginKey: toolId, action: 'uninstall', auth: {}, isEntityTool: true }, { onError: (error: unknown) => handleInstallError(error as TError), onSuccess: () => { - const remainingToolIds = - getValues('tools')?.filter((toolId) => !toolIdsToRemove.includes(toolId)) || []; + const remainingToolIds = getValues('tools')?.filter((id) => id !== toolId) || []; setValue('tools', remainingToolIds); }, }, @@ -119,7 +102,8 @@ function ToolSelectDialog({ const onAddTool = (pluginKey: string) => { setShowPluginAuthForm(false); - const availablePluginFromKey = pluginTools?.find((p) => p.pluginKey === pluginKey); + // Find the tool in regularTools + const availablePluginFromKey = regularTools?.find((p) => p.pluginKey === pluginKey); setSelectedPlugin(availablePluginFromKey); const { authConfig, authenticated = false } = availablePluginFromKey ?? {}; @@ -134,30 +118,19 @@ function ToolSelectDialog({ } }; - const filteredTools = Object.values(groupedTools || {}).filter( - (currentTool: AgentToolType & { tools?: AgentToolType[] }) => { - if (currentTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase())) { - return true; - } - if (currentTool.tools) { - return currentTool.tools.some((childTool) => - childTool.metadata?.name?.toLowerCase().includes(searchValue.toLowerCase()), - ); - } - return false; - }, - ); + const filteredTools = (regularTools || []).filter((tool: TPlugin) => { + return tool.name?.toLowerCase().includes(searchValue.toLowerCase()); + }); useEffect(() => { if (filteredTools) { - setMaxPage(Math.ceil(Object.keys(filteredTools || {}).length / itemsPerPage)); + setMaxPage(Math.ceil(filteredTools.length / itemsPerPage)); if (searchChanged) { setCurrentPage(1); setSearchChanged(false); } } }, [ - pluginTools, searchValue, itemsPerPage, filteredTools, @@ -254,10 +227,13 @@ function ToolSelectDialog({ .map((tool, index) => ( onAddTool(tool.tool_id)} - onRemoveTool={() => onRemoveTool(tool.tool_id)} + tool={{ + tool_id: tool.pluginKey, + metadata: tool, + }} + isInstalled={getValues('tools')?.includes(tool.pluginKey) || false} + onAddTool={() => onAddTool(tool.pluginKey)} + onRemoveTool={() => onRemoveTool(tool.pluginKey)} /> ))}
diff --git a/client/src/data-provider/index.ts b/client/src/data-provider/index.ts index 1b1ceed19..1e2582cb8 100644 --- a/client/src/data-provider/index.ts +++ b/client/src/data-provider/index.ts @@ -11,5 +11,6 @@ export * from './connection'; export * from './mutations'; export * from './prompts'; export * from './queries'; +export * from './mcp'; export * from './roles'; export * from './tags'; diff --git a/client/src/data-provider/mcp.ts b/client/src/data-provider/mcp.ts new file mode 100644 index 000000000..cc144f78b --- /dev/null +++ b/client/src/data-provider/mcp.ts @@ -0,0 +1,29 @@ +/** + * 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 { TPlugin } from 'librechat-data-provider'; + +/** + * Hook for fetching MCP-specific tools + * @param config - React Query configuration + * @returns MCP tools grouped by server + */ +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/queries.ts b/client/src/data-provider/queries.ts index 447775067..8f2b702a3 100644 --- a/client/src/data-provider/queries.ts +++ b/client/src/data-provider/queries.ts @@ -178,7 +178,8 @@ export const useConversationTagsQuery = ( */ /** - * Hook for getting all available tools for Assistants + * Hook for getting available LibreChat tools (excludes MCP tools) + * For MCP tools, use `useMCPToolsQuery` from mcp-queries.ts */ export const useAvailableToolsQuery = ( endpoint: t.AssistantsEndpoint | EModelEndpoint.agents, diff --git a/client/src/hooks/MCP/useGetMCPTools.ts b/client/src/hooks/MCP/useGetMCPTools.ts index adffb963e..2785675b9 100644 --- a/client/src/hooks/MCP/useGetMCPTools.ts +++ b/client/src/hooks/MCP/useGetMCPTools.ts @@ -1,32 +1,37 @@ import { useMemo } from 'react'; -import { Constants, EModelEndpoint } from 'librechat-data-provider'; +import { Constants } from 'librechat-data-provider'; import type { TPlugin } from 'librechat-data-provider'; -import { useAvailableToolsQuery, useGetStartupConfig } from '~/data-provider'; +import { useMCPToolsQuery, useGetStartupConfig } from '~/data-provider'; +/** + * Hook for fetching and filtering MCP tools based on server configuration + * Uses the dedicated MCP tools query instead of filtering from general tools + */ export function useGetMCPTools() { const { data: startupConfig } = useGetStartupConfig(); - const { data: rawMcpTools } = useAvailableToolsQuery(EModelEndpoint.agents, { + + // Use dedicated MCP tools query + const { data: rawMcpTools } = useMCPToolsQuery({ select: (data: TPlugin[]) => { + // Group tools by server for easier management const mcpToolsMap = new Map(); data.forEach((tool) => { - const isMCP = tool.pluginKey.includes(Constants.mcp_delimiter); - if (isMCP) { - const parts = tool.pluginKey.split(Constants.mcp_delimiter); - const serverName = parts[parts.length - 1]; - if (!mcpToolsMap.has(serverName)) { - mcpToolsMap.set(serverName, { - name: serverName, - pluginKey: tool.pluginKey, - authConfig: tool.authConfig, - authenticated: tool.authenticated, - }); - } + const parts = tool.pluginKey.split(Constants.mcp_delimiter); + const serverName = parts[parts.length - 1]; + if (!mcpToolsMap.has(serverName)) { + mcpToolsMap.set(serverName, { + name: serverName, + pluginKey: tool.pluginKey, + authConfig: tool.authConfig, + authenticated: tool.authenticated, + }); } }); return Array.from(mcpToolsMap.values()); }, }); + // Filter out servers that have chatMenu disabled const mcpToolDetails = useMemo(() => { if (!rawMcpTools || !startupConfig?.mcpServers) { return rawMcpTools; diff --git a/client/src/hooks/MCP/useMCPServerManager.ts b/client/src/hooks/MCP/useMCPServerManager.ts index 733168c1a..c30193919 100644 --- a/client/src/hooks/MCP/useMCPServerManager.ts +++ b/client/src/hooks/MCP/useMCPServerManager.ts @@ -53,7 +53,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin showToast({ message: localize('com_nav_mcp_vars_updated'), status: 'success' }); await Promise.all([ - queryClient.refetchQueries([QueryKeys.tools]), + queryClient.refetchQueries([QueryKeys.mcpTools]), queryClient.refetchQueries([QueryKeys.mcpAuthValues]), queryClient.refetchQueries([QueryKeys.mcpConnectionStatus]), ]); @@ -170,7 +170,7 @@ export function useMCPServerManager({ conversationId }: { conversationId?: strin setMCPValues([...currentValues, serverName]); } - await queryClient.invalidateQueries([QueryKeys.tools]); + await queryClient.invalidateQueries([QueryKeys.mcpTools]); // This delay is to ensure UI has updated with new connection status before cleanup // Otherwise servers will show as disconnected for a second after OAuth flow completes diff --git a/client/src/hooks/MCP/useVisibleTools.ts b/client/src/hooks/MCP/useVisibleTools.ts index acb48bf11..1e48d0891 100644 --- a/client/src/hooks/MCP/useVisibleTools.ts +++ b/client/src/hooks/MCP/useVisibleTools.ts @@ -1,79 +1,52 @@ import { useMemo } from 'react'; import { Constants } from 'librechat-data-provider'; -import type { AgentToolType } from 'librechat-data-provider'; +import type { TPlugin } from 'librechat-data-provider'; import type { MCPServerInfo } from '~/common'; -type GroupedToolType = AgentToolType & { tools?: AgentToolType[] }; -type GroupedToolsRecord = Record; - interface VisibleToolsResult { toolIds: string[]; mcpServerNames: string[]; } /** - * Custom hook to calculate visible tool IDs based on selected tools and their parent groups. - * If any subtool of a group is selected, the parent group tool is also made visible. + * Custom hook to calculate visible tool IDs based on selected tools. + * Separates regular LibreChat tools from MCP servers. * * @param selectedToolIds - Array of selected tool IDs - * @param allTools - Record of all available tools + * @param regularTools - Array of regular LibreChat tools * @param mcpServersMap - Map of all MCP servers * @returns Object containing separate arrays of visible tool IDs for regular and MCP tools */ export function useVisibleTools( selectedToolIds: string[] | undefined, - allTools: GroupedToolsRecord | undefined, + regularTools: TPlugin[] | undefined, mcpServersMap: Map, ): VisibleToolsResult { return useMemo(() => { const mcpServers = new Set(); - const selectedSet = new Set(); - const regularToolIds = new Set(); + const regularToolIds: string[] = []; for (const toolId of selectedToolIds ?? []) { - if (!toolId.includes(Constants.mcp_delimiter)) { - selectedSet.add(toolId); - continue; - } - const serverName = toolId.split(Constants.mcp_delimiter)[1]; - if (!serverName) { - continue; - } - mcpServers.add(serverName); - } - - if (allTools) { - for (const [toolId, toolObj] of Object.entries(allTools)) { - if (selectedSet.has(toolId)) { - regularToolIds.add(toolId); - } - - if (toolObj.tools?.length) { - for (const subtool of toolObj.tools) { - if (selectedSet.has(subtool.tool_id)) { - regularToolIds.add(toolId); - break; - } - } + // MCP tools/servers + if (toolId.includes(Constants.mcp_delimiter)) { + const serverName = toolId.split(Constants.mcp_delimiter)[1]; + if (serverName) { + mcpServers.add(serverName); } } - } - - if (mcpServersMap) { - for (const [mcpServerName] of mcpServersMap) { - if (mcpServers.has(mcpServerName)) { - continue; - } - /** Legacy check */ - if (selectedSet.has(mcpServerName)) { - mcpServers.add(mcpServerName); - } + // Legacy MCP server check (just server name) + else if (mcpServersMap.has(toolId)) { + mcpServers.add(toolId); + } + // Regular LibreChat tools + else if (regularTools?.some((t) => t.pluginKey === toolId)) { + regularToolIds.push(toolId); } } return { - toolIds: Array.from(regularToolIds).sort((a, b) => a.localeCompare(b)), + toolIds: regularToolIds.sort((a, b) => a.localeCompare(b)), mcpServerNames: Array.from(mcpServers).sort((a, b) => a.localeCompare(b)), }; - }, [allTools, mcpServersMap, selectedToolIds]); + }, [regularTools, mcpServersMap, selectedToolIds]); } diff --git a/packages/data-provider/src/api-endpoints.ts b/packages/data-provider/src/api-endpoints.ts index be3f2a62a..8c1447abe 100644 --- a/packages/data-provider/src/api-endpoints.ts +++ b/packages/data-provider/src/api-endpoints.ts @@ -222,6 +222,10 @@ export const agents = ({ path = '', options }: { path?: string; options?: object return url; }; +export const mcp = { + tools: `${BASE_URL}/api/mcp/tools`, +}; + export const revertAgentVersion = (agent_id: string) => `${agents({ path: `${agent_id}/revert` })}`; export const files = () => `${BASE_URL}/api/files`; diff --git a/packages/data-provider/src/data-service.ts b/packages/data-provider/src/data-service.ts index 7116f0d7c..9926e18be 100644 --- a/packages/data-provider/src/data-service.ts +++ b/packages/data-provider/src/data-service.ts @@ -297,6 +297,12 @@ export const getAvailableTools = ( return request.get(path); }; +/* MCP Tools - Decoupled from regular tools */ + +export const getMCPTools = (): Promise => { + return request.get(endpoints.mcp.tools); +}; + export const getVerifyAgentToolAuth = ( params: q.VerifyToolAuthParams, ): Promise => { diff --git a/packages/data-provider/src/keys.ts b/packages/data-provider/src/keys.ts index 79d1fa38d..1d6e1d813 100644 --- a/packages/data-provider/src/keys.ts +++ b/packages/data-provider/src/keys.ts @@ -26,6 +26,7 @@ export enum QueryKeys { tools = 'tools', toolAuth = 'toolAuth', toolCalls = 'toolCalls', + mcpTools = 'mcpTools', mcpConnectionStatus = 'mcpConnectionStatus', mcpAuthValues = 'mcpAuthValues', agentTools = 'agentTools', diff --git a/packages/data-provider/src/react-query/react-query-service.ts b/packages/data-provider/src/react-query/react-query-service.ts index ab150a133..db1f0c905 100644 --- a/packages/data-provider/src/react-query/react-query-service.ts +++ b/packages/data-provider/src/react-query/react-query-service.ts @@ -147,6 +147,7 @@ export const useRevokeUserKeyMutation = (name: string): UseMutationResult => { queryClient.invalidateQueries([QueryKeys.assistantDocs]); queryClient.invalidateQueries([QueryKeys.assistants]); queryClient.invalidateQueries([QueryKeys.assistant]); + queryClient.invalidateQueries([QueryKeys.mcpTools]); queryClient.invalidateQueries([QueryKeys.actions]); queryClient.invalidateQueries([QueryKeys.tools]); }, @@ -337,7 +339,7 @@ export const useReinitializeMCPServerMutation = (): UseMutationResult< const queryClient = useQueryClient(); return useMutation((serverName: string) => dataService.reinitializeMCPServer(serverName), { onSuccess: () => { - queryClient.refetchQueries([QueryKeys.tools]); + queryClient.refetchQueries([QueryKeys.mcpTools]); }, }); };