mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🧰 fix: Available Tools Retrieval with correct MCP Caching (#9181)
* fix: available tools retrieval with correct mcp caching and conversion * test: Enhance PluginController tests with MCP tool mocking and conversion * refactor: Simplify PluginController tests by removing unused mocks and enhancing test clarity
This commit is contained in:
parent
a49b2b2833
commit
aba0a93d1d
5 changed files with 268 additions and 195 deletions
|
|
@ -4,6 +4,7 @@ const {
|
||||||
getToolkitKey,
|
getToolkitKey,
|
||||||
checkPluginAuth,
|
checkPluginAuth,
|
||||||
filterUniquePlugins,
|
filterUniquePlugins,
|
||||||
|
convertMCPToolToPlugin,
|
||||||
convertMCPToolsToPlugins,
|
convertMCPToolsToPlugins,
|
||||||
} = require('@librechat/api');
|
} = require('@librechat/api');
|
||||||
const {
|
const {
|
||||||
|
|
@ -107,16 +108,20 @@ const getAvailableTools = async (req, res) => {
|
||||||
if (customConfig?.mcpServers != null) {
|
if (customConfig?.mcpServers != null) {
|
||||||
try {
|
try {
|
||||||
const mcpManager = getMCPManager();
|
const mcpManager = getMCPManager();
|
||||||
const mcpTools = await mcpManager.loadAllManifestTools(userId);
|
const mcpTools = await mcpManager.getAllToolFunctions(userId);
|
||||||
const mcpToolsRecord = mcpTools.reduce((acc, tool) => {
|
prelimCachedTools = prelimCachedTools ?? {};
|
||||||
pluginManifest.push(tool);
|
for (const [toolKey, toolData] of Object.entries(mcpTools)) {
|
||||||
acc[tool.pluginKey] = tool;
|
const plugin = convertMCPToolToPlugin({
|
||||||
if (!toolDefinitions[tool.pluginKey]) {
|
toolKey,
|
||||||
toolDefinitions[tool.pluginKey] = tool;
|
toolData,
|
||||||
|
customConfig,
|
||||||
|
});
|
||||||
|
if (plugin) {
|
||||||
|
pluginManifest.push(plugin);
|
||||||
}
|
}
|
||||||
return acc;
|
prelimCachedTools[toolKey] = toolData;
|
||||||
}, prelimCachedTools ?? {});
|
}
|
||||||
await mergeUserTools({ userId, cachedUserTools, userTools: mcpToolsRecord });
|
await mergeUserTools({ userId, cachedUserTools, userTools: prelimCachedTools });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
'[getAvailableTools] Error loading MCP Tools, servers may still be initializing:',
|
'[getAvailableTools] Error loading MCP Tools, servers may still be initializing:',
|
||||||
|
|
|
||||||
|
|
@ -38,21 +38,7 @@ jest.mock('~/cache', () => ({
|
||||||
getLogStores: jest.fn(),
|
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, getAvailablePluginsController } = require('./PluginController');
|
const { getAvailableTools, getAvailablePluginsController } = require('./PluginController');
|
||||||
const {
|
|
||||||
filterUniquePlugins,
|
|
||||||
checkPluginAuth,
|
|
||||||
convertMCPToolsToPlugins,
|
|
||||||
getToolkitKey,
|
|
||||||
} = require('@librechat/api');
|
|
||||||
const { loadAndFormatTools } = require('~/server/services/ToolService');
|
const { loadAndFormatTools } = require('~/server/services/ToolService');
|
||||||
|
|
||||||
describe('PluginController', () => {
|
describe('PluginController', () => {
|
||||||
|
|
@ -60,10 +46,23 @@ describe('PluginController', () => {
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.clearAllMocks();
|
jest.clearAllMocks();
|
||||||
mockReq = { user: { id: 'test-user-id' } };
|
mockReq = {
|
||||||
|
user: { id: 'test-user-id' },
|
||||||
|
app: {
|
||||||
|
locals: {
|
||||||
|
paths: { structuredTools: '/mock/path' },
|
||||||
|
filteredTools: null,
|
||||||
|
includedTools: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
mockRes = { status: jest.fn().mockReturnThis(), json: jest.fn() };
|
||||||
mockCache = { get: jest.fn(), set: jest.fn() };
|
mockCache = { get: jest.fn(), set: jest.fn() };
|
||||||
getLogStores.mockReturnValue(mockCache);
|
getLogStores.mockReturnValue(mockCache);
|
||||||
|
|
||||||
|
// Clear availableTools and toolkits arrays before each test
|
||||||
|
require('~/app/clients/tools').availableTools.length = 0;
|
||||||
|
require('~/app/clients/tools').toolkits.length = 0;
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getAvailablePluginsController', () => {
|
describe('getAvailablePluginsController', () => {
|
||||||
|
|
@ -72,38 +71,39 @@ describe('PluginController', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
|
it('should use filterUniquePlugins to remove duplicate plugins', async () => {
|
||||||
|
// Add plugins with duplicates to availableTools
|
||||||
const mockPlugins = [
|
const mockPlugins = [
|
||||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
{ name: 'Plugin1', pluginKey: 'key1', description: 'First' },
|
||||||
|
{ name: 'Plugin1', pluginKey: 'key1', description: 'First duplicate' },
|
||||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
filterUniquePlugins.mockReturnValue(mockPlugins);
|
|
||||||
checkPluginAuth.mockReturnValue(true);
|
|
||||||
|
|
||||||
await getAvailablePluginsController(mockReq, mockRes);
|
await getAvailablePluginsController(mockReq, mockRes);
|
||||||
|
|
||||||
expect(filterUniquePlugins).toHaveBeenCalled();
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
// The response includes authenticated: true for each plugin when checkPluginAuth returns true
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
expect(mockRes.json).toHaveBeenCalledWith([
|
expect(responseData).toHaveLength(2);
|
||||||
{ name: 'Plugin1', pluginKey: 'key1', description: 'First', authenticated: true },
|
expect(responseData[0].pluginKey).toBe('key1');
|
||||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second', authenticated: true },
|
expect(responseData[1].pluginKey).toBe('key2');
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use checkPluginAuth to verify plugin authentication', async () => {
|
it('should use checkPluginAuth to verify plugin authentication', async () => {
|
||||||
|
// checkPluginAuth returns false for plugins without authConfig
|
||||||
|
// so authenticated property won't be added
|
||||||
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
|
const mockPlugin = { name: 'Plugin1', pluginKey: 'key1', description: 'First' };
|
||||||
|
|
||||||
|
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
filterUniquePlugins.mockReturnValue([mockPlugin]);
|
|
||||||
checkPluginAuth.mockReturnValueOnce(true);
|
|
||||||
|
|
||||||
await getAvailablePluginsController(mockReq, mockRes);
|
await getAvailablePluginsController(mockReq, mockRes);
|
||||||
|
|
||||||
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
|
|
||||||
const responseData = mockRes.json.mock.calls[0][0];
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
expect(responseData[0].authenticated).toBe(true);
|
// checkPluginAuth returns false, so authenticated property is not added
|
||||||
|
expect(responseData[0].authenticated).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return cached plugins when available', async () => {
|
it('should return cached plugins when available', async () => {
|
||||||
|
|
@ -115,8 +115,7 @@ describe('PluginController', () => {
|
||||||
|
|
||||||
await getAvailablePluginsController(mockReq, mockRes);
|
await getAvailablePluginsController(mockReq, mockRes);
|
||||||
|
|
||||||
expect(filterUniquePlugins).not.toHaveBeenCalled();
|
// When cache is hit, we return immediately without processing
|
||||||
expect(checkPluginAuth).not.toHaveBeenCalled();
|
|
||||||
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
|
expect(mockRes.json).toHaveBeenCalledWith(cachedPlugins);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -126,10 +125,9 @@ describe('PluginController', () => {
|
||||||
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
{ name: 'Plugin2', pluginKey: 'key2', description: 'Second' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
require('~/app/clients/tools').availableTools.push(...mockPlugins);
|
||||||
mockReq.app.locals.includedTools = ['key1'];
|
mockReq.app.locals.includedTools = ['key1'];
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
filterUniquePlugins.mockReturnValue(mockPlugins);
|
|
||||||
checkPluginAuth.mockReturnValue(false);
|
|
||||||
|
|
||||||
await getAvailablePluginsController(mockReq, mockRes);
|
await getAvailablePluginsController(mockReq, mockRes);
|
||||||
|
|
||||||
|
|
@ -143,70 +141,102 @@ describe('PluginController', () => {
|
||||||
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
|
it('should use convertMCPToolsToPlugins for user-specific MCP tools', async () => {
|
||||||
const mockUserTools = {
|
const mockUserTools = {
|
||||||
[`tool1${Constants.mcp_delimiter}server1`]: {
|
[`tool1${Constants.mcp_delimiter}server1`]: {
|
||||||
function: { name: 'tool1', description: 'Tool 1' },
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: `tool1${Constants.mcp_delimiter}server1`,
|
||||||
|
description: 'Tool 1',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
const mockConvertedPlugins = [
|
|
||||||
{
|
|
||||||
name: 'tool1',
|
|
||||||
pluginKey: `tool1${Constants.mcp_delimiter}server1`,
|
|
||||||
description: 'Tool 1',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||||
convertMCPToolsToPlugins.mockReturnValue(mockConvertedPlugins);
|
|
||||||
filterUniquePlugins.mockImplementation((plugins) => plugins);
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Mock second call to return tool definitions
|
||||||
|
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
expect(convertMCPToolsToPlugins).toHaveBeenCalledWith({
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
functionTools: mockUserTools,
|
// convertMCPToolsToPlugins should have converted the tool
|
||||||
customConfig: null,
|
expect(responseData.length).toBeGreaterThan(0);
|
||||||
});
|
const convertedTool = responseData.find(
|
||||||
|
(tool) => tool.pluginKey === `tool1${Constants.mcp_delimiter}server1`,
|
||||||
|
);
|
||||||
|
expect(convertedTool).toBeDefined();
|
||||||
|
expect(convertedTool.name).toBe('tool1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
it('should use filterUniquePlugins to deduplicate combined tools', async () => {
|
||||||
const mockUserPlugins = [
|
const mockUserTools = {
|
||||||
{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' },
|
'user-tool': {
|
||||||
];
|
type: 'function',
|
||||||
const mockManifestPlugins = [
|
function: {
|
||||||
|
name: 'user-tool',
|
||||||
|
description: 'User tool',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockCachedPlugins = [
|
||||||
|
{ name: 'user-tool', pluginKey: 'user-tool', description: 'Duplicate user tool' },
|
||||||
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
|
{ name: 'ManifestTool', pluginKey: 'manifest-tool', description: 'Manifest tool' },
|
||||||
];
|
];
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(mockManifestPlugins);
|
mockCache.get.mockResolvedValue(mockCachedPlugins);
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||||
convertMCPToolsToPlugins.mockReturnValue(mockUserPlugins);
|
|
||||||
filterUniquePlugins.mockReturnValue([...mockUserPlugins, ...mockManifestPlugins]);
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Mock second call to return tool definitions
|
||||||
|
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
// Should be called to deduplicate the combined array
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
expect(filterUniquePlugins).toHaveBeenLastCalledWith([
|
// Should have deduplicated tools with same pluginKey
|
||||||
...mockUserPlugins,
|
const userToolCount = responseData.filter((tool) => tool.pluginKey === 'user-tool').length;
|
||||||
...mockManifestPlugins,
|
expect(userToolCount).toBe(1);
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use checkPluginAuth to verify authentication status', async () => {
|
it('should use checkPluginAuth to verify authentication status', async () => {
|
||||||
const mockPlugin = { name: 'Tool1', pluginKey: 'tool1', description: 'Tool 1' };
|
// Add a plugin to availableTools that will be checked
|
||||||
|
const mockPlugin = {
|
||||||
|
name: 'Tool1',
|
||||||
|
pluginKey: 'tool1',
|
||||||
|
description: 'Tool 1',
|
||||||
|
// No authConfig means checkPluginAuth returns false
|
||||||
|
};
|
||||||
|
|
||||||
|
require('~/app/clients/tools').availableTools.push(mockPlugin);
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValue({});
|
getCachedTools.mockResolvedValue(null);
|
||||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
|
||||||
filterUniquePlugins.mockReturnValue([mockPlugin]);
|
|
||||||
checkPluginAuth.mockReturnValue(true);
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
// Mock getCachedTools second call to return tool definitions
|
// Mock loadAndFormatTools to return tool definitions including our tool
|
||||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({ tool1: true });
|
loadAndFormatTools.mockReturnValue({
|
||||||
|
tool1: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'tool1',
|
||||||
|
description: 'Tool 1',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
expect(checkPluginAuth).toHaveBeenCalledWith(mockPlugin);
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
|
expect(Array.isArray(responseData)).toBe(true);
|
||||||
|
const tool = responseData.find((t) => t.pluginKey === 'tool1');
|
||||||
|
expect(tool).toBeDefined();
|
||||||
|
// checkPluginAuth returns false, so authenticated property is not added
|
||||||
|
expect(tool.authenticated).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should use getToolkitKey for toolkit validation', async () => {
|
it('should use getToolkitKey for toolkit validation', async () => {
|
||||||
|
|
@ -217,22 +247,38 @@ describe('PluginController', () => {
|
||||||
toolkit: true,
|
toolkit: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
||||||
|
|
||||||
|
// Mock toolkits to have a mapping
|
||||||
|
require('~/app/clients/tools').toolkits.push({
|
||||||
|
name: 'Toolkit1',
|
||||||
|
pluginKey: 'toolkit1',
|
||||||
|
tools: ['toolkit1_function'],
|
||||||
|
});
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValue({});
|
getCachedTools.mockResolvedValue(null);
|
||||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
|
||||||
filterUniquePlugins.mockReturnValue([mockToolkit]);
|
|
||||||
checkPluginAuth.mockReturnValue(false);
|
|
||||||
getToolkitKey.mockReturnValue('toolkit1');
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
// Mock getCachedTools second call to return tool definitions
|
// Mock loadAndFormatTools to return tool definitions
|
||||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({
|
loadAndFormatTools.mockReturnValue({
|
||||||
toolkit1_function: true,
|
toolkit1_function: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: 'toolkit1_function',
|
||||||
|
description: 'Toolkit function',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
expect(getToolkitKey).toHaveBeenCalled();
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
|
expect(Array.isArray(responseData)).toBe(true);
|
||||||
|
const toolkit = responseData.find((t) => t.pluginKey === 'toolkit1');
|
||||||
|
expect(toolkit).toBeDefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -243,32 +289,33 @@ describe('PluginController', () => {
|
||||||
|
|
||||||
const functionTools = {
|
const functionTools = {
|
||||||
[`test-tool${Constants.mcp_delimiter}test-server`]: {
|
[`test-tool${Constants.mcp_delimiter}test-server`]: {
|
||||||
function: { name: 'test-tool', description: 'A test tool' },
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: `test-tool${Constants.mcp_delimiter}test-server`,
|
||||||
|
description: 'A test tool',
|
||||||
|
parameters: { type: 'object', properties: {} },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockConvertedPlugin = {
|
// Mock the MCP manager to return tools
|
||||||
name: 'test-tool',
|
const mockMCPManager = {
|
||||||
pluginKey: `test-tool${Constants.mcp_delimiter}test-server`,
|
getAllToolFunctions: jest.fn().mockResolvedValue(functionTools),
|
||||||
description: 'A test tool',
|
|
||||||
icon: mcpServers['test-server']?.iconPath,
|
|
||||||
authenticated: true,
|
|
||||||
authConfig: [],
|
|
||||||
};
|
};
|
||||||
|
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
||||||
|
|
||||||
|
getCachedTools.mockResolvedValueOnce({});
|
||||||
|
|
||||||
|
// Mock loadAndFormatTools to return empty object since these are MCP tools
|
||||||
|
loadAndFormatTools.mockReturnValue({});
|
||||||
|
|
||||||
getCachedTools.mockResolvedValueOnce(functionTools);
|
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,
|
|
||||||
});
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
const responseData = mockRes.json.mock.calls[0][0];
|
const responseData = mockRes.json.mock.calls[0][0];
|
||||||
return responseData.find((tool) => tool.name === 'test-tool');
|
return responseData.find(
|
||||||
|
(tool) => tool.pluginKey === `test-tool${Constants.mcp_delimiter}test-server`,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
it('should set plugin.icon when iconPath is defined', async () => {
|
it('should set plugin.icon when iconPath is defined', async () => {
|
||||||
|
|
@ -302,19 +349,21 @@ describe('PluginController', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
// We need to test the actual flow where MCP manager tools are included
|
// Mock MCP tools returned by getAllToolFunctions
|
||||||
const mcpManagerTools = [
|
const mcpToolFunctions = {
|
||||||
{
|
[`tool1${Constants.mcp_delimiter}test-server`]: {
|
||||||
name: 'tool1',
|
type: 'function',
|
||||||
pluginKey: `tool1${Constants.mcp_delimiter}test-server`,
|
function: {
|
||||||
|
name: `tool1${Constants.mcp_delimiter}test-server`,
|
||||||
description: 'Tool 1',
|
description: 'Tool 1',
|
||||||
authenticated: true,
|
parameters: {},
|
||||||
},
|
},
|
||||||
];
|
},
|
||||||
|
};
|
||||||
|
|
||||||
// Mock the MCP manager to return tools
|
// Mock the MCP manager to return tools
|
||||||
const mockMCPManager = {
|
const mockMCPManager = {
|
||||||
loadAllManifestTools: jest.fn().mockResolvedValue(mcpManagerTools),
|
getAllToolFunctions: jest.fn().mockResolvedValue(mcpToolFunctions),
|
||||||
};
|
};
|
||||||
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
require('~/config').getMCPManager.mockReturnValue(mockMCPManager);
|
||||||
|
|
||||||
|
|
@ -324,19 +373,11 @@ describe('PluginController', () => {
|
||||||
// First call returns user tools (empty in this case)
|
// First call returns user tools (empty in this case)
|
||||||
getCachedTools.mockResolvedValueOnce({});
|
getCachedTools.mockResolvedValueOnce({});
|
||||||
|
|
||||||
// Mock convertMCPToolsToPlugins to return empty array for user tools
|
// Mock loadAndFormatTools to return empty object for MCP tools
|
||||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
loadAndFormatTools.mockReturnValue({});
|
||||||
|
|
||||||
// Mock filterUniquePlugins to pass through
|
// Second call returns tool definitions including our MCP tool
|
||||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
getCachedTools.mockResolvedValueOnce(mcpToolFunctions);
|
||||||
|
|
||||||
// Mock checkPluginAuth
|
|
||||||
checkPluginAuth.mockReturnValue(true);
|
|
||||||
|
|
||||||
// Second call returns tool definitions
|
|
||||||
getCachedTools.mockResolvedValueOnce({
|
|
||||||
[`tool1${Constants.mcp_delimiter}test-server`]: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
|
|
@ -377,23 +418,23 @@ describe('PluginController', () => {
|
||||||
it('should handle null cachedTools and cachedUserTools', async () => {
|
it('should handle null cachedTools and cachedUserTools', async () => {
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValue(null);
|
getCachedTools.mockResolvedValue(null);
|
||||||
convertMCPToolsToPlugins.mockReturnValue(undefined);
|
|
||||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
|
// Mock loadAndFormatTools to return empty object when getCachedTools returns null
|
||||||
|
loadAndFormatTools.mockReturnValue({});
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
// When cachedUserTools is null, convertMCPToolsToPlugins is not called
|
// Should handle null values gracefully
|
||||||
expect(convertMCPToolsToPlugins).not.toHaveBeenCalled();
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle when getCachedTools returns undefined', async () => {
|
it('should handle when getCachedTools returns undefined', async () => {
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValue(undefined);
|
|
||||||
convertMCPToolsToPlugins.mockReturnValue(undefined);
|
|
||||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
checkPluginAuth.mockReturnValue(false);
|
|
||||||
|
// Mock loadAndFormatTools to return empty object when getCachedTools returns undefined
|
||||||
|
loadAndFormatTools.mockReturnValue({});
|
||||||
|
|
||||||
// Mock getCachedTools to return undefined for both calls
|
// Mock getCachedTools to return undefined for both calls
|
||||||
getCachedTools.mockReset();
|
getCachedTools.mockReset();
|
||||||
|
|
@ -401,35 +442,40 @@ describe('PluginController', () => {
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
// When cachedUserTools is undefined, convertMCPToolsToPlugins is not called
|
// Should handle undefined values gracefully
|
||||||
expect(convertMCPToolsToPlugins).not.toHaveBeenCalled();
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
|
it('should handle cachedToolsArray and userPlugins both being defined', async () => {
|
||||||
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
|
const cachedTools = [{ name: 'CachedTool', pluginKey: 'cached-tool', description: 'Cached' }];
|
||||||
|
// Use MCP delimiter for the user tool so convertMCPToolsToPlugins works
|
||||||
const userTools = {
|
const userTools = {
|
||||||
'user-tool': { function: { name: 'user-tool', description: 'User tool' } },
|
[`user-tool${Constants.mcp_delimiter}server1`]: {
|
||||||
|
type: 'function',
|
||||||
|
function: {
|
||||||
|
name: `user-tool${Constants.mcp_delimiter}server1`,
|
||||||
|
description: 'User tool',
|
||||||
|
parameters: {},
|
||||||
|
},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const userPlugins = [{ name: 'UserTool', pluginKey: 'user-tool', description: 'User tool' }];
|
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(cachedTools);
|
mockCache.get.mockResolvedValue(cachedTools);
|
||||||
getCachedTools.mockResolvedValue(userTools);
|
getCachedTools.mockResolvedValue(userTools);
|
||||||
convertMCPToolsToPlugins.mockReturnValue(userPlugins);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
filterUniquePlugins.mockReturnValue([...userPlugins, ...cachedTools]);
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
expect(mockRes.status).toHaveBeenCalledWith(200);
|
expect(mockRes.status).toHaveBeenCalledWith(200);
|
||||||
expect(mockRes.json).toHaveBeenCalledWith([...userPlugins, ...cachedTools]);
|
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 () => {
|
it('should handle empty toolDefinitions object', async () => {
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
|
getCachedTools.mockResolvedValueOnce({}).mockResolvedValueOnce({});
|
||||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
|
||||||
filterUniquePlugins.mockImplementation((plugins) => plugins || []);
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
checkPluginAuth.mockReturnValue(true);
|
|
||||||
|
|
||||||
await getAvailableTools(mockReq, mockRes);
|
await getAvailableTools(mockReq, mockRes);
|
||||||
|
|
||||||
|
|
@ -456,18 +502,6 @@ describe('PluginController', () => {
|
||||||
getCustomConfig.mockResolvedValue(customConfig);
|
getCustomConfig.mockResolvedValue(customConfig);
|
||||||
getCachedTools.mockResolvedValueOnce(mockUserTools);
|
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({
|
getCachedTools.mockResolvedValueOnce({
|
||||||
[`tool1${Constants.mcp_delimiter}test-server`]: true,
|
[`tool1${Constants.mcp_delimiter}test-server`]: true,
|
||||||
});
|
});
|
||||||
|
|
@ -483,8 +517,6 @@ describe('PluginController', () => {
|
||||||
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
|
it('should handle req.app.locals with undefined filteredTools and includedTools', async () => {
|
||||||
mockReq.app = { locals: {} };
|
mockReq.app = { locals: {} };
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
filterUniquePlugins.mockReturnValue([]);
|
|
||||||
checkPluginAuth.mockReturnValue(false);
|
|
||||||
|
|
||||||
await getAvailablePluginsController(mockReq, mockRes);
|
await getAvailablePluginsController(mockReq, mockRes);
|
||||||
|
|
||||||
|
|
@ -509,12 +541,11 @@ describe('PluginController', () => {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Add the toolkit to availableTools
|
||||||
|
require('~/app/clients/tools').availableTools.push(mockToolkit);
|
||||||
|
|
||||||
mockCache.get.mockResolvedValue(null);
|
mockCache.get.mockResolvedValue(null);
|
||||||
getCachedTools.mockResolvedValue({});
|
getCachedTools.mockResolvedValue({});
|
||||||
convertMCPToolsToPlugins.mockReturnValue([]);
|
|
||||||
filterUniquePlugins.mockReturnValue([mockToolkit]);
|
|
||||||
checkPluginAuth.mockReturnValue(false);
|
|
||||||
getToolkitKey.mockReturnValue(undefined);
|
|
||||||
getCustomConfig.mockResolvedValue(null);
|
getCustomConfig.mockResolvedValue(null);
|
||||||
|
|
||||||
// Mock loadAndFormatTools to return an empty object when toolDefinitions is null
|
// Mock loadAndFormatTools to return an empty object when toolDefinitions is null
|
||||||
|
|
|
||||||
|
|
@ -58,6 +58,21 @@ export class MCPManager extends UserConnectionManager {
|
||||||
public getAppToolFunctions(): t.LCAvailableTools | null {
|
public getAppToolFunctions(): t.LCAvailableTools | null {
|
||||||
return this.serversRegistry.toolFunctions!;
|
return this.serversRegistry.toolFunctions!;
|
||||||
}
|
}
|
||||||
|
/** Returns all available tool functions from all connections available to user */
|
||||||
|
public async getAllToolFunctions(userId: string): Promise<t.LCAvailableTools | null> {
|
||||||
|
const allToolFunctions: t.LCAvailableTools = this.getAppToolFunctions() ?? {};
|
||||||
|
const userConnections = this.getUserConnections(userId);
|
||||||
|
if (!userConnections || userConnections.size === 0) {
|
||||||
|
return allToolFunctions;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [serverName, connection] of userConnections.entries()) {
|
||||||
|
const toolFunctions = await this.serversRegistry.getToolFunctions(serverName, connection);
|
||||||
|
Object.assign(allToolFunctions, toolFunctions);
|
||||||
|
}
|
||||||
|
|
||||||
|
return allToolFunctions;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Get instructions for MCP servers
|
* Get instructions for MCP servers
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,7 @@ export class MCPServersRegistry {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Converts server tools to LibreChat-compatible tool functions format */
|
/** Converts server tools to LibreChat-compatible tool functions format */
|
||||||
private async getToolFunctions(
|
public async getToolFunctions(
|
||||||
serverName: string,
|
serverName: string,
|
||||||
conn: MCPConnection,
|
conn: MCPConnection,
|
||||||
): Promise<t.LCAvailableTools> {
|
): Promise<t.LCAvailableTools> {
|
||||||
|
|
|
||||||
|
|
@ -47,26 +47,24 @@ export const checkPluginAuth = (plugin?: TPlugin): boolean => {
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Converts MCP function format tools to plugin format
|
* Converts MCP function format tool to plugin format
|
||||||
* @param functionTools - Object with function format tools
|
* @param params
|
||||||
* @param customConfig - Custom configuration for MCP servers
|
* @param params.toolKey
|
||||||
* @returns Array of plugin objects
|
* @param params.toolData
|
||||||
|
* @param params.customConfig
|
||||||
|
* @returns
|
||||||
*/
|
*/
|
||||||
export function convertMCPToolsToPlugins({
|
export function convertMCPToolToPlugin({
|
||||||
functionTools,
|
toolKey,
|
||||||
|
toolData,
|
||||||
customConfig,
|
customConfig,
|
||||||
}: {
|
}: {
|
||||||
functionTools?: Record<string, FunctionTool>;
|
toolKey: string;
|
||||||
|
toolData: FunctionTool;
|
||||||
customConfig?: Partial<TCustomConfig> | null;
|
customConfig?: Partial<TCustomConfig> | null;
|
||||||
}): TPlugin[] | undefined {
|
}): 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)) {
|
if (!toolData.function || !toolKey.includes(Constants.mcp_delimiter)) {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const functionData = toolData.function;
|
const functionData = toolData.function;
|
||||||
|
|
@ -87,8 +85,7 @@ export function convertMCPToolsToPlugins({
|
||||||
if (!serverConfig?.customUserVars) {
|
if (!serverConfig?.customUserVars) {
|
||||||
/** `authConfig` for MCP tools */
|
/** `authConfig` for MCP tools */
|
||||||
plugin.authConfig = [];
|
plugin.authConfig = [];
|
||||||
plugins.push(plugin);
|
return plugin;
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
const customVarKeys = Object.keys(serverConfig.customUserVars);
|
||||||
|
|
@ -102,8 +99,33 @@ export function convertMCPToolsToPlugins({
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return plugin;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<string, FunctionTool>;
|
||||||
|
customConfig?: Partial<TCustomConfig> | null;
|
||||||
|
}): TPlugin[] | undefined {
|
||||||
|
if (!functionTools || typeof functionTools !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const plugins: TPlugin[] = [];
|
||||||
|
for (const [toolKey, toolData] of Object.entries(functionTools)) {
|
||||||
|
const plugin = convertMCPToolToPlugin({ toolKey, toolData, customConfig });
|
||||||
|
if (plugin) {
|
||||||
plugins.push(plugin);
|
plugins.push(plugin);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return plugins;
|
return plugins;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue