mirror of
https://github.com/danny-avila/LibreChat.git
synced 2026-03-12 02:52:36 +01:00
* fix: Implement race conditions in MCP OAuth flow - Added connection mutex to coalesce concurrent `getUserConnection` calls, preventing multiple simultaneous attempts. - Enhanced flow state management to retry once when a flow state is missing, improving resilience against race conditions. - Introduced `ReauthenticationRequiredError` for better error handling when access tokens are expired or missing. - Updated tests to cover new race condition scenarios and ensure proper handling of OAuth flows. * fix: Stale PENDING flow detection and OAuth URL re-issuance PENDING flows in handleOAuthRequired now check createdAt age — flows older than 2 minutes are treated as stale and replaced instead of joined. Fixes the case where a leftover PENDING flow from a previous session blocks new OAuth initiation. authorizationUrl is now stored in MCPOAuthFlowMetadata so that when a second caller joins an active PENDING flow (e.g., the SSE-emitting path in ToolService), it can re-issue the URL to the user via oauthStart. * fix: CSRF fallback via active PENDING flow in OAuth callback When the OAuth callback arrives without CSRF or session cookies (common in the chat/SSE flow where cookies can't be set on streaming responses), fall back to validating that a PENDING flow exists for the flowId. This is safe because the flow was created server-side after JWT authentication and the authorization code is PKCE-protected. * test: Extract shared OAuth test server helpers Move MockKeyv, getFreePort, trackSockets, and createOAuthMCPServer into a shared helpers/oauthTestServer module. Enhance the test server with refresh token support, token rotation, metadata discovery, and dynamic client registration endpoints. Add InMemoryTokenStore for token storage tests. Refactor MCPOAuthRaceCondition.test.ts to import from shared helpers. * test: Add comprehensive MCP OAuth test modules MCPOAuthTokenStorage — 21 tests for storeTokens/getTokens with InMemoryTokenStore: encrypt/decrypt round-trips, expiry calculation, refresh callback wiring, ReauthenticationRequiredError paths. MCPOAuthFlow — 10 tests against real HTTP server: token refresh with stored client info, refresh token rotation, metadata discovery, dynamic client registration, full store/retrieve/expire/refresh lifecycle. MCPOAuthConnectionEvents — 5 tests for MCPConnection OAuth event cycle with real OAuth-gated MCP server: oauthRequired emission on 401, oauthHandled reconnection, oauthFailed rejection, token expiry detection. MCPOAuthTokenExpiry — 12 tests for the token expiry edge case: refresh success/failure paths, ReauthenticationRequiredError, PENDING flow CSRF fallback, authorizationUrl metadata storage, full re-auth cycle after refresh failure, concurrent expired token coalescing, stale PENDING flow detection. * test: Enhance MCP OAuth connection tests with cooldown reset Added a `beforeEach` hook to clear the cooldown for `MCPConnection` before each test, ensuring a clean state. Updated the race condition handling in the tests to properly clear the timeout, improving reliability in the event data retrieval process. * refactor: PENDING flow management and state recovery in MCP OAuth - Introduced a constant `PENDING_STALE_MS` to define the age threshold for PENDING flows, improving the handling of stale flows. - Updated the logic in `MCPConnectionFactory` and `FlowStateManager` to check the age of PENDING flows before joining or reusing them. - Modified the `completeFlow` method to return false when the flow state is deleted, ensuring graceful handling of race conditions. - Enhanced tests to validate the new behavior and ensure robustness against state recovery issues. * refactor: MCP OAuth flow management and testing - Updated the `completeFlow` method to log warnings when a tool flow state is not found during completion, improving error handling. - Introduced a new `normalizeExpiresAt` function to standardize expiration timestamp handling across the application. - Refactored token expiration checks in `MCPConnectionFactory` to utilize the new normalization function, ensuring consistent behavior. - Added a comprehensive test suite for OAuth callback CSRF fallback logic, validating the handling of PENDING flows and their staleness. - Enhanced existing tests to cover new expiration normalization logic and ensure robust flow state management. * test: Add CSRF fallback tests for active PENDING flows in MCP OAuth - Introduced new tests to validate CSRF fallback behavior when a fresh PENDING flow exists without cookies, ensuring successful OAuth callback handling. - Added scenarios to reject requests when no PENDING flow exists, when only a COMPLETED flow is present, and when a PENDING flow is stale, enhancing the robustness of flow state management. - Improved overall test coverage for OAuth callback logic, reinforcing the handling of CSRF validation failures. * chore: imports order * refactor: Update UserConnectionManager to conditionally manage pending connections - Modified the logic in `UserConnectionManager` to only set pending connections if `forceNew` is false, preventing unnecessary overwrites. - Adjusted the cleanup process to ensure pending connections are only deleted when not forced, enhancing connection management efficiency. * refactor: MCP OAuth flow state management - Introduced a new method `storeStateMapping` in `MCPOAuthHandler` to securely map the OAuth state parameter to the flow ID, improving callback resolution and security against forgery. - Updated the OAuth initiation and callback handling in `mcp.js` to utilize the new state mapping functionality, ensuring robust flow management. - Refactored `MCPConnectionFactory` to store state mappings during flow initialization, enhancing the integrity of the OAuth process. - Adjusted comments to clarify the purpose of state parameters in authorization URLs, reinforcing code readability. * refactor: MCPConnection with OAuth recovery handling - Added `oauthRecovery` flag to manage OAuth recovery state during connection attempts. - Introduced `decrementCycleCount` method to reduce the circuit breaker's cycle count upon successful reconnection after OAuth recovery. - Updated connection logic to reset the `oauthRecovery` flag after handling OAuth, improving state management and connection reliability. * chore: Add debug logging for OAuth recovery cycle count decrement - Introduced a debug log statement in the `MCPConnection` class to track the decrement of the cycle count after a successful reconnection during OAuth recovery. - This enhancement improves observability and aids in troubleshooting connection issues related to OAuth recovery. * test: Add OAuth recovery cycle management tests - Introduced new tests for the OAuth recovery cycle in `MCPConnection`, validating the decrement of cycle counts after successful reconnections. - Added scenarios to ensure that the cycle count is not decremented on OAuth failures, enhancing the robustness of connection management. - Improved test coverage for OAuth reconnect scenarios, ensuring reliable behavior under various conditions. * feat: Implement circuit breaker configuration in MCP - Added circuit breaker settings to `.env.example` for max cycles, cycle window, and cooldown duration. - Refactored `MCPConnection` to utilize the new configuration values from `mcpConfig`, enhancing circuit breaker management. - Improved code maintainability by centralizing circuit breaker parameters in the configuration file. * refactor: Update decrementCycleCount method for circuit breaker management - Changed the visibility of the `decrementCycleCount` method in `MCPConnection` from private to public static, allowing it to be called with a server name parameter. - Updated calls to `decrementCycleCount` in `MCPConnectionFactory` to use the new static method, improving clarity and consistency in circuit breaker management during connection failures and OAuth recovery. - Enhanced the handling of circuit breaker state by ensuring the method checks for the existence of the circuit breaker before decrementing the cycle count. * refactor: cycle count decrement on tool listing failure - Added a call to `MCPConnection.decrementCycleCount` in the `MCPConnectionFactory` to handle cases where unauthenticated tool listing fails, improving circuit breaker management. - This change ensures that the cycle count is decremented appropriately, maintaining the integrity of the connection recovery process. * refactor: Update circuit breaker configuration and logic - Enhanced circuit breaker settings in `.env.example` to include new parameters for failed rounds and backoff strategies. - Refactored `MCPConnection` to utilize the updated configuration values from `mcpConfig`, improving circuit breaker management. - Updated tests to reflect changes in circuit breaker logic, ensuring accurate validation of connection behavior under rapid reconnect scenarios. * feat: Implement state mapping deletion in MCP flow management - Added a new method `deleteStateMapping` in `MCPOAuthHandler` to remove orphaned state mappings when a flow is replaced, preventing old authorization URLs from resolving after a flow restart. - Updated `MCPConnectionFactory` to call `deleteStateMapping` during flow cleanup, ensuring proper management of OAuth states. - Enhanced test coverage for state mapping functionality to validate the new deletion logic.
1963 lines
67 KiB
JavaScript
1963 lines
67 KiB
JavaScript
const crypto = require('crypto');
|
|
const express = require('express');
|
|
const request = require('supertest');
|
|
const mongoose = require('mongoose');
|
|
const cookieParser = require('cookie-parser');
|
|
const { getBasePath } = require('@librechat/api');
|
|
const { MongoMemoryServer } = require('mongodb-memory-server');
|
|
|
|
function generateTestCsrfToken(flowId) {
|
|
return crypto
|
|
.createHmac('sha256', process.env.JWT_SECRET)
|
|
.update(flowId)
|
|
.digest('hex')
|
|
.slice(0, 32);
|
|
}
|
|
|
|
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(),
|
|
resolveStateToFlowId: jest.fn(async (state) => state),
|
|
storeStateMapping: jest.fn(),
|
|
deleteStateMapping: 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(cookieParser());
|
|
|
|
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-user-id:test-server',
|
|
flowMetadata: { state: 'random-state-value' },
|
|
});
|
|
MCPOAuthHandler.storeStateMapping.mockResolvedValue();
|
|
mockFlowManager.initFlow = jest.fn().mockResolvedValue();
|
|
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/initiate').query({
|
|
userId: 'test-user-id',
|
|
flowId: 'test-user-id:test-server',
|
|
});
|
|
|
|
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-user-id:test-server',
|
|
});
|
|
|
|
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-user-id:test-server',
|
|
});
|
|
|
|
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-user-id:test-server',
|
|
});
|
|
|
|
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-user-id:test-server',
|
|
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-user-id:test-server',
|
|
});
|
|
|
|
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-user-id:test-server',
|
|
});
|
|
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-user-id:test-server',
|
|
});
|
|
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 CSRF cookie is missing', async () => {
|
|
const response = await request(app).get('/api/mcp/test-server/oauth/callback').query({
|
|
code: 'test-auth-code',
|
|
state: 'test-user-id:test-server',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(
|
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
|
);
|
|
});
|
|
|
|
it('should redirect to error page when CSRF cookie does not match state', async () => {
|
|
const csrfToken = generateTestCsrfToken('different-flow-id');
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: 'test-user-id:test-server',
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(
|
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
|
);
|
|
});
|
|
|
|
it('should redirect to error page when flow state is not found', async () => {
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(null);
|
|
const flowId = 'invalid-flow:id';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/oauth/error?error=invalid_state`);
|
|
});
|
|
|
|
describe('CSRF fallback via active PENDING flow', () => {
|
|
it('should proceed when a fresh PENDING flow exists and no cookies are present', async () => {
|
|
const flowId = 'test-user-id:test-server';
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'PENDING',
|
|
createdAt: Date.now(),
|
|
}),
|
|
completeFlow: jest.fn().mockResolvedValue(true),
|
|
deleteFlow: jest.fn().mockResolvedValue(true),
|
|
};
|
|
const mockFlowState = {
|
|
serverName: 'test-server',
|
|
userId: 'test-user-id',
|
|
metadata: {},
|
|
clientInfo: {},
|
|
codeVerifier: 'test-verifier',
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
MCPOAuthHandler.getFlowState.mockResolvedValue(mockFlowState);
|
|
MCPOAuthHandler.completeOAuthFlow.mockResolvedValue({
|
|
access_token: 'test-token',
|
|
});
|
|
MCPTokenStorage.storeTokens.mockResolvedValue();
|
|
mockRegistryInstance.getServerConfig.mockResolvedValue({});
|
|
|
|
const mockMcpManager = {
|
|
getUserConnection: jest.fn().mockResolvedValue({
|
|
fetchTools: jest.fn().mockResolvedValue([]),
|
|
}),
|
|
};
|
|
require('~/config').getMCPManager.mockReturnValue(mockMcpManager);
|
|
require('~/config').getOAuthReconnectionManager.mockReturnValue({
|
|
clearReconnection: jest.fn(),
|
|
});
|
|
require('~/server/services/Config/mcp').updateMCPServerTools.mockResolvedValue();
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.query({ code: 'test-code', state: flowId });
|
|
|
|
const basePath = getBasePath();
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toContain(`${basePath}/oauth/success`);
|
|
});
|
|
|
|
it('should reject when no PENDING flow exists and no cookies are present', async () => {
|
|
const flowId = 'test-user-id:test-server';
|
|
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/callback')
|
|
.query({ code: 'test-code', state: flowId });
|
|
|
|
const basePath = getBasePath();
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(
|
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
|
);
|
|
});
|
|
|
|
it('should reject when only a COMPLETED flow exists (not PENDING)', async () => {
|
|
const flowId = 'test-user-id:test-server';
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'COMPLETED',
|
|
createdAt: Date.now(),
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.query({ code: 'test-code', state: flowId });
|
|
|
|
const basePath = getBasePath();
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(
|
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
|
);
|
|
});
|
|
|
|
it('should reject when PENDING flow is stale (older than PENDING_STALE_MS)', async () => {
|
|
const flowId = 'test-user-id:test-server';
|
|
const mockFlowManager = {
|
|
getFlowState: jest.fn().mockResolvedValue({
|
|
status: 'PENDING',
|
|
createdAt: Date.now() - 3 * 60 * 1000,
|
|
}),
|
|
};
|
|
|
|
getLogStores.mockReturnValue({});
|
|
require('~/config').getFlowStateManager.mockReturnValue(mockFlowManager);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.query({ code: 'test-code', state: flowId });
|
|
|
|
const basePath = getBasePath();
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(
|
|
`${basePath}/oauth/error?error=csrf_validation_failed`,
|
|
);
|
|
});
|
|
});
|
|
|
|
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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
|
|
expect(MCPOAuthHandler.completeOAuthFlow).toHaveBeenCalledWith(
|
|
flowId,
|
|
'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-user-id:test-server',
|
|
'mcp_get_tokens',
|
|
);
|
|
});
|
|
|
|
it('should redirect to error page when callback processing fails', async () => {
|
|
MCPOAuthHandler.getFlowState.mockRejectedValue(new Error('Callback error'));
|
|
const flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
|
|
expect(mockFlowManager.deleteFlow).toHaveBeenCalledWith(flowId, '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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
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(flowId, '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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
|
|
|
|
expect(MCPTokenStorage.storeTokens).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
userId: 'test-user-id',
|
|
serverName: 'test-server',
|
|
tokens: mockTokens,
|
|
clientInfo: clientInfo,
|
|
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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get('/api/mcp/test-server/oauth/callback')
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.query({
|
|
code: 'test-auth-code',
|
|
state: flowId,
|
|
});
|
|
const basePath = getBasePath();
|
|
|
|
expect(response.status).toBe(302);
|
|
expect(response.headers.location).toBe(`${basePath}/oauth/success?serverName=test-server`);
|
|
|
|
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-user-id:test-server');
|
|
|
|
expect(response.status).toBe(200);
|
|
expect(response.body).toEqual({
|
|
status: 'PENDING',
|
|
completed: false,
|
|
failed: false,
|
|
error: null,
|
|
});
|
|
});
|
|
|
|
it('should return 403 when flowId does not match authenticated user', async () => {
|
|
const response = await request(app).get('/api/mcp/oauth/status/other-user-id:test-server');
|
|
|
|
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/status/test-user-id:non-existent');
|
|
|
|
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/test-user-id:error-server');
|
|
|
|
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-user-id:test-server',
|
|
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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.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-user-id:test-server',
|
|
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 flowId = 'test-user-id:test-server';
|
|
const csrfToken = generateTestCsrfToken(flowId);
|
|
|
|
const response = await request(app)
|
|
.get(`/api/mcp/test-server/oauth/callback?code=test-code&state=${flowId}`)
|
|
.set('Cookie', [`oauth_csrf=${csrfToken}`])
|
|
.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' });
|
|
});
|
|
});
|
|
});
|