mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
🧰 refactor: Decouple MCP Tools from System Tools (#9748)
This commit is contained in:
parent
9d2aba5df5
commit
386900fb4f
29 changed files with 1032 additions and 1195 deletions
561
api/server/controllers/mcp.spec.js
Normal file
561
api/server/controllers/mcp.spec.js
Normal file
|
|
@ -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([]);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue