mirror of
https://github.com/danny-avila/LibreChat.git
synced 2025-12-17 08:50:15 +01:00
* Feature: Dynamic MCP Server with Full UI Management * 🚦 feat: Add MCP Connection Status icons to MCPBuilder panel (#10805) * feature: Add MCP server connection status icons to MCPBuilder panel * refactor: Simplify MCPConfigDialog rendering in MCPBuilderPanel --------- Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com> Co-authored-by: Danny Avila <danny@librechat.ai> * fix: address code review feedback for MCP server management - Fix OAuth secret preservation to avoid mutating input parameter by creating a merged config copy in ServerConfigsDB.update() - Improve error handling in getResourcePermissionsMap to propagate critical errors instead of silently returning empty Map - Extract duplicated MCP server filter logic by exposing selectableServers from useMCPServerManager hook and using it in MCPSelect component * test: Update PermissionService tests to throw errors on invalid resource types - Changed the test for handling invalid resource types to ensure it throws an error instead of returning an empty permissions map. - Updated the expectation to check for the specific error message when an invalid resource type is provided. * feat: Implement retry logic for MCP server creation to handle race conditions - Enhanced the createMCPServer method to include retry logic with exponential backoff for handling duplicate key errors during concurrent server creation. - Updated tests to verify that all concurrent requests succeed and that unique server names are generated. - Added a helper function to identify MongoDB duplicate key errors, improving error handling during server creation. * refactor: StatusIcon to use CircleCheck for connected status - Replaced the PlugZap icon with CircleCheck in the ConnectedStatusIcon component to better represent the connected state. - Ensured consistent icon usage across the component for improved visual clarity. * test: Update AccessControlService tests to throw errors on invalid resource types - Modified the test for invalid resource types to ensure it throws an error with a specific message instead of returning an empty permissions map. - This change enhances error handling and improves test coverage for the AccessControlService. * fix: Update error message for missing server name in MCP server retrieval - Changed the error message returned when the server name is not provided from 'MCP ID is required' to 'Server name is required' for better clarity and accuracy in the API response. --------- Co-authored-by: Atef Bellaaj <slalom.bellaaj@external.daimlertruck.com> Co-authored-by: Danny Avila <danny@librechat.ai>
1717 lines
58 KiB
JavaScript
1717 lines
58 KiB
JavaScript
const express = require('express');
|
|
const request = require('supertest');
|
|
const mongoose = require('mongoose');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
|
|
const mockRegistryInstance = {
|
|
getServerConfig: jest.fn(),
|
|
getOAuthServers: jest.fn(),
|
|
getAllServerConfigs: jest.fn(),
|
|
addServer: jest.fn(),
|
|
updateServer: jest.fn(),
|
|
removeServer: jest.fn(),
|
|
};
|
|
|
|
jest.mock('@librechat/api', () => ({
|
|
...jest.requireActual('@librechat/api'),
|
|
MCPOAuthHandler: {
|
|
initiateOAuthFlow: jest.fn(),
|
|
getFlowState: jest.fn(),
|
|
completeOAuthFlow: jest.fn(),
|
|
generateFlowId: jest.fn(),
|
|
},
|
|
MCPTokenStorage: {
|
|
storeTokens: jest.fn(),
|
|
getClientInfoAndMetadata: jest.fn(),
|
|
getTokens: jest.fn(),
|
|
deleteUserTokens: jest.fn(),
|
|
},
|
|
getUserMCPAuthMap: jest.fn(),
|
|
generateCheckAccess: jest.fn(() => (req, res, next) => next()),
|
|
MCPServersRegistry: {
|
|
getInstance: () => mockRegistryInstance,
|
|
},
|
|
}));
|
|
|
|
jest.mock('@librechat/data-schemas', () => ({
|
|
logger: {
|
|
debug: jest.fn(),
|
|
info: jest.fn(),
|
|
warn: jest.fn(),
|
|
error: jest.fn(),
|
|
},
|
|
createModels: jest.fn(() => ({
|
|
User: {
|
|
findOne: jest.fn(),
|
|
findById: jest.fn(),
|
|
},
|
|
Conversation: {
|
|
findOne: jest.fn(),
|
|
findById: jest.fn(),
|
|
},
|
|
})),
|
|
createMethods: jest.fn(() => ({
|
|
findUser: jest.fn(),
|
|
})),
|
|
}));
|
|
|
|
jest.mock('~/models', () => ({
|
|
findToken: jest.fn(),
|
|
updateToken: jest.fn(),
|
|
createToken: jest.fn(),
|
|
deleteTokens: jest.fn(),
|
|
findPluginAuthsByKeys: jest.fn(),
|
|
getRoleByName: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config', () => ({
|
|
setCachedTools: jest.fn(),
|
|
getCachedTools: jest.fn(),
|
|
getMCPServerTools: jest.fn(),
|
|
loadCustomConfig: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Config/mcp', () => ({
|
|
updateMCPServerTools: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/MCP', () => ({
|
|
getMCPSetupData: jest.fn(),
|
|
getServerConnectionStatus: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/PluginService', () => ({
|
|
getUserPluginAuthValue: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/config', () => ({
|
|
getMCPManager: jest.fn(),
|
|
getFlowStateManager: jest.fn(),
|
|
getOAuthReconnectionManager: jest.fn(),
|
|
getMCPServersRegistry: jest.fn(() => mockRegistryInstance),
|
|
}));
|
|
|
|
jest.mock('~/cache', () => ({
|
|
getLogStores: jest.fn(),
|
|
}));
|
|
|
|
jest.mock('~/server/middleware', () => ({
|
|
requireJwtAuth: (req, res, next) => next(),
|
|
canAccessMCPServerResource: () => (req, res, next) => next(),
|
|
}));
|
|
|
|
jest.mock('~/server/services/Tools/mcp', () => ({
|
|
reinitMCPServer: jest.fn(),
|
|
}));
|
|
|
|
describe('MCP Routes', () => {
|
|
let app;
|
|
let mongoServer;
|
|
let mcpRouter;
|
|
|
|
beforeAll(async () => {
|
|
mongoServer = await MongoMemoryServer.create();
|
|
await mongoose.connect(mongoServer.getUri());
|
|
|
|
require('~/db/models');
|
|
|
|
mcpRouter = require('../mcp');
|
|
|
|
app = express();
|
|
app.use(express.json());
|
|
|
|
app.use((req, res, next) => {
|
|
req.user = { id: 'test-user-id' };
|
|
next();
|
|
});
|
|
|
|
app.use('/api/mcp', mcpRouter);
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await mongoose.disconnect();
|
|
await mongoServer.stop();
|
|
});
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks();
|
|
});
|
|
|
|
describe('GET /:serverName/oauth/initiate', () => {
|
|
const { MCPOAuthHandler } = require('@librechat/api');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
it('should initiate OAuth flow successfully', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
metadata: {
|
|
serverUrl: 'https://test-server.com',
|
|
oauth: { clientId: 'test-client-id' },
|
|
},
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
|
|
MCPOAuthHandler.initiateOAuthFlow.mockResolvedValue({
|
|
authorizationUrl: 'https://oauth.example.com/auth',
|
|
flowId: 'test-flow-id',
|
|
});
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
|
userId: 'test-user-id',
|
|
flowId: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('https://oauth.example.com/auth');
|
|
expect(MCPOAuthHandler.initiateOAuthFlow).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'https://test-server.com',
|
|
'test-user-id',
|
|
{},
|
|
{ clientId: 'test-client-id' },
|
|
);
|
|
});
|
|
|
|
it('should return 403 when userId does not match authenticated user', async () => {
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
|
userId: 'different-user-id',
|
|
flowId: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body).toEqual({ error: 'User mismatch' });
|
|
});
|
|
|
|
it('should return 404 when flow state is not found', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue(null),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
|
userId: 'test-user-id',
|
|
flowId: 'non-existent-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({ error: 'Flow not found' });
|
|
});
|
|
|
|
it('should return 400 when flow state has missing OAuth config', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
metadata: {
|
|
serverUrl: 'https://test-server.com',
|
|
},
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
|
userId: 'test-user-id',
|
|
flowId: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'Invalid flow state' });
|
|
});
|
|
|
|
it('should return 500 when OAuth initiation throws unexpected error', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockRejectedValue(new Error('Database error')),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
|
userId: 'test-user-id',
|
|
flowId: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Failed to initiate OAuth' });
|
|
});
|
|
|
|
it('should return 400 when flow state metadata is null', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
id: 'test-flow-id',
|
|
metadata: null,
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
|
userId: 'test-user-id',
|
|
flowId: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'Invalid flow state' });
|
|
});
|
|
});
|
|
|
|
describe('GET /:serverName/oauth/callback', () => {
|
|
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
it('should redirect to error page when OAuth error is received', async () => {
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
error: 'access_denied',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/error?error=access_denied');
|
|
});
|
|
|
|
it('should redirect to error page when code is missing', async () => {
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/error?error=missing_code');
|
|
});
|
|
|
|
it('should redirect to error page when state is missing', async () => {
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/error?error=missing_state');
|
|
});
|
|
|
|
it('should redirect to error page when flow state is not found', async () => {
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(null);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'invalid-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/error?error=invalid_state');
|
|
});
|
|
|
|
it('should handle OAuth callback successfully', async () => {
|
|
// mockRegistryInstance is defined at the top of the file
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
|
completeFlow: jest.fn().mockResolvedValue(),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
const mockFlowState = {
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
metadata: { toolFlowId: 'tool-flow-123' },
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
};
|
|
const mockTokens = {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
};
|
|
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const mockUserConnection = {
|
|
fetchTools: jest.fn().mockResolvedValue([
|
|
{
|
|
name: 'test-tool',
|
|
description: 'A test tool',
|
|
inputSchema: { type: 'object' },
|
|
},
|
|
]),
|
|
};
|
|
const mockMcpManager = {
|
|
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
|
};
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
|
const { Constants } = require('librechat-data-provider');
|
|
getCachedTools.mockResolvedValue({
|
|
[`existing-tool${Constants.mcp_delimiter}test-server`]: { type: 'function' },
|
|
[`other-tool${Constants.mcp_delimiter}other-server`]: { type: 'function' },
|
|
});
|
|
setCachedTools.mockResolvedValue();
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
|
expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
|
|
'test-flow-id',
|
|
'test-auth-code',
|
|
mockFlowManager,
|
|
{},
|
|
);
|
|
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'test-user-id',
|
|
serverName: 'test-server',
|
|
tokens: mockTokens,
|
|
clientInfo: mockFlowState.clientInfo,
|
|
metadata: mockFlowState.metadata,
|
|
}),
|
|
);
|
|
const storeInvocation = MCPTokenStorage.storeTokens.mock.invocationCallOrder[0];
|
|
const connectInvocation = mockMcpManager.getUserConnection.mock.invocationCallOrder[0];
|
|
expect(storeInvocation).toBeLessThan(connectInvocation);
|
|
expect(mockFlowManager.completeFlow).toHaveBeenCalledWith(
|
|
'tool-flow-123',
|
|
'mcp_oauth',
|
|
mockTokens,
|
|
);
|
|
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
|
|
});
|
|
|
|
it('should redirect to error page when callback processing fails', async () => {
|
|
MCPOAuthHandler.getFlowState.mockRejectedValue(new Error('Callback error'));
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
|
|
});
|
|
|
|
it('should handle system-level OAuth completion', async () => {
|
|
// mockRegistryInstance is defined at the top of the file
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
|
completeFlow: jest.fn().mockResolvedValue(),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
const mockFlowState = {
|
|
serverName: 'test-server',
|
|
userId: 'system',
|
|
metadata: { toolFlowId: 'tool-flow-123' },
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
};
|
|
const mockTokens = {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
};
|
|
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
|
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
|
|
});
|
|
|
|
it('should handle reconnection failure after OAuth', async () => {
|
|
// mockRegistryInstance is defined at the top of the file
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
|
completeFlow: jest.fn().mockResolvedValue(),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
const mockFlowState = {
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
metadata: { toolFlowId: 'tool-flow-123' },
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
};
|
|
const mockTokens = {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
};
|
|
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const mockMcpManager = {
|
|
getUserConnection: jest.fn().mockRejectedValue(new Error('Reconnection failed')),
|
|
};
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
|
getCachedTools.mockResolvedValue({});
|
|
setCachedTools.mockResolvedValue();
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
|
expect(MCPTokenStorage.storeTokens).toHaveBeenCalled();
|
|
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith('test-flow-id', 'mcp_get_tokens');
|
|
});
|
|
|
|
it('should redirect to error page if token storage fails', async () => {
|
|
// mockRegistryInstance is defined at the top of the file
|
|
const mockFlowManager = {
|
|
completeFlow: jest.fn().mockResolvedValue(),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
const mockFlowState = {
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
metadata: { toolFlowId: 'tool-flow-123' },
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
};
|
|
const mockTokens = {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
};
|
|
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
|
MCPTokenStorage.storeTokens.mockRejectedValue(new Error('store failed'));
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const mockMcpManager = {
|
|
getUserConnection: jest.fn(),
|
|
};
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/error?error=callback_failed');
|
|
expect(mockMcpManager.getUserConnection).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('should use original flow state credentials when storing tokens', async () => {
|
|
// mockRegistryInstance is defined at the top of the file
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn(),
|
|
completeFlow: jest.fn().mockResolvedValue(),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
const clientInfo = {
|
|
client_id: 'client123',
|
|
client_secret: 'client_secret',
|
|
};
|
|
const flowState = {
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
metadata: { toolFlowId: 'tool-flow-123', serverUrl: 'http://example.com' },
|
|
clientInfo: clientInfo,
|
|
codeVerifier: 'test-verifier',
|
|
status: 'PENDING',
|
|
};
|
|
const mockTokens = {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
};
|
|
|
|
// First call checks idempotency (status PENDING = not completed)
|
|
// Second call retrieves flow state for processing
|
|
mockFlowManager.getFlowState
|
|
.mockResolvedValueOnce({ status: 'PENDING' })
|
|
.mockResolvedValueOnce(flowState);
|
|
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(flowState);
|
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const mockUserConnection = {
|
|
fetchTools: jest.fn().mockResolvedValue([]),
|
|
};
|
|
const mockMcpManager = {
|
|
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
|
};
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
require('~/config').getOAuthReconnectionManager = jest.fn().mockReturnValue({
|
|
clearReconnection: jest.fn(),
|
|
});
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
|
|
|
// Verify storeTokens was called with ORIGINAL flow state credentials
|
|
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'test-user-id',
|
|
serverName: 'test-server',
|
|
tokens: mockTokens,
|
|
clientInfo: clientInfo, // Uses original flow state, not any "updated" credentials
|
|
metadata: flowState.metadata,
|
|
}),
|
|
);
|
|
});
|
|
|
|
it('should prevent duplicate token exchange with idempotency check', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn(),
|
|
};
|
|
|
|
// Flow is already completed
|
|
mockFlowManager.getFlowState.mockResolvedValue({
|
|
status: 'COMPLETED',
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
});
|
|
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue({
|
|
status: 'COMPLETED',
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
});
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-flow-id',
|
|
});
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe('/oauth/success?serverName=test-server');
|
|
|
|
// Verify completeOAuthFlow was NOT called (prevented duplicate)
|
|
expect(MCPOAuthHandler.completeOAuthFlow).not.toHaveBeenCalled();
|
|
expect(MCPTokenStorage.storeTokens).not.toHaveBeenCalled();
|
|
});
|
|
});
|
|
|
|
describe('GET /oauth/tokens/:flowId', () => {
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
it('should return tokens for completed flow', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'COMPLETED',
|
|
result: {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
},
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:flow-123');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
tokens: {
|
|
access_token: 'test-access-token',
|
|
refresh_token: 'test-refresh-token',
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should return 401 when user is not authenticated', async () => {
|
|
const unauthApp = express();
|
|
unauthApp.use(express.json());
|
|
unauthApp.use((req, res, next) => {
|
|
req.user = null;
|
|
next();
|
|
});
|
|
unauthApp.use('/api/mcp', mcpRouter);
|
|
|
|
const response = await request(unauthApp).get('/api/mcp/oauth/tokens/test-flow-id');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({ error: 'User not authenticated' });
|
|
});
|
|
|
|
it('should return 403 when user tries to access flow they do not own', async () => {
|
|
const response = await request(app).get('/api/mcp/oauth/tokens/other-user-id:flow-123');
|
|
|
|
expect(response.status).toBe(403);
|
|
expect(response.body).toEqual({ error: 'Access denied' });
|
|
});
|
|
|
|
it('should return 404 when flow is not found', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue(null),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get(
|
|
'/api/mcp/oauth/tokens/test-user-id:non-existent-flow',
|
|
);
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({ error: 'Flow not found' });
|
|
});
|
|
|
|
it('should return 400 when flow is not completed', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'PENDING',
|
|
result: null,
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:pending-flow');
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body).toEqual({ error: 'Flow not completed' });
|
|
});
|
|
|
|
it('should return 500 when token retrieval throws an unexpected error', async () => {
|
|
getLogStores.mockImplementation(() => {
|
|
throw new Error('Database connection failed');
|
|
});
|
|
|
|
const response = await request(app).get('/api/mcp/oauth/tokens/test-user-id:error-flow');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Failed to get tokens' });
|
|
});
|
|
});
|
|
|
|
describe('GET /oauth/status/:flowId', () => {
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
it('should return flow status when flow exists', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'PENDING',
|
|
error: null,
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/oauth/status/test-flow-id');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
status: 'PENDING',
|
|
completed: false,
|
|
failed: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it('should return 404 when flow is not found', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue(null),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/oauth/status/non-existent-flow');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({ error: 'Flow not found' });
|
|
});
|
|
|
|
it('should return 500 when status check fails', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockRejectedValue(new Error('Database error')),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app).get('/api/mcp/oauth/status/error-flow-id');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Failed to get flow status' });
|
|
});
|
|
});
|
|
|
|
describe('POST /oauth/cancel/:serverName', () => {
|
|
const { MCPOAuthHandler } = require('@librechat/api');
|
|
const { getLogStores } = require('~/cache');
|
|
|
|
it('should cancel OAuth flow successfully', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'PENDING',
|
|
}),
|
|
failFlow: jest.fn().mockResolvedValue(),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
|
|
|
|
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
message: 'OAuth flow for test-server cancelled successfully',
|
|
});
|
|
|
|
expect(mockFlowManager.failFlow).toHaveBeenCalledWith(
|
|
'test-user-id:test-server',
|
|
'mcp_oauth',
|
|
'User cancelled OAuth flow',
|
|
);
|
|
});
|
|
|
|
it('should return success message when no active flow exists', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue(null),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
|
|
|
|
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
message: 'No active OAuth flow to cancel',
|
|
});
|
|
});
|
|
|
|
it('should return 500 when cancellation fails', async () => {
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
|
failFlow: jest.fn().mockRejectedValue(new Error('Database error')),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
MCPOAuthHandler.generateFlowId.mockReturnValue('test-user-id:test-server');
|
|
|
|
const response = await request(app).post('/api/mcp/oauth/cancel/test-server');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Failed to cancel OAuth flow' });
|
|
});
|
|
|
|
it('should return 401 when user is not authenticated', async () => {
|
|
const unauthApp = express();
|
|
unauthApp.use(express.json());
|
|
unauthApp.use((req, res, next) => {
|
|
req.user = null;
|
|
next();
|
|
});
|
|
unauthApp.use('/api/mcp', mcpRouter);
|
|
|
|
const response = await request(unauthApp).post('/api/mcp/oauth/cancel/test-server');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({ error: 'User not authenticated' });
|
|
});
|
|
});
|
|
|
|
describe('POST /:serverName/reinitialize', () => {
|
|
// mockRegistryInstance is defined at the top of the file
|
|
|
|
it('should return 404 when server is not found in configuration', async () => {
|
|
const mockMcpManager = {
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
|
};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
require('~/config').getFlowStateManager.mockReturnValue({});
|
|
require('~/cache').getLogStores.mockReturnValue({});
|
|
|
|
const response = await request(app).post('/api/mcp/non-existent-server/reinitialize');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({
|
|
error: "MCP server 'non-existent-server' not found in configuration",
|
|
});
|
|
});
|
|
|
|
it('should handle OAuth requirement during reinitialize', async () => {
|
|
const mockMcpManager = {
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
|
mcpConfigs: {},
|
|
getUserConnection: jest.fn().mockImplementation(async ({ oauthStart }) => {
|
|
if (oauthStart) {
|
|
await oauthStart('https://oauth.example.com/auth');
|
|
}
|
|
throw new Error('OAuth flow initiated - return early');
|
|
}),
|
|
};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
customUserVars: {},
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
require('~/config').getFlowStateManager.mockReturnValue({});
|
|
require('~/cache').getLogStores.mockReturnValue({});
|
|
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
|
success: true,
|
|
message: "MCP server 'oauth-server' ready for OAuth authentication",
|
|
serverName: 'oauth-server',
|
|
oauthRequired: true,
|
|
oauthUrl: 'https://oauth.example.com/auth',
|
|
});
|
|
|
|
const response = await request(app).post('/api/mcp/oauth-server/reinitialize');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
message: "MCP server 'oauth-server' ready for OAuth authentication",
|
|
serverName: 'oauth-server',
|
|
oauthRequired: true,
|
|
oauthUrl: 'https://oauth.example.com/auth',
|
|
});
|
|
});
|
|
|
|
it('should return 500 when reinitialize fails with non-OAuth error', async () => {
|
|
const mockMcpManager = {
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
|
mcpConfigs: {},
|
|
getUserConnection: jest.fn().mockRejectedValue(new Error('Connection failed')),
|
|
};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
require('~/config').getFlowStateManager.mockReturnValue({});
|
|
require('~/cache').getLogStores.mockReturnValue({});
|
|
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue(null);
|
|
|
|
const response = await request(app).post('/api/mcp/error-server/reinitialize');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({
|
|
error: 'Failed to reinitialize MCP server for user',
|
|
});
|
|
});
|
|
|
|
it('should return 500 when unexpected error occurs', async () => {
|
|
const mockMcpManager = {
|
|
disconnectUserConnection: jest.fn(),
|
|
};
|
|
|
|
mockRegistryInstance.getServerConfig.mockImplementation(() => {
|
|
throw new Error('Config loading failed');
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Internal server error' });
|
|
});
|
|
|
|
it('should return 401 when user is not authenticated', async () => {
|
|
const unauthApp = express();
|
|
unauthApp.use(express.json());
|
|
unauthApp.use((req, res, next) => {
|
|
req.user = null;
|
|
next();
|
|
});
|
|
unauthApp.use('/api/mcp', mcpRouter);
|
|
|
|
const response = await request(unauthApp).post('/api/mcp/test-server/reinitialize');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({ error: 'User not authenticated' });
|
|
});
|
|
|
|
it('should successfully reinitialize server and cache tools', async () => {
|
|
const mockUserConnection = {
|
|
fetchTools: jest.fn().mockResolvedValue([
|
|
{ name: 'tool1', description: 'Test tool 1', inputSchema: { type: 'object' } },
|
|
{ name: 'tool2', description: 'Test tool 2', inputSchema: { type: 'object' } },
|
|
]),
|
|
};
|
|
|
|
const mockMcpManager = {
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
|
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
|
};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
endpoint: 'http://test-server.com',
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
require('~/config').getFlowStateManager.mockReturnValue({});
|
|
require('~/cache').getLogStores.mockReturnValue({});
|
|
|
|
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
|
getCachedTools.mockResolvedValue({});
|
|
setCachedTools.mockResolvedValue();
|
|
updateMCPServerTools.mockResolvedValue();
|
|
|
|
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
|
success: true,
|
|
message: "MCP server 'test-server' reinitialized successfully",
|
|
serverName: 'test-server',
|
|
oauthRequired: false,
|
|
oauthUrl: null,
|
|
});
|
|
|
|
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
message: "MCP server 'test-server' reinitialized successfully",
|
|
serverName: 'test-server',
|
|
oauthRequired: false,
|
|
oauthUrl: null,
|
|
});
|
|
expect(mockMcpManager.disconnectUserConnection).toHaveBeenCalledWith(
|
|
'test-user-id',
|
|
'test-server',
|
|
);
|
|
});
|
|
|
|
it('should handle server with custom user variables', async () => {
|
|
const mockUserConnection = {
|
|
fetchTools: jest.fn().mockResolvedValue([]),
|
|
};
|
|
|
|
const mockMcpManager = {
|
|
disconnectUserConnection: jest.fn().mockResolvedValue(),
|
|
getUserConnection: jest.fn().mockResolvedValue(mockUserConnection),
|
|
};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
endpoint: 'http://test-server.com',
|
|
customUserVars: {
|
|
API_KEY: 'some-env-var',
|
|
},
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
require('~/config').getFlowStateManager.mockReturnValue({});
|
|
require('~/cache').getLogStores.mockReturnValue({});
|
|
require('@librechat/api').getUserMCPAuthMap.mockResolvedValue({
|
|
'mcp:test-server': {
|
|
API_KEY: 'api-key-value',
|
|
},
|
|
});
|
|
require('~/models').findPluginAuthsByKeys.mockResolvedValue([
|
|
{ key: 'API_KEY', value: 'api-key-value' },
|
|
]);
|
|
|
|
const { getCachedTools, setCachedTools } = require('~/server/services/Config');
|
|
const { updateMCPServerTools } = require('~/server/services/Config/mcp');
|
|
getCachedTools.mockResolvedValue({});
|
|
setCachedTools.mockResolvedValue();
|
|
updateMCPServerTools.mockResolvedValue();
|
|
|
|
require('~/server/services/Tools/mcp').reinitMCPServer.mockResolvedValue({
|
|
success: true,
|
|
message: "MCP server 'test-server' reinitialized successfully",
|
|
serverName: 'test-server',
|
|
oauthRequired: false,
|
|
oauthUrl: null,
|
|
});
|
|
|
|
const response = await request(app).post('/api/mcp/test-server/reinitialize');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body.success).toBe(true);
|
|
expect(require('@librechat/api').getUserMCPAuthMap).toHaveBeenCalledWith({
|
|
userId: 'test-user-id',
|
|
servers: ['test-server'],
|
|
findPluginAuthsByKeys: require('~/models').findPluginAuthsByKeys,
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('GET /connection/status', () => {
|
|
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
|
|
|
it('should return connection status for all servers', async () => {
|
|
const mockMcpConfig = {
|
|
server1: { endpoint: 'http://server1.com' },
|
|
server2: { endpoint: 'http://server2.com' },
|
|
};
|
|
|
|
getMCPSetupData.mockResolvedValue({
|
|
mcpConfig: mockMcpConfig,
|
|
appConnections: {},
|
|
userConnections: {},
|
|
oauthServers: [],
|
|
});
|
|
|
|
getServerConnectionStatus
|
|
.mockResolvedValueOnce({
|
|
connectionState: 'connected',
|
|
requiresOAuth: false,
|
|
})
|
|
.mockResolvedValueOnce({
|
|
connectionState: 'disconnected',
|
|
requiresOAuth: true,
|
|
});
|
|
|
|
const response = await request(app).get('/api/mcp/connection/status');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
connectionStatus: {
|
|
server1: {
|
|
connectionState: 'connected',
|
|
requiresOAuth: false,
|
|
},
|
|
server2: {
|
|
connectionState: 'disconnected',
|
|
requiresOAuth: true,
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(getMCPSetupData).toHaveBeenCalledWith('test-user-id');
|
|
expect(getServerConnectionStatus).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should return 404 when MCP config is not found', async () => {
|
|
getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
|
|
|
|
const response = await request(app).get('/api/mcp/connection/status');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({ error: 'MCP config not found' });
|
|
});
|
|
|
|
it('should return 500 when connection status check fails', async () => {
|
|
getMCPSetupData.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app).get('/api/mcp/connection/status');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Failed to get connection status' });
|
|
});
|
|
|
|
it('should return 401 when user is not authenticated', async () => {
|
|
const unauthApp = express();
|
|
unauthApp.use(express.json());
|
|
unauthApp.use((req, res, next) => {
|
|
req.user = null;
|
|
next();
|
|
});
|
|
unauthApp.use('/api/mcp', mcpRouter);
|
|
|
|
const response = await request(unauthApp).get('/api/mcp/connection/status');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({ error: 'User not authenticated' });
|
|
});
|
|
});
|
|
|
|
describe('GET /connection/status/:serverName', () => {
|
|
const { getMCPSetupData, getServerConnectionStatus } = require('~/server/services/MCP');
|
|
|
|
it('should return connection status for OAuth-required server', async () => {
|
|
const mockMcpConfig = {
|
|
'oauth-server': { endpoint: 'http://oauth-server.com' },
|
|
};
|
|
|
|
getMCPSetupData.mockResolvedValue({
|
|
mcpConfig: mockMcpConfig,
|
|
appConnections: {},
|
|
userConnections: {},
|
|
oauthServers: [],
|
|
});
|
|
|
|
getServerConnectionStatus.mockResolvedValue({
|
|
connectionState: 'requires_auth',
|
|
requiresOAuth: true,
|
|
});
|
|
|
|
const response = await request(app).get('/api/mcp/connection/status/oauth-server');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
serverName: 'oauth-server',
|
|
connectionStatus: 'requires_auth',
|
|
requiresOAuth: true,
|
|
});
|
|
});
|
|
|
|
it('should return 404 when server is not found in configuration', async () => {
|
|
getMCPSetupData.mockResolvedValue({
|
|
mcpConfig: {
|
|
'other-server': { endpoint: 'http://other-server.com' },
|
|
},
|
|
appConnections: {},
|
|
userConnections: {},
|
|
oauthServers: [],
|
|
});
|
|
|
|
const response = await request(app).get('/api/mcp/connection/status/non-existent-server');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({
|
|
error: "MCP server 'non-existent-server' not found in configuration",
|
|
});
|
|
});
|
|
|
|
it('should return 404 when MCP config is not found', async () => {
|
|
getMCPSetupData.mockRejectedValue(new Error('MCP config not found'));
|
|
|
|
const response = await request(app).get('/api/mcp/connection/status/test-server');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({ error: 'MCP config not found' });
|
|
});
|
|
|
|
it('should return 500 when connection status check fails', async () => {
|
|
getMCPSetupData.mockRejectedValue(new Error('Database connection failed'));
|
|
|
|
const response = await request(app).get('/api/mcp/connection/status/test-server');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Failed to get connection status' });
|
|
});
|
|
|
|
it('should return 401 when user is not authenticated', async () => {
|
|
const unauthApp = express();
|
|
unauthApp.use(express.json());
|
|
unauthApp.use((req, res, next) => {
|
|
req.user = null;
|
|
next();
|
|
});
|
|
unauthApp.use('/api/mcp', mcpRouter);
|
|
|
|
const response = await request(unauthApp).get('/api/mcp/connection/status/test-server');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({ error: 'User not authenticated' });
|
|
});
|
|
});
|
|
|
|
describe('GET /:serverName/auth-values', () => {
|
|
const { getUserPluginAuthValue } = require('~/server/services/PluginService');
|
|
// mockRegistryInstance is defined at the top of the file
|
|
|
|
it('should return auth value flags for server', async () => {
|
|
const mockMcpManager = {};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
customUserVars: {
|
|
API_KEY: 'some-env-var',
|
|
SECRET_TOKEN: 'another-env-var',
|
|
},
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
getUserPluginAuthValue.mockResolvedValueOnce('some-api-key-value').mockResolvedValueOnce('');
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
serverName: 'test-server',
|
|
authValueFlags: {
|
|
API_KEY: true,
|
|
SECRET_TOKEN: false,
|
|
},
|
|
});
|
|
|
|
expect(getUserPluginAuthValue).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('should return 404 when server is not found in configuration', async () => {
|
|
const mockMcpManager = {};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const response = await request(app).get('/api/mcp/non-existent-server/auth-values');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({
|
|
error: "MCP server 'non-existent-server' not found in configuration",
|
|
});
|
|
});
|
|
|
|
it('should handle errors when checking auth values', async () => {
|
|
const mockMcpManager = {};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
customUserVars: {
|
|
API_KEY: 'some-env-var',
|
|
},
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
getUserPluginAuthValue.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
serverName: 'test-server',
|
|
authValueFlags: {
|
|
API_KEY: false,
|
|
},
|
|
});
|
|
});
|
|
|
|
it('should return 500 when auth values check throws unexpected error', async () => {
|
|
const mockMcpManager = {};
|
|
|
|
mockRegistryInstance.getServerConfig.mockImplementation(() => {
|
|
throw new Error('Config loading failed');
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Failed to check auth value flags' });
|
|
});
|
|
|
|
it('should handle customUserVars that is not an object', async () => {
|
|
const mockMcpManager = {};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({
|
|
customUserVars: 'not-an-object',
|
|
});
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/auth-values');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
success: true,
|
|
serverName: 'test-server',
|
|
authValueFlags: {},
|
|
});
|
|
});
|
|
|
|
it('should return 401 when user is not authenticated in auth-values endpoint', async () => {
|
|
const appWithoutAuth = express();
|
|
appWithoutAuth.use(express.json());
|
|
appWithoutAuth.use('/api/mcp', mcpRouter);
|
|
|
|
const response = await request(appWithoutAuth).get('/api/mcp/test-server/auth-values');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({ error: 'User not authenticated' });
|
|
});
|
|
});
|
|
|
|
describe('GET /:serverName/oauth/callback - Edge Cases', () => {
|
|
it('should handle OAuth callback without toolFlowId (falsy toolFlowId)', async () => {
|
|
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
|
|
const mockTokens = {
|
|
access_token: 'edge-access-token',
|
|
refresh_token: 'edge-refresh-token',
|
|
};
|
|
MCPOAuthHandler.getFlowState = jest.fn().mockResolvedValue({
|
|
id: 'test-flow-id',
|
|
userId: 'test-user-id',
|
|
metadata: {
|
|
serverUrl: 'https://example.com',
|
|
oauth: {},
|
|
// No toolFlowId property
|
|
},
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
});
|
|
MCPOAuthHandler.completeOAuthFlow = jest.fn().mockResolvedValue(mockTokens);
|
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({ status: 'PENDING' }),
|
|
completeFlow: jest.fn(),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const mockMcpManager = {
|
|
getUserConnection: jest.fn().mockResolvedValue({
|
|
fetchTools: jest.fn().mockResolvedValue([]),
|
|
}),
|
|
};
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback?code=test-code&state=test-flow-id')
|
|
.expect(302);
|
|
|
|
expect(mockFlowManager.completeFlow).not.toHaveBeenCalled();
|
|
expect(response.headers.location).toContain('/oauth/success');
|
|
});
|
|
|
|
it('should handle null cached tools in OAuth callback (triggers || {} fallback)', async () => {
|
|
const { getCachedTools } = require('~/server/services/Config');
|
|
getCachedTools.mockResolvedValue(null);
|
|
const { MCPOAuthHandler, MCPTokenStorage } = require('@librechat/api');
|
|
const mockTokens = {
|
|
access_token: 'edge-access-token',
|
|
refresh_token: 'edge-refresh-token',
|
|
};
|
|
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
id: 'test-flow-id',
|
|
userId: 'test-user-id',
|
|
metadata: { serverUrl: 'https://example.com', oauth: {} },
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
}),
|
|
completeFlow: jest.fn(),
|
|
};
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue({
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
metadata: { serverUrl: 'https://example.com', oauth: {} },
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
});
|
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue(mockTokens);
|
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
|
|
const mockMcpManager = {
|
|
getUserConnection: jest.fn().mockResolvedValue({
|
|
fetchTools: jest
|
|
.fn()
|
|
.mockResolvedValue([{ name: 'test-tool', description: 'Test tool' }]),
|
|
}),
|
|
};
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback?code=test-code&state=test-flow-id')
|
|
.expect(302);
|
|
|
|
expect(response.headers.location).toContain('/oauth/success');
|
|
});
|
|
});
|
|
|
|
describe('GET /servers', () => {
|
|
// mockRegistryInstance is defined at the top of the file
|
|
|
|
it('should return all server configs for authenticated user', async () => {
|
|
const mockServerConfigs = {
|
|
'server-1': {
|
|
endpoint: 'http://server1.com',
|
|
name: 'Server 1',
|
|
},
|
|
'server-2': {
|
|
endpoint: 'http://server2.com',
|
|
name: 'Server 2',
|
|
},
|
|
};
|
|
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue(mockServerConfigs);
|
|
|
|
const response = await request(app).get('/api/mcp/servers');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual(mockServerConfigs);
|
|
expect(mockRegistryInstance.getAllServerConfigs).toHaveBeenCalledWith('test-user-id');
|
|
});
|
|
|
|
it('should return empty object when no servers are configured', async () => {
|
|
mockRegistryInstance.getAllServerConfigs.mockResolvedValue({});
|
|
|
|
const response = await request(app).get('/api/mcp/servers');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({});
|
|
});
|
|
|
|
it('should return 401 when user is not authenticated', async () => {
|
|
const unauthApp = express();
|
|
unauthApp.use(express.json());
|
|
unauthApp.use((req, _res, next) => {
|
|
req.user = null;
|
|
next();
|
|
});
|
|
unauthApp.use('/api/mcp', mcpRouter);
|
|
|
|
const response = await request(unauthApp).get('/api/mcp/servers');
|
|
|
|
expect(response.status).toBe(401);
|
|
expect(response.body).toEqual({ message: 'Unauthorized' });
|
|
});
|
|
|
|
it('should return 500 when server config retrieval fails', async () => {
|
|
mockRegistryInstance.getAllServerConfigs.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app).get('/api/mcp/servers');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ error: 'Database error' });
|
|
});
|
|
});
|
|
|
|
describe('POST /servers', () => {
|
|
it('should create MCP server with valid SSE config', async () => {
|
|
const validConfig = {
|
|
type: 'sse',
|
|
url: 'https://mcp-server.example.com/sse',
|
|
title: 'Test SSE Server',
|
|
description: 'A test SSE server',
|
|
};
|
|
|
|
mockRegistryInstance.addServer.mockResolvedValue({
|
|
serverName: 'test-sse-server',
|
|
config: validConfig,
|
|
});
|
|
|
|
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body).toEqual({
|
|
serverName: 'test-sse-server',
|
|
...validConfig,
|
|
});
|
|
expect(mockRegistryInstance.addServer).toHaveBeenCalledWith(
|
|
'temp_server_name',
|
|
expect.objectContaining({
|
|
type: 'sse',
|
|
url: 'https://mcp-server.example.com/sse',
|
|
}),
|
|
'DB',
|
|
'test-user-id',
|
|
);
|
|
});
|
|
|
|
it('should create MCP server with valid stdio config', async () => {
|
|
const validConfig = {
|
|
type: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js'],
|
|
title: 'Test Stdio Server',
|
|
};
|
|
|
|
mockRegistryInstance.addServer.mockResolvedValue({
|
|
serverName: 'test-stdio-server',
|
|
config: validConfig,
|
|
});
|
|
|
|
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
|
|
|
expect(response.status).toBe(201);
|
|
expect(response.body.serverName).toBe('test-stdio-server');
|
|
});
|
|
|
|
it('should return 400 for invalid configuration', async () => {
|
|
const invalidConfig = {
|
|
type: 'sse',
|
|
// Missing required 'url' field
|
|
title: 'Invalid Server',
|
|
};
|
|
|
|
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe('Invalid configuration');
|
|
expect(response.body.errors).toBeDefined();
|
|
});
|
|
|
|
it('should return 400 for SSE config with invalid URL protocol', async () => {
|
|
const invalidConfig = {
|
|
type: 'sse',
|
|
url: 'ws://invalid-protocol.example.com/sse',
|
|
title: 'Invalid Protocol Server',
|
|
};
|
|
|
|
const response = await request(app).post('/api/mcp/servers').send({ config: invalidConfig });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe('Invalid configuration');
|
|
});
|
|
|
|
it('should return 500 when registry throws error', async () => {
|
|
const validConfig = {
|
|
type: 'sse',
|
|
url: 'https://mcp-server.example.com/sse',
|
|
title: 'Test Server',
|
|
};
|
|
|
|
mockRegistryInstance.addServer.mockRejectedValue(new Error('Database connection failed'));
|
|
|
|
const response = await request(app).post('/api/mcp/servers').send({ config: validConfig });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ message: 'Database connection failed' });
|
|
});
|
|
});
|
|
|
|
describe('GET /servers/:serverName', () => {
|
|
it('should return server config when found', async () => {
|
|
const mockConfig = {
|
|
type: 'sse',
|
|
url: 'https://mcp-server.example.com/sse',
|
|
title: 'Test Server',
|
|
};
|
|
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue(mockConfig);
|
|
|
|
const response = await request(app).get('/api/mcp/servers/test-server');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual(mockConfig);
|
|
expect(mockRegistryInstance.getServerConfig).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'test-user-id',
|
|
);
|
|
});
|
|
|
|
it('should return 404 when server not found', async () => {
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue(null);
|
|
|
|
const response = await request(app).get('/api/mcp/servers/non-existent-server');
|
|
|
|
expect(response.status).toBe(404);
|
|
expect(response.body).toEqual({ message: 'MCP server not found' });
|
|
});
|
|
|
|
it('should return 500 when registry throws error', async () => {
|
|
mockRegistryInstance.getServerConfig.mockRejectedValue(new Error('Database error'));
|
|
|
|
const response = await request(app).get('/api/mcp/servers/error-server');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ message: 'Database error' });
|
|
});
|
|
});
|
|
|
|
describe('PATCH /servers/:serverName', () => {
|
|
it('should update server with valid config', async () => {
|
|
const updatedConfig = {
|
|
type: 'sse',
|
|
url: 'https://updated-mcp-server.example.com/sse',
|
|
title: 'Updated Server',
|
|
description: 'Updated description',
|
|
};
|
|
|
|
mockRegistryInstance.updateServer.mockResolvedValue(updatedConfig);
|
|
|
|
const response = await request(app)
|
|
.patch('/api/mcp/servers/test-server')
|
|
.send({ config: updatedConfig });
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual(updatedConfig);
|
|
expect(mockRegistryInstance.updateServer).toHaveBeenCalledWith(
|
|
'test-server',
|
|
expect.objectContaining({
|
|
type: 'sse',
|
|
url: 'https://updated-mcp-server.example.com/sse',
|
|
}),
|
|
'DB',
|
|
'test-user-id',
|
|
);
|
|
});
|
|
|
|
it('should return 400 for invalid configuration', async () => {
|
|
const invalidConfig = {
|
|
type: 'sse',
|
|
// Missing required 'url' field
|
|
title: 'Invalid Update',
|
|
};
|
|
|
|
const response = await request(app)
|
|
.patch('/api/mcp/servers/test-server')
|
|
.send({ config: invalidConfig });
|
|
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe('Invalid configuration');
|
|
expect(response.body.errors).toBeDefined();
|
|
});
|
|
|
|
it('should return 500 when registry throws error', async () => {
|
|
const validConfig = {
|
|
type: 'sse',
|
|
url: 'https://mcp-server.example.com/sse',
|
|
title: 'Test Server',
|
|
};
|
|
|
|
mockRegistryInstance.updateServer.mockRejectedValue(new Error('Update failed'));
|
|
|
|
const response = await request(app)
|
|
.patch('/api/mcp/servers/test-server')
|
|
.send({ config: validConfig });
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ message: 'Update failed' });
|
|
});
|
|
});
|
|
|
|
describe('DELETE /servers/:serverName', () => {
|
|
it('should delete server successfully', async () => {
|
|
mockRegistryInstance.removeServer.mockResolvedValue(undefined);
|
|
|
|
const response = await request(app).delete('/api/mcp/servers/test-server');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({ message: 'MCP server deleted successfully' });
|
|
expect(mockRegistryInstance.removeServer).toHaveBeenCalledWith(
|
|
'test-server',
|
|
'DB',
|
|
'test-user-id',
|
|
);
|
|
});
|
|
|
|
it('should return 500 when registry throws error', async () => {
|
|
mockRegistryInstance.removeServer.mockRejectedValue(new Error('Deletion failed'));
|
|
|
|
const response = await request(app).delete('/api/mcp/servers/error-server');
|
|
|
|
expect(response.status).toBe(500);
|
|
expect(response.body).toEqual({ message: 'Deletion failed' });
|
|
});
|
|
});
|
|
});
|