From 770c766d50e36ca425625c0e88023a0c95fec068 Mon Sep 17 00:00:00 2001 From: Danny Avila Date: Sat, 9 Aug 2025 12:02:44 -0400 Subject: [PATCH] =?UTF-8?q?=F0=9F=94=A7=20refactor:=20Move=20Plugin-relate?= =?UTF-8?q?d=20Helpers=20to=20TS=20API=20and=20Add=20Tests=20(#8961)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api/server/controllers/PluginController.js | 114 +---- .../controllers/PluginController.spec.js | 453 ++++++++++++++++- api/server/services/ToolService.js | 28 +- package-lock.json | 2 +- packages/api/package.json | 2 +- packages/api/src/index.ts | 2 + packages/api/src/tools/format.spec.ts | 461 ++++++++++++++++++ packages/api/src/tools/format.ts | 142 ++++++ packages/api/src/tools/index.ts | 1 + 9 files changed, 1065 insertions(+), 140 deletions(-) create mode 100644 packages/api/src/tools/format.spec.ts create mode 100644 packages/api/src/tools/format.ts create mode 100644 packages/api/src/tools/index.ts diff --git a/api/server/controllers/PluginController.js b/api/server/controllers/PluginController.js index dfabf37f67..9be4b53e36 100644 --- a/api/server/controllers/PluginController.js +++ b/api/server/controllers/PluginController.js @@ -1,54 +1,16 @@ const { logger } = require('@librechat/data-schemas'); -const { CacheKeys, AuthType, Constants } = require('librechat-data-provider'); +const { CacheKeys, Constants } = require('librechat-data-provider'); +const { + getToolkitKey, + checkPluginAuth, + filterUniquePlugins, + convertMCPToolsToPlugins, +} = require('@librechat/api'); const { getCustomConfig, getCachedTools } = require('~/server/services/Config'); -const { getToolkitKey } = require('~/server/services/ToolService'); +const { availableTools, toolkits } = require('~/app/clients/tools'); const { getMCPManager, getFlowStateManager } = require('~/config'); -const { availableTools } = require('~/app/clients/tools'); const { getLogStores } = require('~/cache'); -/** - * Filters out duplicate plugins from the list of plugins. - * - * @param {TPlugin[]} plugins The list of plugins to filter. - * @returns {TPlugin[]} The list of plugins with duplicates removed. - */ -const filterUniquePlugins = (plugins) => { - const seen = new Set(); - return plugins.filter((plugin) => { - const duplicate = seen.has(plugin.pluginKey); - seen.add(plugin.pluginKey); - return !duplicate; - }); -}; - -/** - * Determines if a plugin is authenticated by checking if all required authentication fields have non-empty values. - * Supports alternate authentication fields, allowing validation against multiple possible environment variables. - * - * @param {TPlugin} plugin The plugin object containing the authentication configuration. - * @returns {boolean} True if the plugin is authenticated for all required fields, false otherwise. - */ -const checkPluginAuth = (plugin) => { - if (!plugin.authConfig || plugin.authConfig.length === 0) { - return false; - } - - return plugin.authConfig.every((authFieldObj) => { - const authFieldOptions = authFieldObj.authField.split('||'); - let isFieldAuthenticated = false; - - for (const fieldOption of authFieldOptions) { - const envValue = process.env[fieldOption]; - if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) { - isFieldAuthenticated = true; - break; - } - } - - return isFieldAuthenticated; - }); -}; - const getAvailablePluginsController = async (req, res) => { try { const cache = getLogStores(CacheKeys.CONFIG_STORE); @@ -143,9 +105,9 @@ const getAvailableTools = async (req, res) => { const cache = getLogStores(CacheKeys.CONFIG_STORE); const cachedToolsArray = await cache.get(CacheKeys.TOOLS); const cachedUserTools = await getCachedTools({ userId }); - const userPlugins = convertMCPToolsToPlugins(cachedUserTools, customConfig); + const userPlugins = convertMCPToolsToPlugins({ functionTools: cachedUserTools, customConfig }); - if (cachedToolsArray && userPlugins) { + if (cachedToolsArray != null && userPlugins != null) { const dedupedTools = filterUniquePlugins([...userPlugins, ...cachedToolsArray]); res.status(200).json(dedupedTools); return; @@ -185,7 +147,9 @@ const getAvailableTools = async (req, res) => { const isToolDefined = toolDefinitions[plugin.pluginKey] !== undefined; const isToolkit = plugin.toolkit === true && - Object.keys(toolDefinitions).some((key) => getToolkitKey(key) === plugin.pluginKey); + Object.keys(toolDefinitions).some( + (key) => getToolkitKey({ toolkits, toolName: key }) === plugin.pluginKey, + ); if (!isToolDefined && !isToolkit) { continue; @@ -235,58 +199,6 @@ const getAvailableTools = async (req, res) => { } }; -/** - * Converts MCP function format tools to plugin format - * @param {Object} functionTools - Object with function format tools - * @param {Object} customConfig - Custom configuration for MCP servers - * @returns {Array} Array of plugin objects - */ -function convertMCPToolsToPlugins(functionTools, customConfig) { - const plugins = []; - - for (const [toolKey, toolData] of Object.entries(functionTools)) { - if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) { - continue; - } - - const functionData = toolData.function; - const parts = toolKey.split(Constants.mcp_delimiter); - const serverName = parts[parts.length - 1]; - - const serverConfig = customConfig?.mcpServers?.[serverName]; - - const plugin = { - name: parts[0], // Use the tool name without server suffix - pluginKey: toolKey, - description: functionData.description || '', - authenticated: true, - icon: serverConfig?.iconPath, - }; - - // Build authConfig for MCP tools - if (!serverConfig?.customUserVars) { - plugin.authConfig = []; - plugins.push(plugin); - continue; - } - - const customVarKeys = Object.keys(serverConfig.customUserVars); - if (customVarKeys.length === 0) { - plugin.authConfig = []; - } else { - plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({ - authField: key, - label: value.title || key, - description: value.description || '', - })); - } - - plugins.push(plugin); - } - - return plugins; -} - module.exports = { getAvailableTools, getAvailablePluginsController, diff --git a/api/server/controllers/PluginController.spec.js b/api/server/controllers/PluginController.spec.js index 218d3255f9..89dfd72e4b 100644 --- a/api/server/controllers/PluginController.spec.js +++ b/api/server/controllers/PluginController.spec.js @@ -28,19 +28,211 @@ jest.mock('~/config', () => ({ jest.mock('~/app/clients/tools', () => ({ availableTools: [], + toolkits: [], })); jest.mock('~/cache', () => ({ getLogStores: jest.fn(), })); +jest.mock('@librechat/api', () => ({ + getToolkitKey: jest.fn(), + checkPluginAuth: jest.fn(), + filterUniquePlugins: jest.fn(), + convertMCPToolsToPlugins: jest.fn(), +})); + // Import the actual module with the function we want to test -const { getAvailableTools } = require('./PluginController'); +const { getAvailableTools, getAvailablePluginsController } = require('./PluginController'); +const { + filterUniquePlugins, + checkPluginAuth, + convertMCPToolsToPlugins, + getToolkitKey, +} = require('@librechat/api'); describe('PluginController', () => { - describe('plugin.icon behavior', () => { - let mockReq, mockRes, mockCache; + let mockReq, mockRes, mockCache; + beforeEach(() => { + jest.clearAllMocks(); + mockReq = { user: { id: 'test-user-id' } }; + mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; + mockCache = { get: jest.fn(), set: jest.fn() }; + getLogStores.mockReturnValue(mockCache); + }); + + describe('getAvailablePluginsController', () => { + beforeEach(() => { + mockReq.app = { locals: { filteredTools: [], includedTools: [] } }; + }); + + it('should use filterUniquePlugins to remove duplicate plugins', async () => { + const mockPlugins = [ + { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, + { name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, + ]; + + mockCache.get.mockResolvedValue(null); + filterUniquePlugins.mockReturnValue(mockPlugins); + checkPluginAuth.mockReturnValue(true); + + await getAvailablePluginsController(mockReq, mockRes); + + expect(filterUniquePlugins).toHaveBeenCalled(); + expect(mockRes.status).toHaveBeenCalledWith(200); + // The response includes authenticated: true for each plugin when checkPluginAuth returns true + expect(mockRes.json).toHaveBeenCalledWith([ + { name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true }, + { name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true }, + ]); + }); + + it('should use checkPluginAuth to verify plugin authentication', async () => { + const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' }; + + mockCache.get.mockResolvedValue(null); + filterUniquePlugins.mockReturnValue([mockPlugin]); + checkPluginAuth.mockReturnValueOnce(true); + + await getAvailablePluginsController(mockReq, mockRes); + + expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin); + const responseData = mockRes.json.mock.calls[0][0]; + expect(responseData[0].authenticated).toBe(true); + }); + + it('should return cached plugins when available', async () => { + const cachedPlugins = [ + { name: 'CachedPlugin', pluginKey: 'cached', description: 'Cached plugin' }, + ]; + + mockCache.get.mockResolvedValue(cachedPlugins); + + await getAvailablePluginsController(mockReq, mockRes); + + expect(filterUniquePlugins).not.toHaveBeenCalled(); + expect(checkPluginAuth).not.toHaveBeenCalled(); + expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins); + }); + + it('should filter plugins based on includedTools', async () => { + const mockPlugins = [ + { name: 'Plugin1', pluginKey: 'key1', description: 'First' }, + { name: 'Plugin2', pluginKey: 'key2', description: 'Second' }, + ]; + + mockReq.app.locals.includedTools = ['key1']; + mockCache.get.mockResolvedValue(null); + filterUniquePlugins.mockReturnValue(mockPlugins); + checkPluginAuth.mockReturnValue(false); + + await getAvailablePluginsController(mockReq, mockRes); + + const responseData = mockRes.json.mock.calls[0][0]; + expect(responseData).toHaveLength(1); + expect(responseData[0].pluginKey).toBe('key1'); + }); + }); + + describe('getAvailableTools', () => { + it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => { + const mockUserTools = { + [`tool1${Constants.mcp_delimiter}server1`]: { + function: { name: 'tool1', description: 'Tool 1' }, + }, + }; + const mockConvertedPlugins = [ + { + name: 'tool1', + pluginKey: `tool1${Constants.mcp_delimiter}server1`, + description: 'Tool 1', + }, + ]; + + mockCache.get.mockResolvedValue(null); + getCachedTools.mockResolvedValueOnce(mockUserTools); + convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins); + filterUniquePlugins.mockImplementation((plugins) => plugins); + getCustomConfig.mockResolvedValue(null); + + await getAvailableTools(mockReq, mockRes); + + expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({ + functionTools: mockUserTools, + customConfig: null, + }); + }); + + it('should use filterUniquePlugins to deduplicate combined tools', async () => { + const mockUserPlugins = [ + { name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }, + ]; + const mockManifestPlugins = [ + { name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' }, + ]; + + mockCache.get.mockResolvedValue(mockManifestPlugins); + getCachedTools.mockResolvedValueOnce({}); + convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins); + filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]); + getCustomConfig.mockResolvedValue(null); + + await getAvailableTools(mockReq, mockRes); + + // Should be called to deduplicate the combined array + expect(filterUniquePlugins).toHaveBeenLastCalledWith([ + ...mockUserPlugins, + ...mockManifestPlugins, + ]); + }); + + it('should use checkPluginAuth to verify authentication status', async () => { + const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' }; + + mockCache.get.mockResolvedValue(null); + getCachedTools.mockResolvedValue({}); + convertMCPToolsToPlugins.mockReturnValue([]); + filterUniquePlugins.mockReturnValue([mockPlugin]); + checkPluginAuth.mockReturnValue(true); + getCustomConfig.mockResolvedValue(null); + + // Mock getCachedTools second call to return tool definitions + getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true }); + + await getAvailableTools(mockReq, mockRes); + + expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin); + }); + + it('should use getToolkitKey for toolkit validation', async () => { + const mockToolkit = { + name: 'Toolkit1', + pluginKey: 'toolkit1', + description: 'Toolkit 1', + toolkit: true, + }; + + mockCache.get.mockResolvedValue(null); + getCachedTools.mockResolvedValue({}); + convertMCPToolsToPlugins.mockReturnValue([]); + filterUniquePlugins.mockReturnValue([mockToolkit]); + checkPluginAuth.mockReturnValue(false); + getToolkitKey.mockReturnValue('toolkit1'); + getCustomConfig.mockResolvedValue(null); + + // Mock getCachedTools second call to return tool definitions + getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ + toolkit1_function: true, + }); + + await getAvailableTools(mockReq, mockRes); + + expect(getToolkitKey).toHaveBeenCalled(); + }); + }); + + describe('plugin.icon behavior', () => { const callGetAvailableToolsWithMCPServer = async (mcpServers) => { mockCache.get.mockResolvedValue(null); getCustomConfig.mockResolvedValue({ mcpServers }); @@ -50,7 +242,22 @@ describe('PluginController', () => { function: { name: 'test-tool', description: 'A test tool' }, }, }; + + const mockConvertedPlugin = { + name: 'test-tool', + pluginKey: `test-tool${Constants.mcp_delimiter}test-server`, + description: 'A test tool', + icon: mcpServers['test-server']?.iconPath, + authenticated: true, + authConfig: [], + }; + getCachedTools.mockResolvedValueOnce(functionTools); + convertMCPToolsToPlugins.mockReturnValue([mockConvertedPlugin]); + filterUniquePlugins.mockImplementation((plugins) => plugins); + checkPluginAuth.mockReturnValue(true); + getToolkitKey.mockReturnValue(undefined); + getCachedTools.mockResolvedValueOnce({ [`test-tool${Constants.mcp_delimiter}test-server`]: true, }); @@ -60,14 +267,6 @@ describe('PluginController', () => { return responseData.find((tool) => tool.name === 'test-tool'); }; - beforeEach(() => { - jest.clearAllMocks(); - mockReq = { user: { id: 'test-user-id' } }; - mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() }; - mockCache = { get: jest.fn(), set: jest.fn() }; - getLogStores.mockReturnValue(mockCache); - }); - it('should set plugin.icon when iconPath is defined', async () => { const mcpServers = { 'test-server': { @@ -86,4 +285,236 @@ describe('PluginController', () => { expect(testTool.icon).toBeUndefined(); }); }); + + describe('helper function integration', () => { + it('should properly handle MCP tools with custom user variables', async () => { + const customConfig = { + mcpServers: { + 'test-server': { + customUserVars: { + API_KEY: { title: 'API Key', description: 'Your API key' }, + }, + }, + }, + }; + + // We need to test the actual flow where MCP manager tools are included + const mcpManagerTools = [ + { + name: 'tool1', + pluginKey: `tool1${Constants.mcp_delimiter}test-server`, + description: 'Tool 1', + authenticated: true, + }, + ]; + + // Mock the MCP manager to return tools + const mockMCPManager = { + loadManifestTools: jest.fn().mockResolvedValue(mcpManagerTools), + }; + require('~/config').getMCPManager.mockReturnValue(mockMCPManager); + + mockCache.get.mockResolvedValue(null); + getCustomConfig.mockResolvedValue(customConfig); + + // First call returns user tools (empty in this case) + getCachedTools.mockResolvedValueOnce({}); + + // Mock convertMCPToolsToPlugins to return empty array for user tools + convertMCPToolsToPlugins.mockReturnValue([]); + + // Mock filterUniquePlugins to pass through + filterUniquePlugins.mockImplementation((plugins) => plugins || []); + + // Mock checkPluginAuth + checkPluginAuth.mockReturnValue(true); + + // Second call returns tool definitions + getCachedTools.mockResolvedValueOnce({ + [`tool1${Constants.mcp_delimiter}test-server`]: true, + }); + + await getAvailableTools(mockReq, mockRes); + + const responseData = mockRes.json.mock.calls[0][0]; + + // 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')); + + await getAvailableTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + expect(mockRes.json).toHaveBeenCalledWith({ message: 'Cache error' }); + }); + }); + + describe('edge cases with undefined/null values', () => { + it('should handle undefined cache gracefully', async () => { + getLogStores.mockReturnValue(undefined); + + await getAvailableTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(500); + }); + + it('should handle null cachedTools and cachedUserTools', async () => { + mockCache.get.mockResolvedValue(null); + getCachedTools.mockResolvedValue(null); + convertMCPToolsToPlugins.mockReturnValue(undefined); + filterUniquePlugins.mockImplementation((plugins) => plugins || []); + getCustomConfig.mockResolvedValue(null); + + await getAvailableTools(mockReq, mockRes); + + expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({ + functionTools: null, + customConfig: null, + }); + }); + + it('should handle when getCachedTools returns undefined', async () => { + mockCache.get.mockResolvedValue(null); + getCachedTools.mockResolvedValue(undefined); + convertMCPToolsToPlugins.mockReturnValue(undefined); + filterUniquePlugins.mockImplementation((plugins) => plugins || []); + getCustomConfig.mockResolvedValue(null); + checkPluginAuth.mockReturnValue(false); + + // Mock getCachedTools to return undefined for both calls + getCachedTools.mockReset(); + getCachedTools.mockResolvedValueOnce(undefined).mockResolvedValueOnce(undefined); + + await getAvailableTools(mockReq, mockRes); + + expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({ + functionTools: undefined, + customConfig: null, + }); + }); + + it('should handle cachedToolsArray and userPlugins both being defined', async () => { + const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }]; + const userTools = { + 'user-tool': { function: { name: 'user-tool', description: 'User tool' } }, + }; + const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }]; + + mockCache.get.mockResolvedValue(cachedTools); + getCachedTools.mockResolvedValue(userTools); + convertMCPToolsToPlugins.mockReturnValue(userPlugins); + filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]); + + await getAvailableTools(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]); + }); + + it('should handle empty toolDefinitions object', async () => { + mockCache.get.mockResolvedValue(null); + getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({}); + convertMCPToolsToPlugins.mockReturnValue([]); + filterUniquePlugins.mockImplementation((plugins) => plugins || []); + getCustomConfig.mockResolvedValue(null); + checkPluginAuth.mockReturnValue(true); + + 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 customConfig = { + mcpServers: { + 'test-server': { + // No customUserVars defined + }, + }, + }; + + const mockUserTools = { + [`tool1${Constants.mcp_delimiter}test-server`]: { + function: { name: 'tool1', description: 'Tool 1' }, + }, + }; + + mockCache.get.mockResolvedValue(null); + getCustomConfig.mockResolvedValue(customConfig); + getCachedTools.mockResolvedValueOnce(mockUserTools); + + const mockPlugin = { + name: 'tool1', + pluginKey: `tool1${Constants.mcp_delimiter}test-server`, + description: 'Tool 1', + authenticated: true, + authConfig: [], + }; + + convertMCPToolsToPlugins.mockReturnValue([mockPlugin]); + filterUniquePlugins.mockImplementation((plugins) => plugins); + checkPluginAuth.mockReturnValue(true); + + getCachedTools.mockResolvedValueOnce({ + [`tool1${Constants.mcp_delimiter}test-server`]: true, + }); + + await getAvailableTools(mockReq, mockRes); + + const responseData = mockRes.json.mock.calls[0][0]; + expect(responseData[0].authenticated).toBe(true); + // The actual implementation doesn't set authConfig on tools without customUserVars + expect(responseData[0].authConfig).toEqual([]); + }); + + it('should handle req.app.locals with undefined filteredTools and includedTools', async () => { + mockReq.app = { locals: {} }; + mockCache.get.mockResolvedValue(null); + filterUniquePlugins.mockReturnValue([]); + checkPluginAuth.mockReturnValue(false); + + await getAvailablePluginsController(mockReq, mockRes); + + expect(mockRes.status).toHaveBeenCalledWith(200); + expect(mockRes.json).toHaveBeenCalledWith([]); + }); + + it('should handle toolkit with undefined toolDefinitions keys', async () => { + const mockToolkit = { + name: 'Toolkit1', + pluginKey: 'toolkit1', + description: 'Toolkit 1', + toolkit: true, + }; + + mockCache.get.mockResolvedValue(null); + getCachedTools.mockResolvedValue({}); + convertMCPToolsToPlugins.mockReturnValue([]); + filterUniquePlugins.mockReturnValue([mockToolkit]); + checkPluginAuth.mockReturnValue(false); + getToolkitKey.mockReturnValue(undefined); + getCustomConfig.mockResolvedValue(null); + + // Mock getCachedTools second call to return null + getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce(null); + + await getAvailableTools(mockReq, mockRes); + + // Should handle null toolDefinitions gracefully + expect(mockRes.status).toHaveBeenCalledWith(200); + }); + }); }); diff --git a/api/server/services/ToolService.js b/api/server/services/ToolService.js index e37202a888..86205c1e73 100644 --- a/api/server/services/ToolService.js +++ b/api/server/services/ToolService.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); const { sleep } = require('@librechat/agents'); +const { getToolkitKey } = require('@librechat/api'); const { logger } = require('@librechat/data-schemas'); const { zodToJsonSchema } = require('zod-to-json-schema'); const { Calculator } = require('@langchain/community/tools/calculator'); @@ -11,7 +12,6 @@ const { ErrorTypes, ContentTypes, imageGenTools, - EToolResources, EModelEndpoint, actionDelimiter, ImageVisionTool, @@ -40,30 +40,6 @@ const { recordUsage } = require('~/server/services/Threads'); const { loadTools } = require('~/app/clients/tools/util'); const { redactMessage } = require('~/config/parsers'); -/** - * @param {string} toolName - * @returns {string | undefined} toolKey - */ -function getToolkitKey(toolName) { - /** @type {string|undefined} */ - let toolkitKey; - for (const toolkit of toolkits) { - if (toolName.startsWith(EToolResources.image_edit)) { - const splitMatches = toolkit.pluginKey.split('_'); - const suffix = splitMatches[splitMatches.length - 1]; - if (toolName.endsWith(suffix)) { - toolkitKey = toolkit.pluginKey; - break; - } - } - if (toolName.startsWith(toolkit.pluginKey)) { - toolkitKey = toolkit.pluginKey; - break; - } - } - return toolkitKey; -} - /** * Loads and formats tools from the specified tool directory. * @@ -145,7 +121,7 @@ function loadAndFormatTools({ directory, adminFilter = [], adminIncluded = [] }) for (const toolInstance of basicToolInstances) { const formattedTool = formatToOpenAIAssistantTool(toolInstance); let toolName = formattedTool[Tools.function].name; - toolName = getToolkitKey(toolName) ?? toolName; + toolName = getToolkitKey({ toolkits, toolName }) ?? toolName; if (filter.has(toolName) && included.size === 0) { continue; } diff --git a/package-lock.json b/package-lock.json index 305cc15614..861182ac85 100644 --- a/package-lock.json +++ b/package-lock.json @@ -51345,7 +51345,7 @@ }, "packages/api": { "name": "@librechat/api", - "version": "1.2.9", + "version": "1.3.0", "license": "ISC", "devDependencies": { "@babel/preset-env": "^7.21.5", diff --git a/packages/api/package.json b/packages/api/package.json index 14d4da5ec6..8d4acd3aeb 100644 --- a/packages/api/package.json +++ b/packages/api/package.json @@ -1,6 +1,6 @@ { "name": "@librechat/api", - "version": "1.2.9", + "version": "1.3.0", "type": "commonjs", "description": "MCP services for LibreChat", "main": "dist/index.js", diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 977ba41e71..1d1f653b10 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -21,6 +21,8 @@ export * from './agents'; export * from './endpoints'; /* Files */ export * from './files'; +/* Tools */ +export * from './tools'; /* web search */ export * from './web'; /* types */ diff --git a/packages/api/src/tools/format.spec.ts b/packages/api/src/tools/format.spec.ts new file mode 100644 index 0000000000..3226da41f8 --- /dev/null +++ b/packages/api/src/tools/format.spec.ts @@ -0,0 +1,461 @@ +import { AuthType, Constants, EToolResources } from 'librechat-data-provider'; +import type { TPlugin, FunctionTool, TCustomConfig } from 'librechat-data-provider'; +import { + convertMCPToolsToPlugins, + filterUniquePlugins, + checkPluginAuth, + getToolkitKey, +} from './format'; + +describe('format.ts helper functions', () => { + describe('filterUniquePlugins', () => { + it('should return empty array when plugins is undefined', () => { + const result = filterUniquePlugins(undefined); + expect(result).toEqual([]); + }); + + it('should return empty array when plugins is empty', () => { + const result = filterUniquePlugins([]); + expect(result).toEqual([]); + }); + + it('should filter out duplicate plugins based on pluginKey', () => { + const plugins: TPlugin[] = [ + { name: 'Plugin1', pluginKey: 'key1', description: 'First plugin' }, + { name: 'Plugin2', pluginKey: 'key2', description: 'Second plugin' }, + { name: 'Plugin1 Duplicate', pluginKey: 'key1', description: 'Duplicate of first' }, + { name: 'Plugin3', pluginKey: 'key3', description: 'Third plugin' }, + ]; + + const result = filterUniquePlugins(plugins); + expect(result).toHaveLength(3); + expect(result[0].pluginKey).toBe('key1'); + expect(result[1].pluginKey).toBe('key2'); + expect(result[2].pluginKey).toBe('key3'); + // The first occurrence should be kept + expect(result[0].name).toBe('Plugin1'); + }); + + it('should handle plugins with identical data', () => { + const plugin: TPlugin = { name: 'Plugin', pluginKey: 'key', description: 'Test' }; + const plugins: TPlugin[] = [plugin, plugin, plugin]; + + const result = filterUniquePlugins(plugins); + expect(result).toHaveLength(1); + expect(result[0]).toEqual(plugin); + }); + }); + + describe('checkPluginAuth', () => { + const originalEnv = process.env; + + beforeEach(() => { + process.env = { ...originalEnv }; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it('should return false when plugin is undefined', () => { + const result = checkPluginAuth(undefined); + expect(result).toBe(false); + }); + + it('should return false when authConfig is undefined', () => { + const plugin: TPlugin = { name: 'Test', pluginKey: 'test', description: 'Test plugin' }; + const result = checkPluginAuth(plugin); + expect(result).toBe(false); + }); + + it('should return false when authConfig is empty array', () => { + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [], + }; + const result = checkPluginAuth(plugin); + expect(result).toBe(false); + }); + + it('should return true when all required auth fields have valid env values', () => { + process.env.API_KEY = 'valid-key'; + process.env.SECRET_KEY = 'valid-secret'; + + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [ + { authField: 'API_KEY', label: 'API Key', description: 'API Key' }, + { authField: 'SECRET_KEY', label: 'Secret Key', description: 'Secret Key' }, + ], + }; + + const result = checkPluginAuth(plugin); + expect(result).toBe(true); + }); + + it('should return false when any required auth field is missing', () => { + process.env.API_KEY = 'valid-key'; + // SECRET_KEY is not set + + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [ + { authField: 'API_KEY', label: 'API Key', description: 'API Key' }, + { authField: 'SECRET_KEY', label: 'Secret Key', description: 'Secret Key' }, + ], + }; + + const result = checkPluginAuth(plugin); + expect(result).toBe(false); + }); + + it('should return false when auth field value is empty string', () => { + process.env.API_KEY = ''; + + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [{ authField: 'API_KEY', label: 'API Key', description: 'API Key' }], + }; + + const result = checkPluginAuth(plugin); + expect(result).toBe(false); + }); + + it('should return false when auth field value is whitespace only', () => { + process.env.API_KEY = ' '; + + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [{ authField: 'API_KEY', label: 'API Key', description: 'API Key' }], + }; + + const result = checkPluginAuth(plugin); + expect(result).toBe(false); + }); + + it('should return false when auth field value is USER_PROVIDED', () => { + process.env.API_KEY = AuthType.USER_PROVIDED; + + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [{ authField: 'API_KEY', label: 'API Key', description: 'API Key' }], + }; + + const result = checkPluginAuth(plugin); + expect(result).toBe(false); + }); + + it('should handle alternate auth fields with || separator', () => { + process.env.ALTERNATE_KEY = 'valid-key'; + + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [ + { authField: 'PRIMARY_KEY||ALTERNATE_KEY', label: 'API Key', description: 'API Key' }, + ], + }; + + const result = checkPluginAuth(plugin); + expect(result).toBe(true); + }); + + it('should return true when at least one alternate auth field is valid', () => { + process.env.PRIMARY_KEY = ''; + process.env.ALTERNATE_KEY = 'valid-key'; + process.env.THIRD_KEY = AuthType.USER_PROVIDED; + + const plugin: TPlugin = { + name: 'Test', + pluginKey: 'test', + description: 'Test plugin', + authConfig: [ + { + authField: 'PRIMARY_KEY||ALTERNATE_KEY||THIRD_KEY', + label: 'API Key', + description: 'API Key', + }, + ], + }; + + const result = checkPluginAuth(plugin); + expect(result).toBe(true); + }); + }); + + describe('convertMCPToolsToPlugins', () => { + it('should return undefined when functionTools is undefined', () => { + const result = convertMCPToolsToPlugins({ functionTools: undefined }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when functionTools is not an object', () => { + const result = convertMCPToolsToPlugins({ + functionTools: 'not-an-object' as unknown as Record, + }); + expect(result).toBeUndefined(); + }); + + it('should return empty array when functionTools is empty object', () => { + const result = convertMCPToolsToPlugins({ functionTools: {} }); + expect(result).toEqual([]); + }); + + it('should skip entries without function property', () => { + const functionTools: Record = { + tool1: { type: 'function' } as FunctionTool, + tool2: { function: { name: 'tool2', description: 'Tool 2' } } as FunctionTool, + }; + + const result = convertMCPToolsToPlugins({ functionTools }); + expect(result).toHaveLength(0); // tool2 doesn't have mcp_delimiter in key + }); + + it('should skip entries without mcp_delimiter in key', () => { + const functionTools: Record = { + 'regular-tool': { + type: 'function', + function: { name: 'regular-tool', description: 'Regular tool' }, + } as FunctionTool, + }; + + const result = convertMCPToolsToPlugins({ functionTools }); + expect(result).toHaveLength(0); + }); + + it('should convert MCP tools to plugins correctly', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1', description: 'Tool 1 description' }, + } as FunctionTool, + }; + + const result = convertMCPToolsToPlugins({ functionTools }); + expect(result).toHaveLength(1); + expect(result![0]).toEqual({ + name: 'tool1', + pluginKey: `tool1${Constants.mcp_delimiter}server1`, + description: 'Tool 1 description', + authenticated: true, + icon: undefined, + authConfig: [], + }); + }); + + it('should handle missing description', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1' }, + } as FunctionTool, + }; + + const result = convertMCPToolsToPlugins({ functionTools }); + expect(result).toHaveLength(1); + expect(result![0].description).toBe(''); + }); + + it('should add icon from server config', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1', description: 'Tool 1' }, + } as FunctionTool, + }; + + const customConfig: Partial = { + mcpServers: { + server1: { + command: 'test', + args: [], + iconPath: '/path/to/icon.png', + }, + }, + }; + + const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + expect(result).toHaveLength(1); + expect(result![0].icon).toBe('/path/to/icon.png'); + }); + + it('should handle customUserVars in server config', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1', description: 'Tool 1' }, + } as FunctionTool, + }; + + const customConfig: Partial = { + mcpServers: { + server1: { + command: 'test', + args: [], + customUserVars: { + API_KEY: { title: 'API Key', description: 'Your API key' }, + SECRET: { title: 'Secret', description: 'Your secret' }, + }, + }, + }, + }; + + const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + expect(result).toHaveLength(1); + expect(result![0].authConfig).toHaveLength(2); + expect(result![0].authConfig).toEqual([ + { authField: 'API_KEY', label: 'API Key', description: 'Your API key' }, + { authField: 'SECRET', label: 'Secret', description: 'Your secret' }, + ]); + }); + + it('should use key as label when title is missing in customUserVars', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1', description: 'Tool 1' }, + } as FunctionTool, + }; + + const customConfig: Partial = { + mcpServers: { + server1: { + command: 'test', + args: [], + customUserVars: { + API_KEY: { title: 'API Key', description: 'Your API key' }, + }, + }, + }, + }; + + const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + expect(result).toHaveLength(1); + expect(result![0].authConfig).toEqual([ + { authField: 'API_KEY', label: 'API Key', description: 'Your API key' }, + ]); + }); + + it('should handle empty customUserVars', () => { + const functionTools: Record = { + [`tool1${Constants.mcp_delimiter}server1`]: { + type: 'function', + function: { name: 'tool1', description: 'Tool 1' }, + } as FunctionTool, + }; + + const customConfig: Partial = { + mcpServers: { + server1: { + command: 'test', + args: [], + customUserVars: {}, + }, + }, + }; + + const result = convertMCPToolsToPlugins({ functionTools, customConfig }); + expect(result).toHaveLength(1); + expect(result![0].authConfig).toEqual([]); + }); + }); + + describe('getToolkitKey', () => { + it('should return undefined when toolName is undefined', () => { + const toolkits: TPlugin[] = [ + { name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' }, + ]; + + const result = getToolkitKey({ toolkits, toolName: undefined }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when toolName is empty string', () => { + const toolkits: TPlugin[] = [ + { name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' }, + ]; + + const result = getToolkitKey({ toolkits, toolName: '' }); + expect(result).toBeUndefined(); + }); + + it('should return undefined when no matching toolkit is found', () => { + const toolkits: TPlugin[] = [ + { name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' }, + { name: 'Toolkit2', pluginKey: 'toolkit2', description: 'Test toolkit' }, + ]; + + const result = getToolkitKey({ toolkits, toolName: 'nonexistent_tool' }); + expect(result).toBeUndefined(); + }); + + it('should match toolkit when toolName starts with pluginKey', () => { + const toolkits: TPlugin[] = [ + { name: 'Toolkit1', pluginKey: 'toolkit1', description: 'Test toolkit' }, + { name: 'Toolkit2', pluginKey: 'toolkit2', description: 'Test toolkit' }, + ]; + + const result = getToolkitKey({ toolkits, toolName: 'toolkit2_function' }); + expect(result).toBe('toolkit2'); + }); + + it('should handle image_edit tools with suffix matching', () => { + const toolkits: TPlugin[] = [ + { name: 'Image Editor', pluginKey: 'image_edit_v1', description: 'Image editing' }, + { name: 'Image Editor 2', pluginKey: 'image_edit_v2', description: 'Image editing v2' }, + ]; + + const result = getToolkitKey({ + toolkits, + toolName: `${EToolResources.image_edit}_function_v2`, + }); + expect(result).toBe('image_edit_v2'); + }); + + it('should match the first toolkit when multiple matches are possible', () => { + const toolkits: TPlugin[] = [ + { name: 'Toolkit', pluginKey: 'toolkit', description: 'Base toolkit' }, + { name: 'Toolkit Extended', pluginKey: 'toolkit_extended', description: 'Extended' }, + ]; + + const result = getToolkitKey({ toolkits, toolName: 'toolkit_function' }); + expect(result).toBe('toolkit'); + }); + + it('should handle empty toolkits array', () => { + const toolkits: TPlugin[] = []; + + const result = getToolkitKey({ toolkits, toolName: 'any_tool' }); + expect(result).toBeUndefined(); + }); + + it('should handle complex plugin keys with underscores', () => { + const toolkits: TPlugin[] = [ + { + name: 'Complex Toolkit', + pluginKey: 'complex_toolkit_with_underscores', + description: 'Complex', + }, + ]; + + const result = getToolkitKey({ + toolkits, + toolName: 'complex_toolkit_with_underscores_function', + }); + expect(result).toBe('complex_toolkit_with_underscores'); + }); + }); +}); diff --git a/packages/api/src/tools/format.ts b/packages/api/src/tools/format.ts new file mode 100644 index 0000000000..13075ab819 --- /dev/null +++ b/packages/api/src/tools/format.ts @@ -0,0 +1,142 @@ +import { AuthType, Constants, EToolResources } from 'librechat-data-provider'; +import type { TCustomConfig, TPlugin, FunctionTool } from 'librechat-data-provider'; + +/** + * Filters out duplicate plugins from the list of plugins. + * + * @param plugins The list of plugins to filter. + * @returns The list of plugins with duplicates removed. + */ +export const filterUniquePlugins = (plugins?: TPlugin[]): TPlugin[] => { + const seen = new Set(); + return ( + plugins?.filter((plugin) => { + const duplicate = seen.has(plugin.pluginKey); + seen.add(plugin.pluginKey); + return !duplicate; + }) || [] + ); +}; + +/** + * Determines if a plugin is authenticated by checking if all required authentication fields have non-empty values. + * Supports alternate authentication fields, allowing validation against multiple possible environment variables. + * + * @param plugin The plugin object containing the authentication configuration. + * @returns True if the plugin is authenticated for all required fields, false otherwise. + */ +export const checkPluginAuth = (plugin?: TPlugin): boolean => { + if (!plugin?.authConfig || plugin.authConfig.length === 0) { + return false; + } + + return plugin.authConfig.every((authFieldObj) => { + const authFieldOptions = authFieldObj.authField.split('||'); + let isFieldAuthenticated = false; + + for (const fieldOption of authFieldOptions) { + const envValue = process.env[fieldOption]; + if (envValue && envValue.trim() !== '' && envValue !== AuthType.USER_PROVIDED) { + isFieldAuthenticated = true; + break; + } + } + + return isFieldAuthenticated; + }); +}; + +/** + * Converts MCP function format tools to plugin format + * @param functionTools - Object with function format tools + * @param customConfig - Custom configuration for MCP servers + * @returns Array of plugin objects + */ +export function convertMCPToolsToPlugins({ + functionTools, + customConfig, +}: { + functionTools?: Record; + customConfig?: Partial | null; +}): TPlugin[] | undefined { + if (!functionTools || typeof functionTools !== 'object') { + return; + } + + const plugins: TPlugin[] = []; + for (const [toolKey, toolData] of Object.entries(functionTools)) { + if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) { + continue; + } + + const functionData = toolData.function; + const parts = toolKey.split(Constants.mcp_delimiter); + const serverName = parts[parts.length - 1]; + + const serverConfig = customConfig?.mcpServers?.[serverName]; + + const plugin: TPlugin = { + /** Tool name without server suffix */ + name: parts[0], + pluginKey: toolKey, + description: functionData.description || '', + authenticated: true, + icon: serverConfig?.iconPath, + }; + + if (!serverConfig?.customUserVars) { + /** `authConfig` for MCP tools */ + plugin.authConfig = []; + plugins.push(plugin); + continue; + } + + const customVarKeys = Object.keys(serverConfig.customUserVars); + if (customVarKeys.length === 0) { + plugin.authConfig = []; + } else { + plugin.authConfig = Object.entries(serverConfig.customUserVars).map(([key, value]) => ({ + authField: key, + label: value.title || key, + description: value.description || '', + })); + } + + plugins.push(plugin); + } + + return plugins; +} + +/** + * @param toolkits + * @param toolName + * @returns toolKey + */ +export function getToolkitKey({ + toolkits, + toolName, +}: { + toolkits: TPlugin[]; + toolName?: string; +}): string | undefined { + let toolkitKey: string | undefined; + if (!toolName) { + return toolkitKey; + } + for (const toolkit of toolkits) { + if (toolName.startsWith(EToolResources.image_edit)) { + const splitMatches = toolkit.pluginKey.split('_'); + const suffix = splitMatches[splitMatches.length - 1]; + if (toolName.endsWith(suffix)) { + toolkitKey = toolkit.pluginKey; + break; + } + } + if (toolName.startsWith(toolkit.pluginKey)) { + toolkitKey = toolkit.pluginKey; + break; + } + } + return toolkitKey; +} diff --git a/packages/api/src/tools/index.ts b/packages/api/src/tools/index.ts new file mode 100644 index 0000000000..16c5b2b508 --- /dev/null +++ b/packages/api/src/tools/index.ts @@ -0,0 +1 @@ +export * from './format';