mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-01-13 05:58:51 +01:00
Some checks failed
Docker Dev Branch Images Build / build (Dockerfile, lc-dev, node) (push) Has been cancelled
Docker Dev Branch Images Build / build (Dockerfile.multi, lc-dev-api, api-build) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile, librechat-dev, node) (push) Has been cancelled
Docker Dev Images Build / build (Dockerfile.multi, librechat-dev-api, api-build) (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Sync Translation Keys with Locize (push) Has been cancelled
Sync Locize Translations & Create Translation PR / Create Translation PR on Version Published (push) Has been cancelled
- Updated MCP server configuration tests to reject stdio transport configurations, ensuring that only remote transports (SSE, HTTP, WebSocket) are allowed via the API. - Enhanced documentation to clarify that stdio transport is excluded from user input for security, as it allows arbitrary command execution and should only be configured by administrators through YAML files.
1739 lines
59 KiB
JavaScript
1739 lines
59 KiB
JavaScript
const express = require('express');
|
|
const request = require('supertest');
|
|
const mongoose = require('mongoose');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
const { getBasePath } = require('@librechat/api');
|
|
|
|
const mockRegistryInstance = {
|
|
getServerConfig: jest.fn(),
|
|
getOAuthServers: jest.fn(),
|
|
getAllServerConfigs: jest.fn(),
|
|
addServer: jest.fn(),
|
|
updateServer: jest.fn(),
|
|
removeServer: jest.fn(),
|
|
};
|
|
|
|
jest.mock('@librechat/api', () => {
|
|
const actual = jest.requireActual('@librechat/api');
|
|
return {
|
|
...actual,
|
|
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,
|
|
},
|
|
// Error handling utilities (from @librechat/api mcp/errors)
|
|
isMCPDomainNotAllowedError: (error) => error?.code === 'MCP_DOMAIN_NOT_ALLOWED',
|
|
isMCPInspectionFailedError: (error) => error?.code === 'MCP_INSPECTION_FAILED',
|
|
MCPErrorCodes: {
|
|
DOMAIN_NOT_ALLOWED: 'MCP_DOMAIN_NOT_ALLOWED',
|
|
INSPECTION_FAILED: 'MCP_INSPECTION_FAILED',
|
|
},
|
|
};
|
|
});
|
|
|
|
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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/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);
|
|
|
|
const basePath = getBasePath();
|
|
|
|
expect(mockFlowManager.completeFlow).not.toHaveBeenCalled();
|
|
expect(response.headers.location).toContain(`${basePath}/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);
|
|
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.headers.location).toContain(`${basePath}/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 reject stdio config for security reasons', async () => {
|
|
const stdioConfig = {
|
|
type: 'stdio',
|
|
command: 'node',
|
|
args: ['server.js'],
|
|
title: 'Test Stdio Server',
|
|
};
|
|
|
|
const response = await request(app).post('/api/mcp/servers').send({ config: stdioConfig });
|
|
|
|
// Stdio transport is not allowed via API - only admins can configure it via YAML
|
|
expect(response.status).toBe(400);
|
|
expect(response.body.message).toBe('Invalid configuration');
|
|
});
|
|
|
|
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' });
|
|
});
|
|
});
|
|
});
|